Improve ad stripping

This commit is contained in:
pixeltris 2021-01-29 21:11:21 +00:00
parent ecffef70dd
commit a6928c9d04
17 changed files with 3277 additions and 2501 deletions

View File

@ -22,18 +22,14 @@ Alternatively:
**If you want a perfect solution, please use** `Twitch AdBlock`.
- notify-strip ([ublock](https://github.com/pixeltris/TwitchAdSolutions/raw/master/notify-strip/notify-strip-ublock-origin.js) / [userscript](https://github.com/pixeltris/TwitchAdSolutions/raw/master/notify-strip/notify-strip.user.js))
- Similar to `strip`, but notifies Twitch that ads were "watched" (reduces preroll ad frequency).
- The `strip` variant used here shouldn't have looping issues on preroll ads, but may suffer more issues on midroll ads.
- notify-strip-reload ([ublock](https://github.com/pixeltris/TwitchAdSolutions/raw/master/notify-strip-reload/notify-strip-reload-ublock-origin.js) / [userscript](https://github.com/pixeltris/TwitchAdSolutions/raw/master/notify-strip-reload/notify-strip-reload.user.js))
- Adds a reload step to `notify-strip` which may reduce issues transitioning away from the low resolution stream.
- The same as `strip`, but notifies Twitch that ads were "watched" (reduces preroll ad frequency).
- notify-reload ([ublock](https://github.com/pixeltris/TwitchAdSolutions/raw/master/notify-reload/notify-reload-ublock-origin.js) / [userscript](https://github.com/pixeltris/TwitchAdSolutions/raw/master/notify-reload/notify-reload.user.js))
- Notifies that ads were watched, then reloads the player.
- Repeats this until no ads **(which may never happen ~ infinite reload)**.
- You should expect 3-10 player reloads (give or take). Once successful you shouldn't see preroll ads for a while on any stream (10+ minutes?).
- strip ([ublock](https://github.com/pixeltris/TwitchAdSolutions/raw/master/strip/strip-ublock-origin.js) / [userscript](https://github.com/pixeltris/TwitchAdSolutions/raw/master/strip/strip.user.js))
- Ad segments are replaced by low resolution stream segments (on a m3u8 level).
- Skips 2-3 seconds when switching to the live stream.
- Stuttering and looping of segments often occur (during the ad segments).
- *TODO: Fix midrolls issues (stream freezes for several seconds / potentially longer low res than needed).*
- **NOTE: Removing segments doesn't notify Twitch that ads were watched (aka more served ads).**
- low-res ([ublock](https://github.com/pixeltris/TwitchAdSolutions/raw/master/low-res/low-res-ublock-origin.js) / [userscript](https://github.com/pixeltris/TwitchAdSolutions/raw/master/low-res/low-res.user.js))
- No ads.

View File

@ -1,7 +1,7 @@
// ==UserScript==
// @name TwitchAdSolutions
// @namespace https://github.com/pixeltris/TwitchAdSolutions
// @version 1.2
// @version 1.3
// @description Multiple solutions for blocking Twitch ads
// @author pixeltris
// @match *://*.twitch.tv/*
@ -16,15 +16,12 @@
scope.OPT_MODE_VIDEO_SWAP = false;
scope.OPT_MODE_LOW_RES = false;
scope.OPT_MODE_EMBED = false;
scope.OPT_MODE_STRIP_AD_SEGMENTS = false;// The default implementation attempts to match segment times (TODO: needs improvements - looping issues - cache matched segments and don't repeat any)
scope.OPT_MODE_STRIP_AD_SEGMENTS_NEWEST = false;// Matches segments ordered by newest
scope.OPT_MODE_STRIP_AD_SEGMENTS_NEWEST_WITH_PREFETCH = false;// This will result in a very close match, but often a repeat of a second or so. May be preferred if you dislike the regular 2-3 jump.
scope.OPT_MODE_STRIP_AD_SEGMENTS = false;
scope.OPT_MODE_NOTIFY_ADS_WATCHED = false;
scope.OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS = 1;
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_ASYNC_TOK = false;
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;
@ -92,8 +89,8 @@
}
var newBlobStr = `
${processM3U8.toString()}
${getSegmentTimes.toString()}
${getSegmentUrls.toString()}
${getSegmentInfos.toString()}
${getSegmentInfosLines.toString()}
${hookWorkerFetch.toString()}
${declareOptions.toString()}
${getAccessToken.toString()}
@ -158,33 +155,135 @@
req.send();
return req.responseText.split("'")[1];
}
function getSegmentTimes(lines) {
var result = [];
var lastDate = 0;
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-PROGRAM-DATE-TIME:')) {
lastDate = Date.parse(line.substring(line.indexOf(':') + 1));
} else if (line.startsWith('http')) {
result[lastDate] = line;
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].split(':')[1]);
}
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 getSegmentUrls(lines, includePrefetch) {
var result = [];
for (var i = 0; i < lines.length; i++) {
var line = lines[i];
if (line.startsWith('http')) {
result.push(line);
} else if (includePrefetch && line.startsWith('#EXT-X-TWITCH-PREFETCH:')) {
result.push(line.substring(line.indexOf(':') + 1));
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;
}
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) {
si.BackupSeqNumber = -1;
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 previously modified the sequence number, we need to keep doing so (alternatively pause/playing might work better)
si.FakeSeqNumber += Math.max(0, si.RealSeqNumber - oldRealSeq);
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');
}
}
if (haveAdTags) {
var si = StreamInfosByUrl[url];
if (OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST && si != null && !si.NotifyObservedNoAds) {
@ -211,7 +310,7 @@
console.log('Reload player');
postMessage({key:'UboHideAdBanner'});
postMessage({key:'UboReloadPlayer'});
return "";
return '';
}
}
postMessage({
@ -223,22 +322,13 @@
if (!OPT_MODE_STRIP_AD_SEGMENTS) {
return textStr;
}
// NOTE: midroll ads are intertwined with live segments, always display the banner on midroll ads
if (haveAdTags && (!textStr.includes(LIVE_SIGNIFIER) || textStr.includes('MIDROLL'))) {
postMessage({key:'UboShowAdBanner',isMidroll:textStr.includes('MIDROLL')});
} else if ((LastAdUrl && LastAdUrl == url) || LastAdTime < Date.now() - 10000) {
postMessage({key:'UboHideAdBanner'});
LastAdTime = 0;
}
if (haveAdTags) {
LastAdUrl = url;
LastAdTime = Date.now();
/*if (OPT_MODE_NOTIFY_ADS_WATCHED) {
console.log('Stripping ads (instead of skipping ads)');
}*/
var streamInfo = StreamInfosByUrl[url];
if (streamInfo == null) {
console.log('Unknown stream url! ' + url);
console.log('Unknown stream url ' + url);
postMessage({key:'UboHideAdBanner'});
return textStr;
}
if (!streamInfo.BackupFailed && streamInfo.BackupUrl == null) {
@ -281,71 +371,69 @@
}
}
var lines = textStr.replace('\r', '').split('\n');
if (OPT_MODE_STRIP_AD_SEGMENTS_NEWEST) {
var newLines = [];
if (backupM3u8 != null) {
var backupLines = backupM3u8.replace('\r', '').split('\n');
var segUrls = getSegmentUrls(lines, false);
var backupSegUrls = getSegmentUrls(backupLines, OPT_MODE_STRIP_AD_SEGMENTS_NEWEST_WITH_PREFETCH);
for (var i = segUrls.length - 1, j = backupSegUrls.length - 1; i >= 0 && j >= 0; i--, j--) {
if (streamInfo.SegmentMap[segUrls[i]] == null) {
streamInfo.SegmentMap[segUrls[i]] = backupSegUrls[j];
var seqMatch = /#EXT-X-MEDIA-SEQUENCE:([0-9]*)/.exec(backupM3u8);
if (seqMatch != null) {
var oldBackupSeqNumber = streamInfo.BackupSeqNumber;
streamInfo.BackupSeqNumber = Math.max(0, parseInt(seqMatch[1]));
if (streamInfo.RealSeqNumber > 0) {
// We already have a real stream, this must be a midroll. We should therefore increment rather than just using backup directly.
// - If we don't do this then our sequence number will be broken and the stream will get stuck in a loading state.
if (streamInfo.FakeSeqNumber == 0) {
streamInfo.FakeSeqNumber = streamInfo.RealSeqNumber;
}
if (oldBackupSeqNumber == -1) {
// First backup sequence, assume +1
streamInfo.FakeSeqNumber++;
}
}
for (var i = 0; i < lines.length; i++) {
var line = lines[i];
if (line == null) {
continue;
}
if (line.includes('stitched-ad')) {
lines[i] = '';
}
if (line.startsWith('#EXTINF:') && !line.includes(',live')) {
var newSegUrl = streamInfo.SegmentMap[lines[i + 1]];
//lines[i] = line.substring(0, line.indexOf(',')) + ',live';
lines[i + 1] = newSegUrl != null ? newSegUrl : '';
}
/*if (line.startsWith('#EXT-X-TWITCH-PREFETCH')) {
// TODO
lines[i] = '';
}*/
else {
streamInfo.FakeSeqNumber += Math.max(0, streamInfo.BackupSeqNumber - oldBackupSeqNumber);
}
} else {
var segmentMap = [];
if (backupM3u8 != null) {
streamInfo.FakeSeqNumber = streamInfo.BackupSeqNumber;
}
}
var backupLines = backupM3u8.replace('\r', '').split('\n');
var segTimes = getSegmentTimes(lines);
var backupSegTimes = getSegmentTimes(backupLines);
for (const [segTime, segUrl] of Object.entries(segTimes)) {
//segmentMap[segUrl] = Object.values(backupSegTimes)[Object.keys(backupSegTimes).length-1];
var closestTime = Number.MAX_VALUE;
var matchingBackupTime = Number.MAX_VALUE;
for (const [backupSegTime, backupSegUrl] of Object.entries(backupSegTimes)) {
var timeDiff = Math.abs(segTime - backupSegTime);
if (timeDiff < closestTime) {
closestTime = timeDiff;
matchingBackupTime = backupSegTime;
segmentMap[segUrl] = backupSegUrl;
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 (closestTime != Number.MAX_VALUE) {
backupSegTimes.splice(backupSegTimes.indexOf(matchingBackupTime), 1);
if (segData != null) {
if (segData.isPrefetch) {
pushedPrefetchSegs++;
newLines.push(segData.urlLine);
} else {
//newLines.push(segData.dateTimeLine);
newLines.push(segData.extInfLine);
newLines.push(segData.urlLine);
}
}
}
for (var i = 0; i < lines.length; i++) {
var line = lines[i];
if (line.includes('stitched-ad')) {
lines[i] = '';
}
if (line.startsWith('#EXTINF:') && !line.includes(',live')) {
lines[i] = line.substring(0, line.indexOf(',')) + ',live';
var backupSegment = segmentMap[lines[i + 1]];
lines[i + 1] = backupSegment != null ? backupSegment : ''
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 = lines.join('\n');
textStr = newLines.length > 0 ? newLines.join('\n') : lines.join('\n');
//console.log(textStr);
}
return textStr;
@ -354,6 +442,22 @@
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) {
@ -427,9 +531,13 @@
streamInfo.RootM3U8Params = (new URL(url)).search;
streamInfo.BackupUrl = null;
streamInfo.BackupFailed = false;
streamInfo.SegmentMap = [];
streamInfo.SegmentCache = [];
streamInfo.IsMidroll = false;
streamInfo.NotifyFirstTime = 0;
streamInfo.NotifyObservedNoAds = false;
streamInfo.RealSeqNumber = -1;
streamInfo.BackupSeqNumber = -1;
streamInfo.FakeSeqNumber = 0;
var lines = encodingsM3u8.replace('\r', '').split('\n');
for (var i = 0; i < lines.length; i++) {
if (!lines[i].startsWith('#') && lines[i].includes('.m3u8')) {
@ -617,12 +725,7 @@
if (typeof url === 'string') {
if (url.includes('/access_token') || url.includes('gql')) {
if (OPT_ACCESS_TOKEN_PLAYER_TYPE) {
if (url.includes('/access_token')) {
var modifiedUrl = new URL(url);
modifiedUrl.searchParams.set('player_type', OPT_ACCESS_TOKEN_PLAYER_TYPE);
arguments[0] = modifiedUrl.href;
}
else if (url.includes('gql') && init && typeof init.body === 'string' && init.body.includes('PlaybackAccessToken')) {
if (url.includes('gql') && init && typeof init.body === 'string' && init.body.includes('PlaybackAccessToken')) {
const newBody = JSON.parse(init.body);
newBody.variables.playerType = OPT_ACCESS_TOKEN_PLAYER_TYPE;
init.body = JSON.stringify(newBody);
@ -641,63 +744,13 @@
value: gql_device_id
});
}
if (OPT_MODE_NOTIFY_ADS_WATCHED) {
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) {
if (OPT_MODE_NOTIFY_ADS_WATCHED_ASYNC_TOK) {
var channelName = JSON.parse(init.body).variables.login;
// See if the first response has an ad, if it does then send requests until there is no ad
{
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, -1, responseData.data.streamPlaybackAccessToken.signature, responseData.data.streamPlaybackAccessToken.value) == 1) {
console.log('No ad in main request');
resolve(new Response(responseStr));
return;
} else {
console.log('Ad in main request');
}
}
}
if (!channelName) {
resolve(response);
return;
}
// Ads are being served, try skipping a bunch of ads
var resolved = false;
var skipAdTries = 0;
for (var i = 0; i < OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS; i++) {
new Promise(async (skipAdResolve, skipAdReject) => {
var noAds = false;
var accessTokenResponse = await getAccessToken(channelName, OPT_REGULAR_PLAYER_TYPE, realFetch);
if (accessTokenResponse.status == 200) {
var responseStr = await accessTokenResponse.text();
var responseData = JSON.parse(responseStr);
var hasAd = false;
if (responseData && responseData.data && responseData.data.streamPlaybackAccessToken && responseData.data.streamPlaybackAccessToken.value && responseData.data.streamPlaybackAccessToken.signature) {
hasAd = (await tryNotifyAdsWatchedSigTok(realFetch, -1, responseData.data.streamPlaybackAccessToken.signature, responseData.data.streamPlaybackAccessToken.value)) == 0;
}
var attempt = ++skipAdTries;
console.log('Attempt ' + attempt + ' ' + (hasAd ? 'has ad' : 'no ad'));
if (attempt === OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS && !resolved) {
resolved = true;
resolve(new Response(responseStr));
return;
}
} else if (!resolved) {
resolved = true;
resolve(response);
return;
}
}).catch(console.log);
}
} else {
for (var i = 0; i < OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS; i++) {
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);
@ -713,7 +766,6 @@
}
}
resolve(response);
}
} else {
resolve(response);
}

View File

@ -7,15 +7,12 @@ twitch-videoad.js application/javascript
scope.OPT_MODE_VIDEO_SWAP = false;
scope.OPT_MODE_LOW_RES = false;
scope.OPT_MODE_EMBED = false;
scope.OPT_MODE_STRIP_AD_SEGMENTS = false;// The default implementation attempts to match segment times (TODO: needs improvements - looping issues - cache matched segments and don't repeat any)
scope.OPT_MODE_STRIP_AD_SEGMENTS_NEWEST = false;// Matches segments ordered by newest
scope.OPT_MODE_STRIP_AD_SEGMENTS_NEWEST_WITH_PREFETCH = false;// This will result in a very close match, but often a repeat of a second or so. May be preferred if you dislike the regular 2-3 jump.
scope.OPT_MODE_STRIP_AD_SEGMENTS = false;
scope.OPT_MODE_NOTIFY_ADS_WATCHED = false;
scope.OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS = 1;
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_ASYNC_TOK = false;
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;
@ -83,8 +80,8 @@ twitch-videoad.js application/javascript
}
var newBlobStr = `
${processM3U8.toString()}
${getSegmentTimes.toString()}
${getSegmentUrls.toString()}
${getSegmentInfos.toString()}
${getSegmentInfosLines.toString()}
${hookWorkerFetch.toString()}
${declareOptions.toString()}
${getAccessToken.toString()}
@ -149,33 +146,135 @@ twitch-videoad.js application/javascript
req.send();
return req.responseText.split("'")[1];
}
function getSegmentTimes(lines) {
var result = [];
var lastDate = 0;
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-PROGRAM-DATE-TIME:')) {
lastDate = Date.parse(line.substring(line.indexOf(':') + 1));
} else if (line.startsWith('http')) {
result[lastDate] = line;
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].split(':')[1]);
}
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 getSegmentUrls(lines, includePrefetch) {
var result = [];
for (var i = 0; i < lines.length; i++) {
var line = lines[i];
if (line.startsWith('http')) {
result.push(line);
} else if (includePrefetch && line.startsWith('#EXT-X-TWITCH-PREFETCH:')) {
result.push(line.substring(line.indexOf(':') + 1));
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;
}
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) {
si.BackupSeqNumber = -1;
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 previously modified the sequence number, we need to keep doing so (alternatively pause/playing might work better)
si.FakeSeqNumber += Math.max(0, si.RealSeqNumber - oldRealSeq);
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');
}
}
if (haveAdTags) {
var si = StreamInfosByUrl[url];
if (OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST && si != null && !si.NotifyObservedNoAds) {
@ -202,7 +301,7 @@ twitch-videoad.js application/javascript
console.log('Reload player');
postMessage({key:'UboHideAdBanner'});
postMessage({key:'UboReloadPlayer'});
return "";
return '';
}
}
postMessage({
@ -214,22 +313,13 @@ twitch-videoad.js application/javascript
if (!OPT_MODE_STRIP_AD_SEGMENTS) {
return textStr;
}
// NOTE: midroll ads are intertwined with live segments, always display the banner on midroll ads
if (haveAdTags && (!textStr.includes(LIVE_SIGNIFIER) || textStr.includes('MIDROLL'))) {
postMessage({key:'UboShowAdBanner',isMidroll:textStr.includes('MIDROLL')});
} else if ((LastAdUrl && LastAdUrl == url) || LastAdTime < Date.now() - 10000) {
postMessage({key:'UboHideAdBanner'});
LastAdTime = 0;
}
if (haveAdTags) {
LastAdUrl = url;
LastAdTime = Date.now();
/*if (OPT_MODE_NOTIFY_ADS_WATCHED) {
console.log('Stripping ads (instead of skipping ads)');
}*/
var streamInfo = StreamInfosByUrl[url];
if (streamInfo == null) {
console.log('Unknown stream url! ' + url);
console.log('Unknown stream url ' + url);
postMessage({key:'UboHideAdBanner'});
return textStr;
}
if (!streamInfo.BackupFailed && streamInfo.BackupUrl == null) {
@ -272,71 +362,69 @@ twitch-videoad.js application/javascript
}
}
var lines = textStr.replace('\r', '').split('\n');
if (OPT_MODE_STRIP_AD_SEGMENTS_NEWEST) {
var newLines = [];
if (backupM3u8 != null) {
var backupLines = backupM3u8.replace('\r', '').split('\n');
var segUrls = getSegmentUrls(lines, false);
var backupSegUrls = getSegmentUrls(backupLines, OPT_MODE_STRIP_AD_SEGMENTS_NEWEST_WITH_PREFETCH);
for (var i = segUrls.length - 1, j = backupSegUrls.length - 1; i >= 0 && j >= 0; i--, j--) {
if (streamInfo.SegmentMap[segUrls[i]] == null) {
streamInfo.SegmentMap[segUrls[i]] = backupSegUrls[j];
var seqMatch = /#EXT-X-MEDIA-SEQUENCE:([0-9]*)/.exec(backupM3u8);
if (seqMatch != null) {
var oldBackupSeqNumber = streamInfo.BackupSeqNumber;
streamInfo.BackupSeqNumber = Math.max(0, parseInt(seqMatch[1]));
if (streamInfo.RealSeqNumber > 0) {
// We already have a real stream, this must be a midroll. We should therefore increment rather than just using backup directly.
// - If we don't do this then our sequence number will be broken and the stream will get stuck in a loading state.
if (streamInfo.FakeSeqNumber == 0) {
streamInfo.FakeSeqNumber = streamInfo.RealSeqNumber;
}
if (oldBackupSeqNumber == -1) {
// First backup sequence, assume +1
streamInfo.FakeSeqNumber++;
}
}
for (var i = 0; i < lines.length; i++) {
var line = lines[i];
if (line == null) {
continue;
}
if (line.includes('stitched-ad')) {
lines[i] = '';
}
if (line.startsWith('#EXTINF:') && !line.includes(',live')) {
var newSegUrl = streamInfo.SegmentMap[lines[i + 1]];
//lines[i] = line.substring(0, line.indexOf(',')) + ',live';
lines[i + 1] = newSegUrl != null ? newSegUrl : '';
}
/*if (line.startsWith('#EXT-X-TWITCH-PREFETCH')) {
// TODO
lines[i] = '';
}*/
else {
streamInfo.FakeSeqNumber += Math.max(0, streamInfo.BackupSeqNumber - oldBackupSeqNumber);
}
} else {
var segmentMap = [];
if (backupM3u8 != null) {
streamInfo.FakeSeqNumber = streamInfo.BackupSeqNumber;
}
}
var backupLines = backupM3u8.replace('\r', '').split('\n');
var segTimes = getSegmentTimes(lines);
var backupSegTimes = getSegmentTimes(backupLines);
for (const [segTime, segUrl] of Object.entries(segTimes)) {
//segmentMap[segUrl] = Object.values(backupSegTimes)[Object.keys(backupSegTimes).length-1];
var closestTime = Number.MAX_VALUE;
var matchingBackupTime = Number.MAX_VALUE;
for (const [backupSegTime, backupSegUrl] of Object.entries(backupSegTimes)) {
var timeDiff = Math.abs(segTime - backupSegTime);
if (timeDiff < closestTime) {
closestTime = timeDiff;
matchingBackupTime = backupSegTime;
segmentMap[segUrl] = backupSegUrl;
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 (closestTime != Number.MAX_VALUE) {
backupSegTimes.splice(backupSegTimes.indexOf(matchingBackupTime), 1);
if (segData != null) {
if (segData.isPrefetch) {
pushedPrefetchSegs++;
newLines.push(segData.urlLine);
} else {
//newLines.push(segData.dateTimeLine);
newLines.push(segData.extInfLine);
newLines.push(segData.urlLine);
}
}
}
for (var i = 0; i < lines.length; i++) {
var line = lines[i];
if (line.includes('stitched-ad')) {
lines[i] = '';
}
if (line.startsWith('#EXTINF:') && !line.includes(',live')) {
lines[i] = line.substring(0, line.indexOf(',')) + ',live';
var backupSegment = segmentMap[lines[i + 1]];
lines[i + 1] = backupSegment != null ? backupSegment : ''
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 = lines.join('\n');
textStr = newLines.length > 0 ? newLines.join('\n') : lines.join('\n');
//console.log(textStr);
}
return textStr;
@ -345,6 +433,22 @@ twitch-videoad.js application/javascript
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) {
@ -418,9 +522,13 @@ twitch-videoad.js application/javascript
streamInfo.RootM3U8Params = (new URL(url)).search;
streamInfo.BackupUrl = null;
streamInfo.BackupFailed = false;
streamInfo.SegmentMap = [];
streamInfo.SegmentCache = [];
streamInfo.IsMidroll = false;
streamInfo.NotifyFirstTime = 0;
streamInfo.NotifyObservedNoAds = false;
streamInfo.RealSeqNumber = -1;
streamInfo.BackupSeqNumber = -1;
streamInfo.FakeSeqNumber = 0;
var lines = encodingsM3u8.replace('\r', '').split('\n');
for (var i = 0; i < lines.length; i++) {
if (!lines[i].startsWith('#') && lines[i].includes('.m3u8')) {
@ -608,12 +716,7 @@ twitch-videoad.js application/javascript
if (typeof url === 'string') {
if (url.includes('/access_token') || url.includes('gql')) {
if (OPT_ACCESS_TOKEN_PLAYER_TYPE) {
if (url.includes('/access_token')) {
var modifiedUrl = new URL(url);
modifiedUrl.searchParams.set('player_type', OPT_ACCESS_TOKEN_PLAYER_TYPE);
arguments[0] = modifiedUrl.href;
}
else if (url.includes('gql') && init && typeof init.body === 'string' && init.body.includes('PlaybackAccessToken')) {
if (url.includes('gql') && init && typeof init.body === 'string' && init.body.includes('PlaybackAccessToken')) {
const newBody = JSON.parse(init.body);
newBody.variables.playerType = OPT_ACCESS_TOKEN_PLAYER_TYPE;
init.body = JSON.stringify(newBody);
@ -632,63 +735,13 @@ twitch-videoad.js application/javascript
value: gql_device_id
});
}
if (OPT_MODE_NOTIFY_ADS_WATCHED) {
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) {
if (OPT_MODE_NOTIFY_ADS_WATCHED_ASYNC_TOK) {
var channelName = JSON.parse(init.body).variables.login;
// See if the first response has an ad, if it does then send requests until there is no ad
{
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, -1, responseData.data.streamPlaybackAccessToken.signature, responseData.data.streamPlaybackAccessToken.value) == 1) {
console.log('No ad in main request');
resolve(new Response(responseStr));
return;
} else {
console.log('Ad in main request');
}
}
}
if (!channelName) {
resolve(response);
return;
}
// Ads are being served, try skipping a bunch of ads
var resolved = false;
var skipAdTries = 0;
for (var i = 0; i < OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS; i++) {
new Promise(async (skipAdResolve, skipAdReject) => {
var noAds = false;
var accessTokenResponse = await getAccessToken(channelName, OPT_REGULAR_PLAYER_TYPE, realFetch);
if (accessTokenResponse.status == 200) {
var responseStr = await accessTokenResponse.text();
var responseData = JSON.parse(responseStr);
var hasAd = false;
if (responseData && responseData.data && responseData.data.streamPlaybackAccessToken && responseData.data.streamPlaybackAccessToken.value && responseData.data.streamPlaybackAccessToken.signature) {
hasAd = (await tryNotifyAdsWatchedSigTok(realFetch, -1, responseData.data.streamPlaybackAccessToken.signature, responseData.data.streamPlaybackAccessToken.value)) == 0;
}
var attempt = ++skipAdTries;
console.log('Attempt ' + attempt + ' ' + (hasAd ? 'has ad' : 'no ad'));
if (attempt === OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS && !resolved) {
resolved = true;
resolve(new Response(responseStr));
return;
}
} else if (!resolved) {
resolved = true;
resolve(response);
return;
}
}).catch(console.log);
}
} else {
for (var i = 0; i < OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS; i++) {
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);
@ -704,7 +757,6 @@ twitch-videoad.js application/javascript
}
}
resolve(response);
}
} else {
resolve(response);
}

View File

@ -1,7 +1,7 @@
// ==UserScript==
// @name TwitchAdSolutions
// @namespace https://github.com/pixeltris/TwitchAdSolutions
// @version 1.2
// @version 1.3
// @updateURL https://github.com/pixeltris/TwitchAdSolutions/raw/master/mute-black/mute-black.user.js
// @downloadURL https://github.com/pixeltris/TwitchAdSolutions/raw/master/mute-black/mute-black.user.js
// @description Multiple solutions for blocking Twitch ads (mute-black)
@ -18,15 +18,12 @@
scope.OPT_MODE_VIDEO_SWAP = false;
scope.OPT_MODE_LOW_RES = false;
scope.OPT_MODE_EMBED = false;
scope.OPT_MODE_STRIP_AD_SEGMENTS = false;// The default implementation attempts to match segment times (TODO: needs improvements - looping issues - cache matched segments and don't repeat any)
scope.OPT_MODE_STRIP_AD_SEGMENTS_NEWEST = false;// Matches segments ordered by newest
scope.OPT_MODE_STRIP_AD_SEGMENTS_NEWEST_WITH_PREFETCH = false;// This will result in a very close match, but often a repeat of a second or so. May be preferred if you dislike the regular 2-3 jump.
scope.OPT_MODE_STRIP_AD_SEGMENTS = false;
scope.OPT_MODE_NOTIFY_ADS_WATCHED = false;
scope.OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS = 1;
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_ASYNC_TOK = false;
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;
@ -94,8 +91,8 @@
}
var newBlobStr = `
${processM3U8.toString()}
${getSegmentTimes.toString()}
${getSegmentUrls.toString()}
${getSegmentInfos.toString()}
${getSegmentInfosLines.toString()}
${hookWorkerFetch.toString()}
${declareOptions.toString()}
${getAccessToken.toString()}
@ -160,33 +157,135 @@
req.send();
return req.responseText.split("'")[1];
}
function getSegmentTimes(lines) {
var result = [];
var lastDate = 0;
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-PROGRAM-DATE-TIME:')) {
lastDate = Date.parse(line.substring(line.indexOf(':') + 1));
} else if (line.startsWith('http')) {
result[lastDate] = line;
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].split(':')[1]);
}
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 getSegmentUrls(lines, includePrefetch) {
var result = [];
for (var i = 0; i < lines.length; i++) {
var line = lines[i];
if (line.startsWith('http')) {
result.push(line);
} else if (includePrefetch && line.startsWith('#EXT-X-TWITCH-PREFETCH:')) {
result.push(line.substring(line.indexOf(':') + 1));
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;
}
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) {
si.BackupSeqNumber = -1;
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 previously modified the sequence number, we need to keep doing so (alternatively pause/playing might work better)
si.FakeSeqNumber += Math.max(0, si.RealSeqNumber - oldRealSeq);
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');
}
}
if (haveAdTags) {
var si = StreamInfosByUrl[url];
if (OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST && si != null && !si.NotifyObservedNoAds) {
@ -213,7 +312,7 @@
console.log('Reload player');
postMessage({key:'UboHideAdBanner'});
postMessage({key:'UboReloadPlayer'});
return "";
return '';
}
}
postMessage({
@ -225,22 +324,13 @@
if (!OPT_MODE_STRIP_AD_SEGMENTS) {
return textStr;
}
// NOTE: midroll ads are intertwined with live segments, always display the banner on midroll ads
if (haveAdTags && (!textStr.includes(LIVE_SIGNIFIER) || textStr.includes('MIDROLL'))) {
postMessage({key:'UboShowAdBanner',isMidroll:textStr.includes('MIDROLL')});
} else if ((LastAdUrl && LastAdUrl == url) || LastAdTime < Date.now() - 10000) {
postMessage({key:'UboHideAdBanner'});
LastAdTime = 0;
}
if (haveAdTags) {
LastAdUrl = url;
LastAdTime = Date.now();
/*if (OPT_MODE_NOTIFY_ADS_WATCHED) {
console.log('Stripping ads (instead of skipping ads)');
}*/
var streamInfo = StreamInfosByUrl[url];
if (streamInfo == null) {
console.log('Unknown stream url! ' + url);
console.log('Unknown stream url ' + url);
postMessage({key:'UboHideAdBanner'});
return textStr;
}
if (!streamInfo.BackupFailed && streamInfo.BackupUrl == null) {
@ -283,71 +373,69 @@
}
}
var lines = textStr.replace('\r', '').split('\n');
if (OPT_MODE_STRIP_AD_SEGMENTS_NEWEST) {
var newLines = [];
if (backupM3u8 != null) {
var backupLines = backupM3u8.replace('\r', '').split('\n');
var segUrls = getSegmentUrls(lines, false);
var backupSegUrls = getSegmentUrls(backupLines, OPT_MODE_STRIP_AD_SEGMENTS_NEWEST_WITH_PREFETCH);
for (var i = segUrls.length - 1, j = backupSegUrls.length - 1; i >= 0 && j >= 0; i--, j--) {
if (streamInfo.SegmentMap[segUrls[i]] == null) {
streamInfo.SegmentMap[segUrls[i]] = backupSegUrls[j];
var seqMatch = /#EXT-X-MEDIA-SEQUENCE:([0-9]*)/.exec(backupM3u8);
if (seqMatch != null) {
var oldBackupSeqNumber = streamInfo.BackupSeqNumber;
streamInfo.BackupSeqNumber = Math.max(0, parseInt(seqMatch[1]));
if (streamInfo.RealSeqNumber > 0) {
// We already have a real stream, this must be a midroll. We should therefore increment rather than just using backup directly.
// - If we don't do this then our sequence number will be broken and the stream will get stuck in a loading state.
if (streamInfo.FakeSeqNumber == 0) {
streamInfo.FakeSeqNumber = streamInfo.RealSeqNumber;
}
if (oldBackupSeqNumber == -1) {
// First backup sequence, assume +1
streamInfo.FakeSeqNumber++;
}
}
for (var i = 0; i < lines.length; i++) {
var line = lines[i];
if (line == null) {
continue;
}
if (line.includes('stitched-ad')) {
lines[i] = '';
}
if (line.startsWith('#EXTINF:') && !line.includes(',live')) {
var newSegUrl = streamInfo.SegmentMap[lines[i + 1]];
//lines[i] = line.substring(0, line.indexOf(',')) + ',live';
lines[i + 1] = newSegUrl != null ? newSegUrl : '';
}
/*if (line.startsWith('#EXT-X-TWITCH-PREFETCH')) {
// TODO
lines[i] = '';
}*/
else {
streamInfo.FakeSeqNumber += Math.max(0, streamInfo.BackupSeqNumber - oldBackupSeqNumber);
}
} else {
var segmentMap = [];
if (backupM3u8 != null) {
streamInfo.FakeSeqNumber = streamInfo.BackupSeqNumber;
}
}
var backupLines = backupM3u8.replace('\r', '').split('\n');
var segTimes = getSegmentTimes(lines);
var backupSegTimes = getSegmentTimes(backupLines);
for (const [segTime, segUrl] of Object.entries(segTimes)) {
//segmentMap[segUrl] = Object.values(backupSegTimes)[Object.keys(backupSegTimes).length-1];
var closestTime = Number.MAX_VALUE;
var matchingBackupTime = Number.MAX_VALUE;
for (const [backupSegTime, backupSegUrl] of Object.entries(backupSegTimes)) {
var timeDiff = Math.abs(segTime - backupSegTime);
if (timeDiff < closestTime) {
closestTime = timeDiff;
matchingBackupTime = backupSegTime;
segmentMap[segUrl] = backupSegUrl;
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 (closestTime != Number.MAX_VALUE) {
backupSegTimes.splice(backupSegTimes.indexOf(matchingBackupTime), 1);
if (segData != null) {
if (segData.isPrefetch) {
pushedPrefetchSegs++;
newLines.push(segData.urlLine);
} else {
//newLines.push(segData.dateTimeLine);
newLines.push(segData.extInfLine);
newLines.push(segData.urlLine);
}
}
}
for (var i = 0; i < lines.length; i++) {
var line = lines[i];
if (line.includes('stitched-ad')) {
lines[i] = '';
}
if (line.startsWith('#EXTINF:') && !line.includes(',live')) {
lines[i] = line.substring(0, line.indexOf(',')) + ',live';
var backupSegment = segmentMap[lines[i + 1]];
lines[i + 1] = backupSegment != null ? backupSegment : ''
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 = lines.join('\n');
textStr = newLines.length > 0 ? newLines.join('\n') : lines.join('\n');
//console.log(textStr);
}
return textStr;
@ -356,6 +444,22 @@
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) {
@ -429,9 +533,13 @@
streamInfo.RootM3U8Params = (new URL(url)).search;
streamInfo.BackupUrl = null;
streamInfo.BackupFailed = false;
streamInfo.SegmentMap = [];
streamInfo.SegmentCache = [];
streamInfo.IsMidroll = false;
streamInfo.NotifyFirstTime = 0;
streamInfo.NotifyObservedNoAds = false;
streamInfo.RealSeqNumber = -1;
streamInfo.BackupSeqNumber = -1;
streamInfo.FakeSeqNumber = 0;
var lines = encodingsM3u8.replace('\r', '').split('\n');
for (var i = 0; i < lines.length; i++) {
if (!lines[i].startsWith('#') && lines[i].includes('.m3u8')) {
@ -619,12 +727,7 @@
if (typeof url === 'string') {
if (url.includes('/access_token') || url.includes('gql')) {
if (OPT_ACCESS_TOKEN_PLAYER_TYPE) {
if (url.includes('/access_token')) {
var modifiedUrl = new URL(url);
modifiedUrl.searchParams.set('player_type', OPT_ACCESS_TOKEN_PLAYER_TYPE);
arguments[0] = modifiedUrl.href;
}
else if (url.includes('gql') && init && typeof init.body === 'string' && init.body.includes('PlaybackAccessToken')) {
if (url.includes('gql') && init && typeof init.body === 'string' && init.body.includes('PlaybackAccessToken')) {
const newBody = JSON.parse(init.body);
newBody.variables.playerType = OPT_ACCESS_TOKEN_PLAYER_TYPE;
init.body = JSON.stringify(newBody);
@ -643,63 +746,13 @@
value: gql_device_id
});
}
if (OPT_MODE_NOTIFY_ADS_WATCHED) {
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) {
if (OPT_MODE_NOTIFY_ADS_WATCHED_ASYNC_TOK) {
var channelName = JSON.parse(init.body).variables.login;
// See if the first response has an ad, if it does then send requests until there is no ad
{
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, -1, responseData.data.streamPlaybackAccessToken.signature, responseData.data.streamPlaybackAccessToken.value) == 1) {
console.log('No ad in main request');
resolve(new Response(responseStr));
return;
} else {
console.log('Ad in main request');
}
}
}
if (!channelName) {
resolve(response);
return;
}
// Ads are being served, try skipping a bunch of ads
var resolved = false;
var skipAdTries = 0;
for (var i = 0; i < OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS; i++) {
new Promise(async (skipAdResolve, skipAdReject) => {
var noAds = false;
var accessTokenResponse = await getAccessToken(channelName, OPT_REGULAR_PLAYER_TYPE, realFetch);
if (accessTokenResponse.status == 200) {
var responseStr = await accessTokenResponse.text();
var responseData = JSON.parse(responseStr);
var hasAd = false;
if (responseData && responseData.data && responseData.data.streamPlaybackAccessToken && responseData.data.streamPlaybackAccessToken.value && responseData.data.streamPlaybackAccessToken.signature) {
hasAd = (await tryNotifyAdsWatchedSigTok(realFetch, -1, responseData.data.streamPlaybackAccessToken.signature, responseData.data.streamPlaybackAccessToken.value)) == 0;
}
var attempt = ++skipAdTries;
console.log('Attempt ' + attempt + ' ' + (hasAd ? 'has ad' : 'no ad'));
if (attempt === OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS && !resolved) {
resolved = true;
resolve(new Response(responseStr));
return;
}
} else if (!resolved) {
resolved = true;
resolve(response);
return;
}
}).catch(console.log);
}
} else {
for (var i = 0; i < OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS; i++) {
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);
@ -715,7 +768,6 @@
}
}
resolve(response);
}
} else {
resolve(response);
}

View File

@ -7,15 +7,12 @@ twitch-videoad.js application/javascript
scope.OPT_MODE_VIDEO_SWAP = false;
scope.OPT_MODE_LOW_RES = false;
scope.OPT_MODE_EMBED = false;
scope.OPT_MODE_STRIP_AD_SEGMENTS = false;// The default implementation attempts to match segment times (TODO: needs improvements - looping issues - cache matched segments and don't repeat any)
scope.OPT_MODE_STRIP_AD_SEGMENTS_NEWEST = false;// Matches segments ordered by newest
scope.OPT_MODE_STRIP_AD_SEGMENTS_NEWEST_WITH_PREFETCH = false;// This will result in a very close match, but often a repeat of a second or so. May be preferred if you dislike the regular 2-3 jump.
scope.OPT_MODE_STRIP_AD_SEGMENTS = false;
scope.OPT_MODE_NOTIFY_ADS_WATCHED = true;
scope.OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS = 1;
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_ASYNC_TOK = false;
scope.OPT_MODE_NOTIFY_ADS_WATCHED_MIN_REQUESTS = true;
scope.OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT = true;
scope.OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT_DELAY = 0;
@ -83,8 +80,8 @@ twitch-videoad.js application/javascript
}
var newBlobStr = `
${processM3U8.toString()}
${getSegmentTimes.toString()}
${getSegmentUrls.toString()}
${getSegmentInfos.toString()}
${getSegmentInfosLines.toString()}
${hookWorkerFetch.toString()}
${declareOptions.toString()}
${getAccessToken.toString()}
@ -149,33 +146,135 @@ twitch-videoad.js application/javascript
req.send();
return req.responseText.split("'")[1];
}
function getSegmentTimes(lines) {
var result = [];
var lastDate = 0;
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-PROGRAM-DATE-TIME:')) {
lastDate = Date.parse(line.substring(line.indexOf(':') + 1));
} else if (line.startsWith('http')) {
result[lastDate] = line;
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].split(':')[1]);
}
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 getSegmentUrls(lines, includePrefetch) {
var result = [];
for (var i = 0; i < lines.length; i++) {
var line = lines[i];
if (line.startsWith('http')) {
result.push(line);
} else if (includePrefetch && line.startsWith('#EXT-X-TWITCH-PREFETCH:')) {
result.push(line.substring(line.indexOf(':') + 1));
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;
}
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) {
si.BackupSeqNumber = -1;
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 previously modified the sequence number, we need to keep doing so (alternatively pause/playing might work better)
si.FakeSeqNumber += Math.max(0, si.RealSeqNumber - oldRealSeq);
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');
}
}
if (haveAdTags) {
var si = StreamInfosByUrl[url];
if (OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST && si != null && !si.NotifyObservedNoAds) {
@ -202,7 +301,7 @@ twitch-videoad.js application/javascript
console.log('Reload player');
postMessage({key:'UboHideAdBanner'});
postMessage({key:'UboReloadPlayer'});
return "";
return '';
}
}
postMessage({
@ -214,22 +313,13 @@ twitch-videoad.js application/javascript
if (!OPT_MODE_STRIP_AD_SEGMENTS) {
return textStr;
}
// NOTE: midroll ads are intertwined with live segments, always display the banner on midroll ads
if (haveAdTags && (!textStr.includes(LIVE_SIGNIFIER) || textStr.includes('MIDROLL'))) {
postMessage({key:'UboShowAdBanner',isMidroll:textStr.includes('MIDROLL')});
} else if ((LastAdUrl && LastAdUrl == url) || LastAdTime < Date.now() - 10000) {
postMessage({key:'UboHideAdBanner'});
LastAdTime = 0;
}
if (haveAdTags) {
LastAdUrl = url;
LastAdTime = Date.now();
/*if (OPT_MODE_NOTIFY_ADS_WATCHED) {
console.log('Stripping ads (instead of skipping ads)');
}*/
var streamInfo = StreamInfosByUrl[url];
if (streamInfo == null) {
console.log('Unknown stream url! ' + url);
console.log('Unknown stream url ' + url);
postMessage({key:'UboHideAdBanner'});
return textStr;
}
if (!streamInfo.BackupFailed && streamInfo.BackupUrl == null) {
@ -272,71 +362,69 @@ twitch-videoad.js application/javascript
}
}
var lines = textStr.replace('\r', '').split('\n');
if (OPT_MODE_STRIP_AD_SEGMENTS_NEWEST) {
var newLines = [];
if (backupM3u8 != null) {
var backupLines = backupM3u8.replace('\r', '').split('\n');
var segUrls = getSegmentUrls(lines, false);
var backupSegUrls = getSegmentUrls(backupLines, OPT_MODE_STRIP_AD_SEGMENTS_NEWEST_WITH_PREFETCH);
for (var i = segUrls.length - 1, j = backupSegUrls.length - 1; i >= 0 && j >= 0; i--, j--) {
if (streamInfo.SegmentMap[segUrls[i]] == null) {
streamInfo.SegmentMap[segUrls[i]] = backupSegUrls[j];
var seqMatch = /#EXT-X-MEDIA-SEQUENCE:([0-9]*)/.exec(backupM3u8);
if (seqMatch != null) {
var oldBackupSeqNumber = streamInfo.BackupSeqNumber;
streamInfo.BackupSeqNumber = Math.max(0, parseInt(seqMatch[1]));
if (streamInfo.RealSeqNumber > 0) {
// We already have a real stream, this must be a midroll. We should therefore increment rather than just using backup directly.
// - If we don't do this then our sequence number will be broken and the stream will get stuck in a loading state.
if (streamInfo.FakeSeqNumber == 0) {
streamInfo.FakeSeqNumber = streamInfo.RealSeqNumber;
}
if (oldBackupSeqNumber == -1) {
// First backup sequence, assume +1
streamInfo.FakeSeqNumber++;
}
}
for (var i = 0; i < lines.length; i++) {
var line = lines[i];
if (line == null) {
continue;
}
if (line.includes('stitched-ad')) {
lines[i] = '';
}
if (line.startsWith('#EXTINF:') && !line.includes(',live')) {
var newSegUrl = streamInfo.SegmentMap[lines[i + 1]];
//lines[i] = line.substring(0, line.indexOf(',')) + ',live';
lines[i + 1] = newSegUrl != null ? newSegUrl : '';
}
/*if (line.startsWith('#EXT-X-TWITCH-PREFETCH')) {
// TODO
lines[i] = '';
}*/
else {
streamInfo.FakeSeqNumber += Math.max(0, streamInfo.BackupSeqNumber - oldBackupSeqNumber);
}
} else {
var segmentMap = [];
if (backupM3u8 != null) {
streamInfo.FakeSeqNumber = streamInfo.BackupSeqNumber;
}
}
var backupLines = backupM3u8.replace('\r', '').split('\n');
var segTimes = getSegmentTimes(lines);
var backupSegTimes = getSegmentTimes(backupLines);
for (const [segTime, segUrl] of Object.entries(segTimes)) {
//segmentMap[segUrl] = Object.values(backupSegTimes)[Object.keys(backupSegTimes).length-1];
var closestTime = Number.MAX_VALUE;
var matchingBackupTime = Number.MAX_VALUE;
for (const [backupSegTime, backupSegUrl] of Object.entries(backupSegTimes)) {
var timeDiff = Math.abs(segTime - backupSegTime);
if (timeDiff < closestTime) {
closestTime = timeDiff;
matchingBackupTime = backupSegTime;
segmentMap[segUrl] = backupSegUrl;
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 (closestTime != Number.MAX_VALUE) {
backupSegTimes.splice(backupSegTimes.indexOf(matchingBackupTime), 1);
if (segData != null) {
if (segData.isPrefetch) {
pushedPrefetchSegs++;
newLines.push(segData.urlLine);
} else {
//newLines.push(segData.dateTimeLine);
newLines.push(segData.extInfLine);
newLines.push(segData.urlLine);
}
}
}
for (var i = 0; i < lines.length; i++) {
var line = lines[i];
if (line.includes('stitched-ad')) {
lines[i] = '';
}
if (line.startsWith('#EXTINF:') && !line.includes(',live')) {
lines[i] = line.substring(0, line.indexOf(',')) + ',live';
var backupSegment = segmentMap[lines[i + 1]];
lines[i + 1] = backupSegment != null ? backupSegment : ''
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 = lines.join('\n');
textStr = newLines.length > 0 ? newLines.join('\n') : lines.join('\n');
//console.log(textStr);
}
return textStr;
@ -345,6 +433,22 @@ twitch-videoad.js application/javascript
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) {
@ -418,9 +522,13 @@ twitch-videoad.js application/javascript
streamInfo.RootM3U8Params = (new URL(url)).search;
streamInfo.BackupUrl = null;
streamInfo.BackupFailed = false;
streamInfo.SegmentMap = [];
streamInfo.SegmentCache = [];
streamInfo.IsMidroll = false;
streamInfo.NotifyFirstTime = 0;
streamInfo.NotifyObservedNoAds = false;
streamInfo.RealSeqNumber = -1;
streamInfo.BackupSeqNumber = -1;
streamInfo.FakeSeqNumber = 0;
var lines = encodingsM3u8.replace('\r', '').split('\n');
for (var i = 0; i < lines.length; i++) {
if (!lines[i].startsWith('#') && lines[i].includes('.m3u8')) {
@ -608,12 +716,7 @@ twitch-videoad.js application/javascript
if (typeof url === 'string') {
if (url.includes('/access_token') || url.includes('gql')) {
if (OPT_ACCESS_TOKEN_PLAYER_TYPE) {
if (url.includes('/access_token')) {
var modifiedUrl = new URL(url);
modifiedUrl.searchParams.set('player_type', OPT_ACCESS_TOKEN_PLAYER_TYPE);
arguments[0] = modifiedUrl.href;
}
else if (url.includes('gql') && init && typeof init.body === 'string' && init.body.includes('PlaybackAccessToken')) {
if (url.includes('gql') && init && typeof init.body === 'string' && init.body.includes('PlaybackAccessToken')) {
const newBody = JSON.parse(init.body);
newBody.variables.playerType = OPT_ACCESS_TOKEN_PLAYER_TYPE;
init.body = JSON.stringify(newBody);
@ -632,63 +735,13 @@ twitch-videoad.js application/javascript
value: gql_device_id
});
}
if (OPT_MODE_NOTIFY_ADS_WATCHED) {
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) {
if (OPT_MODE_NOTIFY_ADS_WATCHED_ASYNC_TOK) {
var channelName = JSON.parse(init.body).variables.login;
// See if the first response has an ad, if it does then send requests until there is no ad
{
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, -1, responseData.data.streamPlaybackAccessToken.signature, responseData.data.streamPlaybackAccessToken.value) == 1) {
console.log('No ad in main request');
resolve(new Response(responseStr));
return;
} else {
console.log('Ad in main request');
}
}
}
if (!channelName) {
resolve(response);
return;
}
// Ads are being served, try skipping a bunch of ads
var resolved = false;
var skipAdTries = 0;
for (var i = 0; i < OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS; i++) {
new Promise(async (skipAdResolve, skipAdReject) => {
var noAds = false;
var accessTokenResponse = await getAccessToken(channelName, OPT_REGULAR_PLAYER_TYPE, realFetch);
if (accessTokenResponse.status == 200) {
var responseStr = await accessTokenResponse.text();
var responseData = JSON.parse(responseStr);
var hasAd = false;
if (responseData && responseData.data && responseData.data.streamPlaybackAccessToken && responseData.data.streamPlaybackAccessToken.value && responseData.data.streamPlaybackAccessToken.signature) {
hasAd = (await tryNotifyAdsWatchedSigTok(realFetch, -1, responseData.data.streamPlaybackAccessToken.signature, responseData.data.streamPlaybackAccessToken.value)) == 0;
}
var attempt = ++skipAdTries;
console.log('Attempt ' + attempt + ' ' + (hasAd ? 'has ad' : 'no ad'));
if (attempt === OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS && !resolved) {
resolved = true;
resolve(new Response(responseStr));
return;
}
} else if (!resolved) {
resolved = true;
resolve(response);
return;
}
}).catch(console.log);
}
} else {
for (var i = 0; i < OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS; i++) {
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);
@ -704,7 +757,6 @@ twitch-videoad.js application/javascript
}
}
resolve(response);
}
} else {
resolve(response);
}

View File

@ -1,7 +1,7 @@
// ==UserScript==
// @name TwitchAdSolutions
// @namespace https://github.com/pixeltris/TwitchAdSolutions
// @version 1.2
// @version 1.3
// @updateURL https://github.com/pixeltris/TwitchAdSolutions/raw/master/notify-reload/notify-reload.user.js
// @downloadURL https://github.com/pixeltris/TwitchAdSolutions/raw/master/notify-reload/notify-reload.user.js
// @description Multiple solutions for blocking Twitch ads (notify-reload)
@ -18,15 +18,12 @@
scope.OPT_MODE_VIDEO_SWAP = false;
scope.OPT_MODE_LOW_RES = false;
scope.OPT_MODE_EMBED = false;
scope.OPT_MODE_STRIP_AD_SEGMENTS = false;// The default implementation attempts to match segment times (TODO: needs improvements - looping issues - cache matched segments and don't repeat any)
scope.OPT_MODE_STRIP_AD_SEGMENTS_NEWEST = false;// Matches segments ordered by newest
scope.OPT_MODE_STRIP_AD_SEGMENTS_NEWEST_WITH_PREFETCH = false;// This will result in a very close match, but often a repeat of a second or so. May be preferred if you dislike the regular 2-3 jump.
scope.OPT_MODE_STRIP_AD_SEGMENTS = false;
scope.OPT_MODE_NOTIFY_ADS_WATCHED = true;
scope.OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS = 1;
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_ASYNC_TOK = false;
scope.OPT_MODE_NOTIFY_ADS_WATCHED_MIN_REQUESTS = true;
scope.OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT = true;
scope.OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT_DELAY = 0;
@ -94,8 +91,8 @@
}
var newBlobStr = `
${processM3U8.toString()}
${getSegmentTimes.toString()}
${getSegmentUrls.toString()}
${getSegmentInfos.toString()}
${getSegmentInfosLines.toString()}
${hookWorkerFetch.toString()}
${declareOptions.toString()}
${getAccessToken.toString()}
@ -160,33 +157,135 @@
req.send();
return req.responseText.split("'")[1];
}
function getSegmentTimes(lines) {
var result = [];
var lastDate = 0;
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-PROGRAM-DATE-TIME:')) {
lastDate = Date.parse(line.substring(line.indexOf(':') + 1));
} else if (line.startsWith('http')) {
result[lastDate] = line;
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].split(':')[1]);
}
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 getSegmentUrls(lines, includePrefetch) {
var result = [];
for (var i = 0; i < lines.length; i++) {
var line = lines[i];
if (line.startsWith('http')) {
result.push(line);
} else if (includePrefetch && line.startsWith('#EXT-X-TWITCH-PREFETCH:')) {
result.push(line.substring(line.indexOf(':') + 1));
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;
}
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) {
si.BackupSeqNumber = -1;
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 previously modified the sequence number, we need to keep doing so (alternatively pause/playing might work better)
si.FakeSeqNumber += Math.max(0, si.RealSeqNumber - oldRealSeq);
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');
}
}
if (haveAdTags) {
var si = StreamInfosByUrl[url];
if (OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST && si != null && !si.NotifyObservedNoAds) {
@ -213,7 +312,7 @@
console.log('Reload player');
postMessage({key:'UboHideAdBanner'});
postMessage({key:'UboReloadPlayer'});
return "";
return '';
}
}
postMessage({
@ -225,22 +324,13 @@
if (!OPT_MODE_STRIP_AD_SEGMENTS) {
return textStr;
}
// NOTE: midroll ads are intertwined with live segments, always display the banner on midroll ads
if (haveAdTags && (!textStr.includes(LIVE_SIGNIFIER) || textStr.includes('MIDROLL'))) {
postMessage({key:'UboShowAdBanner',isMidroll:textStr.includes('MIDROLL')});
} else if ((LastAdUrl && LastAdUrl == url) || LastAdTime < Date.now() - 10000) {
postMessage({key:'UboHideAdBanner'});
LastAdTime = 0;
}
if (haveAdTags) {
LastAdUrl = url;
LastAdTime = Date.now();
/*if (OPT_MODE_NOTIFY_ADS_WATCHED) {
console.log('Stripping ads (instead of skipping ads)');
}*/
var streamInfo = StreamInfosByUrl[url];
if (streamInfo == null) {
console.log('Unknown stream url! ' + url);
console.log('Unknown stream url ' + url);
postMessage({key:'UboHideAdBanner'});
return textStr;
}
if (!streamInfo.BackupFailed && streamInfo.BackupUrl == null) {
@ -283,71 +373,69 @@
}
}
var lines = textStr.replace('\r', '').split('\n');
if (OPT_MODE_STRIP_AD_SEGMENTS_NEWEST) {
var newLines = [];
if (backupM3u8 != null) {
var backupLines = backupM3u8.replace('\r', '').split('\n');
var segUrls = getSegmentUrls(lines, false);
var backupSegUrls = getSegmentUrls(backupLines, OPT_MODE_STRIP_AD_SEGMENTS_NEWEST_WITH_PREFETCH);
for (var i = segUrls.length - 1, j = backupSegUrls.length - 1; i >= 0 && j >= 0; i--, j--) {
if (streamInfo.SegmentMap[segUrls[i]] == null) {
streamInfo.SegmentMap[segUrls[i]] = backupSegUrls[j];
var seqMatch = /#EXT-X-MEDIA-SEQUENCE:([0-9]*)/.exec(backupM3u8);
if (seqMatch != null) {
var oldBackupSeqNumber = streamInfo.BackupSeqNumber;
streamInfo.BackupSeqNumber = Math.max(0, parseInt(seqMatch[1]));
if (streamInfo.RealSeqNumber > 0) {
// We already have a real stream, this must be a midroll. We should therefore increment rather than just using backup directly.
// - If we don't do this then our sequence number will be broken and the stream will get stuck in a loading state.
if (streamInfo.FakeSeqNumber == 0) {
streamInfo.FakeSeqNumber = streamInfo.RealSeqNumber;
}
if (oldBackupSeqNumber == -1) {
// First backup sequence, assume +1
streamInfo.FakeSeqNumber++;
}
}
for (var i = 0; i < lines.length; i++) {
var line = lines[i];
if (line == null) {
continue;
}
if (line.includes('stitched-ad')) {
lines[i] = '';
}
if (line.startsWith('#EXTINF:') && !line.includes(',live')) {
var newSegUrl = streamInfo.SegmentMap[lines[i + 1]];
//lines[i] = line.substring(0, line.indexOf(',')) + ',live';
lines[i + 1] = newSegUrl != null ? newSegUrl : '';
}
/*if (line.startsWith('#EXT-X-TWITCH-PREFETCH')) {
// TODO
lines[i] = '';
}*/
else {
streamInfo.FakeSeqNumber += Math.max(0, streamInfo.BackupSeqNumber - oldBackupSeqNumber);
}
} else {
var segmentMap = [];
if (backupM3u8 != null) {
streamInfo.FakeSeqNumber = streamInfo.BackupSeqNumber;
}
}
var backupLines = backupM3u8.replace('\r', '').split('\n');
var segTimes = getSegmentTimes(lines);
var backupSegTimes = getSegmentTimes(backupLines);
for (const [segTime, segUrl] of Object.entries(segTimes)) {
//segmentMap[segUrl] = Object.values(backupSegTimes)[Object.keys(backupSegTimes).length-1];
var closestTime = Number.MAX_VALUE;
var matchingBackupTime = Number.MAX_VALUE;
for (const [backupSegTime, backupSegUrl] of Object.entries(backupSegTimes)) {
var timeDiff = Math.abs(segTime - backupSegTime);
if (timeDiff < closestTime) {
closestTime = timeDiff;
matchingBackupTime = backupSegTime;
segmentMap[segUrl] = backupSegUrl;
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 (closestTime != Number.MAX_VALUE) {
backupSegTimes.splice(backupSegTimes.indexOf(matchingBackupTime), 1);
if (segData != null) {
if (segData.isPrefetch) {
pushedPrefetchSegs++;
newLines.push(segData.urlLine);
} else {
//newLines.push(segData.dateTimeLine);
newLines.push(segData.extInfLine);
newLines.push(segData.urlLine);
}
}
}
for (var i = 0; i < lines.length; i++) {
var line = lines[i];
if (line.includes('stitched-ad')) {
lines[i] = '';
}
if (line.startsWith('#EXTINF:') && !line.includes(',live')) {
lines[i] = line.substring(0, line.indexOf(',')) + ',live';
var backupSegment = segmentMap[lines[i + 1]];
lines[i + 1] = backupSegment != null ? backupSegment : ''
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 = lines.join('\n');
textStr = newLines.length > 0 ? newLines.join('\n') : lines.join('\n');
//console.log(textStr);
}
return textStr;
@ -356,6 +444,22 @@
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) {
@ -429,9 +533,13 @@
streamInfo.RootM3U8Params = (new URL(url)).search;
streamInfo.BackupUrl = null;
streamInfo.BackupFailed = false;
streamInfo.SegmentMap = [];
streamInfo.SegmentCache = [];
streamInfo.IsMidroll = false;
streamInfo.NotifyFirstTime = 0;
streamInfo.NotifyObservedNoAds = false;
streamInfo.RealSeqNumber = -1;
streamInfo.BackupSeqNumber = -1;
streamInfo.FakeSeqNumber = 0;
var lines = encodingsM3u8.replace('\r', '').split('\n');
for (var i = 0; i < lines.length; i++) {
if (!lines[i].startsWith('#') && lines[i].includes('.m3u8')) {
@ -619,12 +727,7 @@
if (typeof url === 'string') {
if (url.includes('/access_token') || url.includes('gql')) {
if (OPT_ACCESS_TOKEN_PLAYER_TYPE) {
if (url.includes('/access_token')) {
var modifiedUrl = new URL(url);
modifiedUrl.searchParams.set('player_type', OPT_ACCESS_TOKEN_PLAYER_TYPE);
arguments[0] = modifiedUrl.href;
}
else if (url.includes('gql') && init && typeof init.body === 'string' && init.body.includes('PlaybackAccessToken')) {
if (url.includes('gql') && init && typeof init.body === 'string' && init.body.includes('PlaybackAccessToken')) {
const newBody = JSON.parse(init.body);
newBody.variables.playerType = OPT_ACCESS_TOKEN_PLAYER_TYPE;
init.body = JSON.stringify(newBody);
@ -643,63 +746,13 @@
value: gql_device_id
});
}
if (OPT_MODE_NOTIFY_ADS_WATCHED) {
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) {
if (OPT_MODE_NOTIFY_ADS_WATCHED_ASYNC_TOK) {
var channelName = JSON.parse(init.body).variables.login;
// See if the first response has an ad, if it does then send requests until there is no ad
{
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, -1, responseData.data.streamPlaybackAccessToken.signature, responseData.data.streamPlaybackAccessToken.value) == 1) {
console.log('No ad in main request');
resolve(new Response(responseStr));
return;
} else {
console.log('Ad in main request');
}
}
}
if (!channelName) {
resolve(response);
return;
}
// Ads are being served, try skipping a bunch of ads
var resolved = false;
var skipAdTries = 0;
for (var i = 0; i < OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS; i++) {
new Promise(async (skipAdResolve, skipAdReject) => {
var noAds = false;
var accessTokenResponse = await getAccessToken(channelName, OPT_REGULAR_PLAYER_TYPE, realFetch);
if (accessTokenResponse.status == 200) {
var responseStr = await accessTokenResponse.text();
var responseData = JSON.parse(responseStr);
var hasAd = false;
if (responseData && responseData.data && responseData.data.streamPlaybackAccessToken && responseData.data.streamPlaybackAccessToken.value && responseData.data.streamPlaybackAccessToken.signature) {
hasAd = (await tryNotifyAdsWatchedSigTok(realFetch, -1, responseData.data.streamPlaybackAccessToken.signature, responseData.data.streamPlaybackAccessToken.value)) == 0;
}
var attempt = ++skipAdTries;
console.log('Attempt ' + attempt + ' ' + (hasAd ? 'has ad' : 'no ad'));
if (attempt === OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS && !resolved) {
resolved = true;
resolve(new Response(responseStr));
return;
}
} else if (!resolved) {
resolved = true;
resolve(response);
return;
}
}).catch(console.log);
}
} else {
for (var i = 0; i < OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS; i++) {
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);
@ -715,7 +768,6 @@
}
}
resolve(response);
}
} else {
resolve(response);
}

View File

@ -8,14 +8,11 @@ twitch-videoad.js application/javascript
scope.OPT_MODE_LOW_RES = false;
scope.OPT_MODE_EMBED = false;
scope.OPT_MODE_STRIP_AD_SEGMENTS = true;
scope.OPT_MODE_STRIP_AD_SEGMENTS_NEWEST = true;
scope.OPT_MODE_STRIP_AD_SEGMENTS_NEWEST_WITH_PREFETCH = false;// This will result in a very close match, but often a repeat of a second or so. May be preferred if you dislike the regular 2-3 jump.
scope.OPT_MODE_NOTIFY_ADS_WATCHED = true;
scope.OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS = 1;
scope.OPT_MODE_NOTIFY_ADS_WATCHED_INITIAL_ATTEMPTS = 0;
scope.OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST = true;
scope.OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST_AND_RELOAD = true;
scope.OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST_EXPECTED_DURATION = 10000;// In milliseconds
scope.OPT_MODE_NOTIFY_ADS_WATCHED_ASYNC_TOK = false;
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;
@ -83,8 +80,8 @@ twitch-videoad.js application/javascript
}
var newBlobStr = `
${processM3U8.toString()}
${getSegmentTimes.toString()}
${getSegmentUrls.toString()}
${getSegmentInfos.toString()}
${getSegmentInfosLines.toString()}
${hookWorkerFetch.toString()}
${declareOptions.toString()}
${getAccessToken.toString()}
@ -149,33 +146,135 @@ twitch-videoad.js application/javascript
req.send();
return req.responseText.split("'")[1];
}
function getSegmentTimes(lines) {
var result = [];
var lastDate = 0;
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-PROGRAM-DATE-TIME:')) {
lastDate = Date.parse(line.substring(line.indexOf(':') + 1));
} else if (line.startsWith('http')) {
result[lastDate] = line;
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].split(':')[1]);
}
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 getSegmentUrls(lines, includePrefetch) {
var result = [];
for (var i = 0; i < lines.length; i++) {
var line = lines[i];
if (line.startsWith('http')) {
result.push(line);
} else if (includePrefetch && line.startsWith('#EXT-X-TWITCH-PREFETCH:')) {
result.push(line.substring(line.indexOf(':') + 1));
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;
}
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) {
si.BackupSeqNumber = -1;
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 previously modified the sequence number, we need to keep doing so (alternatively pause/playing might work better)
si.FakeSeqNumber += Math.max(0, si.RealSeqNumber - oldRealSeq);
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');
}
}
if (haveAdTags) {
var si = StreamInfosByUrl[url];
if (OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST && si != null && !si.NotifyObservedNoAds) {
@ -202,7 +301,7 @@ twitch-videoad.js application/javascript
console.log('Reload player');
postMessage({key:'UboHideAdBanner'});
postMessage({key:'UboReloadPlayer'});
return "";
return '';
}
}
postMessage({
@ -214,22 +313,13 @@ twitch-videoad.js application/javascript
if (!OPT_MODE_STRIP_AD_SEGMENTS) {
return textStr;
}
// NOTE: midroll ads are intertwined with live segments, always display the banner on midroll ads
if (haveAdTags && (!textStr.includes(LIVE_SIGNIFIER) || textStr.includes('MIDROLL'))) {
postMessage({key:'UboShowAdBanner',isMidroll:textStr.includes('MIDROLL')});
} else if ((LastAdUrl && LastAdUrl == url) || LastAdTime < Date.now() - 10000) {
postMessage({key:'UboHideAdBanner'});
LastAdTime = 0;
}
if (haveAdTags) {
LastAdUrl = url;
LastAdTime = Date.now();
/*if (OPT_MODE_NOTIFY_ADS_WATCHED) {
console.log('Stripping ads (instead of skipping ads)');
}*/
var streamInfo = StreamInfosByUrl[url];
if (streamInfo == null) {
console.log('Unknown stream url! ' + url);
console.log('Unknown stream url ' + url);
postMessage({key:'UboHideAdBanner'});
return textStr;
}
if (!streamInfo.BackupFailed && streamInfo.BackupUrl == null) {
@ -272,71 +362,69 @@ twitch-videoad.js application/javascript
}
}
var lines = textStr.replace('\r', '').split('\n');
if (OPT_MODE_STRIP_AD_SEGMENTS_NEWEST) {
var newLines = [];
if (backupM3u8 != null) {
var backupLines = backupM3u8.replace('\r', '').split('\n');
var segUrls = getSegmentUrls(lines, false);
var backupSegUrls = getSegmentUrls(backupLines, OPT_MODE_STRIP_AD_SEGMENTS_NEWEST_WITH_PREFETCH);
for (var i = segUrls.length - 1, j = backupSegUrls.length - 1; i >= 0 && j >= 0; i--, j--) {
if (streamInfo.SegmentMap[segUrls[i]] == null) {
streamInfo.SegmentMap[segUrls[i]] = backupSegUrls[j];
var seqMatch = /#EXT-X-MEDIA-SEQUENCE:([0-9]*)/.exec(backupM3u8);
if (seqMatch != null) {
var oldBackupSeqNumber = streamInfo.BackupSeqNumber;
streamInfo.BackupSeqNumber = Math.max(0, parseInt(seqMatch[1]));
if (streamInfo.RealSeqNumber > 0) {
// We already have a real stream, this must be a midroll. We should therefore increment rather than just using backup directly.
// - If we don't do this then our sequence number will be broken and the stream will get stuck in a loading state.
if (streamInfo.FakeSeqNumber == 0) {
streamInfo.FakeSeqNumber = streamInfo.RealSeqNumber;
}
if (oldBackupSeqNumber == -1) {
// First backup sequence, assume +1
streamInfo.FakeSeqNumber++;
}
}
for (var i = 0; i < lines.length; i++) {
var line = lines[i];
if (line == null) {
continue;
}
if (line.includes('stitched-ad')) {
lines[i] = '';
}
if (line.startsWith('#EXTINF:') && !line.includes(',live')) {
var newSegUrl = streamInfo.SegmentMap[lines[i + 1]];
//lines[i] = line.substring(0, line.indexOf(',')) + ',live';
lines[i + 1] = newSegUrl != null ? newSegUrl : '';
}
/*if (line.startsWith('#EXT-X-TWITCH-PREFETCH')) {
// TODO
lines[i] = '';
}*/
else {
streamInfo.FakeSeqNumber += Math.max(0, streamInfo.BackupSeqNumber - oldBackupSeqNumber);
}
} else {
var segmentMap = [];
if (backupM3u8 != null) {
streamInfo.FakeSeqNumber = streamInfo.BackupSeqNumber;
}
}
var backupLines = backupM3u8.replace('\r', '').split('\n');
var segTimes = getSegmentTimes(lines);
var backupSegTimes = getSegmentTimes(backupLines);
for (const [segTime, segUrl] of Object.entries(segTimes)) {
//segmentMap[segUrl] = Object.values(backupSegTimes)[Object.keys(backupSegTimes).length-1];
var closestTime = Number.MAX_VALUE;
var matchingBackupTime = Number.MAX_VALUE;
for (const [backupSegTime, backupSegUrl] of Object.entries(backupSegTimes)) {
var timeDiff = Math.abs(segTime - backupSegTime);
if (timeDiff < closestTime) {
closestTime = timeDiff;
matchingBackupTime = backupSegTime;
segmentMap[segUrl] = backupSegUrl;
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 (closestTime != Number.MAX_VALUE) {
backupSegTimes.splice(backupSegTimes.indexOf(matchingBackupTime), 1);
if (segData != null) {
if (segData.isPrefetch) {
pushedPrefetchSegs++;
newLines.push(segData.urlLine);
} else {
//newLines.push(segData.dateTimeLine);
newLines.push(segData.extInfLine);
newLines.push(segData.urlLine);
}
}
}
for (var i = 0; i < lines.length; i++) {
var line = lines[i];
if (line.includes('stitched-ad')) {
lines[i] = '';
}
if (line.startsWith('#EXTINF:') && !line.includes(',live')) {
lines[i] = line.substring(0, line.indexOf(',')) + ',live';
var backupSegment = segmentMap[lines[i + 1]];
lines[i + 1] = backupSegment != null ? backupSegment : ''
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 = lines.join('\n');
textStr = newLines.length > 0 ? newLines.join('\n') : lines.join('\n');
//console.log(textStr);
}
return textStr;
@ -345,6 +433,22 @@ twitch-videoad.js application/javascript
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) {
@ -418,9 +522,13 @@ twitch-videoad.js application/javascript
streamInfo.RootM3U8Params = (new URL(url)).search;
streamInfo.BackupUrl = null;
streamInfo.BackupFailed = false;
streamInfo.SegmentMap = [];
streamInfo.SegmentCache = [];
streamInfo.IsMidroll = false;
streamInfo.NotifyFirstTime = 0;
streamInfo.NotifyObservedNoAds = false;
streamInfo.RealSeqNumber = -1;
streamInfo.BackupSeqNumber = -1;
streamInfo.FakeSeqNumber = 0;
var lines = encodingsM3u8.replace('\r', '').split('\n');
for (var i = 0; i < lines.length; i++) {
if (!lines[i].startsWith('#') && lines[i].includes('.m3u8')) {
@ -608,12 +716,7 @@ twitch-videoad.js application/javascript
if (typeof url === 'string') {
if (url.includes('/access_token') || url.includes('gql')) {
if (OPT_ACCESS_TOKEN_PLAYER_TYPE) {
if (url.includes('/access_token')) {
var modifiedUrl = new URL(url);
modifiedUrl.searchParams.set('player_type', OPT_ACCESS_TOKEN_PLAYER_TYPE);
arguments[0] = modifiedUrl.href;
}
else if (url.includes('gql') && init && typeof init.body === 'string' && init.body.includes('PlaybackAccessToken')) {
if (url.includes('gql') && init && typeof init.body === 'string' && init.body.includes('PlaybackAccessToken')) {
const newBody = JSON.parse(init.body);
newBody.variables.playerType = OPT_ACCESS_TOKEN_PLAYER_TYPE;
init.body = JSON.stringify(newBody);
@ -632,63 +735,13 @@ twitch-videoad.js application/javascript
value: gql_device_id
});
}
if (OPT_MODE_NOTIFY_ADS_WATCHED) {
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) {
if (OPT_MODE_NOTIFY_ADS_WATCHED_ASYNC_TOK) {
var channelName = JSON.parse(init.body).variables.login;
// See if the first response has an ad, if it does then send requests until there is no ad
{
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, -1, responseData.data.streamPlaybackAccessToken.signature, responseData.data.streamPlaybackAccessToken.value) == 1) {
console.log('No ad in main request');
resolve(new Response(responseStr));
return;
} else {
console.log('Ad in main request');
}
}
}
if (!channelName) {
resolve(response);
return;
}
// Ads are being served, try skipping a bunch of ads
var resolved = false;
var skipAdTries = 0;
for (var i = 0; i < OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS; i++) {
new Promise(async (skipAdResolve, skipAdReject) => {
var noAds = false;
var accessTokenResponse = await getAccessToken(channelName, OPT_REGULAR_PLAYER_TYPE, realFetch);
if (accessTokenResponse.status == 200) {
var responseStr = await accessTokenResponse.text();
var responseData = JSON.parse(responseStr);
var hasAd = false;
if (responseData && responseData.data && responseData.data.streamPlaybackAccessToken && responseData.data.streamPlaybackAccessToken.value && responseData.data.streamPlaybackAccessToken.signature) {
hasAd = (await tryNotifyAdsWatchedSigTok(realFetch, -1, responseData.data.streamPlaybackAccessToken.signature, responseData.data.streamPlaybackAccessToken.value)) == 0;
}
var attempt = ++skipAdTries;
console.log('Attempt ' + attempt + ' ' + (hasAd ? 'has ad' : 'no ad'));
if (attempt === OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS && !resolved) {
resolved = true;
resolve(new Response(responseStr));
return;
}
} else if (!resolved) {
resolved = true;
resolve(response);
return;
}
}).catch(console.log);
}
} else {
for (var i = 0; i < OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS; i++) {
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);
@ -704,7 +757,6 @@ twitch-videoad.js application/javascript
}
}
resolve(response);
}
} else {
resolve(response);
}

View File

@ -1,7 +1,7 @@
// ==UserScript==
// @name TwitchAdSolutions
// @namespace https://github.com/pixeltris/TwitchAdSolutions
// @version 1.2
// @version 1.3
// @updateURL https://github.com/pixeltris/TwitchAdSolutions/raw/master/notify-strip-reload/notify-strip-reload.user.js
// @downloadURL https://github.com/pixeltris/TwitchAdSolutions/raw/master/notify-strip-reload/notify-strip-reload.user.js
// @description Multiple solutions for blocking Twitch ads (notify-strip-reload)
@ -19,14 +19,11 @@
scope.OPT_MODE_LOW_RES = false;
scope.OPT_MODE_EMBED = false;
scope.OPT_MODE_STRIP_AD_SEGMENTS = true;
scope.OPT_MODE_STRIP_AD_SEGMENTS_NEWEST = true;
scope.OPT_MODE_STRIP_AD_SEGMENTS_NEWEST_WITH_PREFETCH = false;// This will result in a very close match, but often a repeat of a second or so. May be preferred if you dislike the regular 2-3 jump.
scope.OPT_MODE_NOTIFY_ADS_WATCHED = true;
scope.OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS = 1;
scope.OPT_MODE_NOTIFY_ADS_WATCHED_INITIAL_ATTEMPTS = 0;
scope.OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST = true;
scope.OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST_AND_RELOAD = true;
scope.OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST_EXPECTED_DURATION = 10000;// In milliseconds
scope.OPT_MODE_NOTIFY_ADS_WATCHED_ASYNC_TOK = false;
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;
@ -94,8 +91,8 @@
}
var newBlobStr = `
${processM3U8.toString()}
${getSegmentTimes.toString()}
${getSegmentUrls.toString()}
${getSegmentInfos.toString()}
${getSegmentInfosLines.toString()}
${hookWorkerFetch.toString()}
${declareOptions.toString()}
${getAccessToken.toString()}
@ -160,33 +157,135 @@
req.send();
return req.responseText.split("'")[1];
}
function getSegmentTimes(lines) {
var result = [];
var lastDate = 0;
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-PROGRAM-DATE-TIME:')) {
lastDate = Date.parse(line.substring(line.indexOf(':') + 1));
} else if (line.startsWith('http')) {
result[lastDate] = line;
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].split(':')[1]);
}
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 getSegmentUrls(lines, includePrefetch) {
var result = [];
for (var i = 0; i < lines.length; i++) {
var line = lines[i];
if (line.startsWith('http')) {
result.push(line);
} else if (includePrefetch && line.startsWith('#EXT-X-TWITCH-PREFETCH:')) {
result.push(line.substring(line.indexOf(':') + 1));
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;
}
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) {
si.BackupSeqNumber = -1;
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 previously modified the sequence number, we need to keep doing so (alternatively pause/playing might work better)
si.FakeSeqNumber += Math.max(0, si.RealSeqNumber - oldRealSeq);
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');
}
}
if (haveAdTags) {
var si = StreamInfosByUrl[url];
if (OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST && si != null && !si.NotifyObservedNoAds) {
@ -213,7 +312,7 @@
console.log('Reload player');
postMessage({key:'UboHideAdBanner'});
postMessage({key:'UboReloadPlayer'});
return "";
return '';
}
}
postMessage({
@ -225,22 +324,13 @@
if (!OPT_MODE_STRIP_AD_SEGMENTS) {
return textStr;
}
// NOTE: midroll ads are intertwined with live segments, always display the banner on midroll ads
if (haveAdTags && (!textStr.includes(LIVE_SIGNIFIER) || textStr.includes('MIDROLL'))) {
postMessage({key:'UboShowAdBanner',isMidroll:textStr.includes('MIDROLL')});
} else if ((LastAdUrl && LastAdUrl == url) || LastAdTime < Date.now() - 10000) {
postMessage({key:'UboHideAdBanner'});
LastAdTime = 0;
}
if (haveAdTags) {
LastAdUrl = url;
LastAdTime = Date.now();
/*if (OPT_MODE_NOTIFY_ADS_WATCHED) {
console.log('Stripping ads (instead of skipping ads)');
}*/
var streamInfo = StreamInfosByUrl[url];
if (streamInfo == null) {
console.log('Unknown stream url! ' + url);
console.log('Unknown stream url ' + url);
postMessage({key:'UboHideAdBanner'});
return textStr;
}
if (!streamInfo.BackupFailed && streamInfo.BackupUrl == null) {
@ -283,71 +373,69 @@
}
}
var lines = textStr.replace('\r', '').split('\n');
if (OPT_MODE_STRIP_AD_SEGMENTS_NEWEST) {
var newLines = [];
if (backupM3u8 != null) {
var backupLines = backupM3u8.replace('\r', '').split('\n');
var segUrls = getSegmentUrls(lines, false);
var backupSegUrls = getSegmentUrls(backupLines, OPT_MODE_STRIP_AD_SEGMENTS_NEWEST_WITH_PREFETCH);
for (var i = segUrls.length - 1, j = backupSegUrls.length - 1; i >= 0 && j >= 0; i--, j--) {
if (streamInfo.SegmentMap[segUrls[i]] == null) {
streamInfo.SegmentMap[segUrls[i]] = backupSegUrls[j];
var seqMatch = /#EXT-X-MEDIA-SEQUENCE:([0-9]*)/.exec(backupM3u8);
if (seqMatch != null) {
var oldBackupSeqNumber = streamInfo.BackupSeqNumber;
streamInfo.BackupSeqNumber = Math.max(0, parseInt(seqMatch[1]));
if (streamInfo.RealSeqNumber > 0) {
// We already have a real stream, this must be a midroll. We should therefore increment rather than just using backup directly.
// - If we don't do this then our sequence number will be broken and the stream will get stuck in a loading state.
if (streamInfo.FakeSeqNumber == 0) {
streamInfo.FakeSeqNumber = streamInfo.RealSeqNumber;
}
if (oldBackupSeqNumber == -1) {
// First backup sequence, assume +1
streamInfo.FakeSeqNumber++;
}
}
for (var i = 0; i < lines.length; i++) {
var line = lines[i];
if (line == null) {
continue;
}
if (line.includes('stitched-ad')) {
lines[i] = '';
}
if (line.startsWith('#EXTINF:') && !line.includes(',live')) {
var newSegUrl = streamInfo.SegmentMap[lines[i + 1]];
//lines[i] = line.substring(0, line.indexOf(',')) + ',live';
lines[i + 1] = newSegUrl != null ? newSegUrl : '';
}
/*if (line.startsWith('#EXT-X-TWITCH-PREFETCH')) {
// TODO
lines[i] = '';
}*/
else {
streamInfo.FakeSeqNumber += Math.max(0, streamInfo.BackupSeqNumber - oldBackupSeqNumber);
}
} else {
var segmentMap = [];
if (backupM3u8 != null) {
streamInfo.FakeSeqNumber = streamInfo.BackupSeqNumber;
}
}
var backupLines = backupM3u8.replace('\r', '').split('\n');
var segTimes = getSegmentTimes(lines);
var backupSegTimes = getSegmentTimes(backupLines);
for (const [segTime, segUrl] of Object.entries(segTimes)) {
//segmentMap[segUrl] = Object.values(backupSegTimes)[Object.keys(backupSegTimes).length-1];
var closestTime = Number.MAX_VALUE;
var matchingBackupTime = Number.MAX_VALUE;
for (const [backupSegTime, backupSegUrl] of Object.entries(backupSegTimes)) {
var timeDiff = Math.abs(segTime - backupSegTime);
if (timeDiff < closestTime) {
closestTime = timeDiff;
matchingBackupTime = backupSegTime;
segmentMap[segUrl] = backupSegUrl;
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 (closestTime != Number.MAX_VALUE) {
backupSegTimes.splice(backupSegTimes.indexOf(matchingBackupTime), 1);
if (segData != null) {
if (segData.isPrefetch) {
pushedPrefetchSegs++;
newLines.push(segData.urlLine);
} else {
//newLines.push(segData.dateTimeLine);
newLines.push(segData.extInfLine);
newLines.push(segData.urlLine);
}
}
}
for (var i = 0; i < lines.length; i++) {
var line = lines[i];
if (line.includes('stitched-ad')) {
lines[i] = '';
}
if (line.startsWith('#EXTINF:') && !line.includes(',live')) {
lines[i] = line.substring(0, line.indexOf(',')) + ',live';
var backupSegment = segmentMap[lines[i + 1]];
lines[i + 1] = backupSegment != null ? backupSegment : ''
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 = lines.join('\n');
textStr = newLines.length > 0 ? newLines.join('\n') : lines.join('\n');
//console.log(textStr);
}
return textStr;
@ -356,6 +444,22 @@
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) {
@ -429,9 +533,13 @@
streamInfo.RootM3U8Params = (new URL(url)).search;
streamInfo.BackupUrl = null;
streamInfo.BackupFailed = false;
streamInfo.SegmentMap = [];
streamInfo.SegmentCache = [];
streamInfo.IsMidroll = false;
streamInfo.NotifyFirstTime = 0;
streamInfo.NotifyObservedNoAds = false;
streamInfo.RealSeqNumber = -1;
streamInfo.BackupSeqNumber = -1;
streamInfo.FakeSeqNumber = 0;
var lines = encodingsM3u8.replace('\r', '').split('\n');
for (var i = 0; i < lines.length; i++) {
if (!lines[i].startsWith('#') && lines[i].includes('.m3u8')) {
@ -619,12 +727,7 @@
if (typeof url === 'string') {
if (url.includes('/access_token') || url.includes('gql')) {
if (OPT_ACCESS_TOKEN_PLAYER_TYPE) {
if (url.includes('/access_token')) {
var modifiedUrl = new URL(url);
modifiedUrl.searchParams.set('player_type', OPT_ACCESS_TOKEN_PLAYER_TYPE);
arguments[0] = modifiedUrl.href;
}
else if (url.includes('gql') && init && typeof init.body === 'string' && init.body.includes('PlaybackAccessToken')) {
if (url.includes('gql') && init && typeof init.body === 'string' && init.body.includes('PlaybackAccessToken')) {
const newBody = JSON.parse(init.body);
newBody.variables.playerType = OPT_ACCESS_TOKEN_PLAYER_TYPE;
init.body = JSON.stringify(newBody);
@ -643,63 +746,13 @@
value: gql_device_id
});
}
if (OPT_MODE_NOTIFY_ADS_WATCHED) {
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) {
if (OPT_MODE_NOTIFY_ADS_WATCHED_ASYNC_TOK) {
var channelName = JSON.parse(init.body).variables.login;
// See if the first response has an ad, if it does then send requests until there is no ad
{
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, -1, responseData.data.streamPlaybackAccessToken.signature, responseData.data.streamPlaybackAccessToken.value) == 1) {
console.log('No ad in main request');
resolve(new Response(responseStr));
return;
} else {
console.log('Ad in main request');
}
}
}
if (!channelName) {
resolve(response);
return;
}
// Ads are being served, try skipping a bunch of ads
var resolved = false;
var skipAdTries = 0;
for (var i = 0; i < OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS; i++) {
new Promise(async (skipAdResolve, skipAdReject) => {
var noAds = false;
var accessTokenResponse = await getAccessToken(channelName, OPT_REGULAR_PLAYER_TYPE, realFetch);
if (accessTokenResponse.status == 200) {
var responseStr = await accessTokenResponse.text();
var responseData = JSON.parse(responseStr);
var hasAd = false;
if (responseData && responseData.data && responseData.data.streamPlaybackAccessToken && responseData.data.streamPlaybackAccessToken.value && responseData.data.streamPlaybackAccessToken.signature) {
hasAd = (await tryNotifyAdsWatchedSigTok(realFetch, -1, responseData.data.streamPlaybackAccessToken.signature, responseData.data.streamPlaybackAccessToken.value)) == 0;
}
var attempt = ++skipAdTries;
console.log('Attempt ' + attempt + ' ' + (hasAd ? 'has ad' : 'no ad'));
if (attempt === OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS && !resolved) {
resolved = true;
resolve(new Response(responseStr));
return;
}
} else if (!resolved) {
resolved = true;
resolve(response);
return;
}
}).catch(console.log);
}
} else {
for (var i = 0; i < OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS; i++) {
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);
@ -715,7 +768,6 @@
}
}
resolve(response);
}
} else {
resolve(response);
}

View File

@ -8,14 +8,11 @@ twitch-videoad.js application/javascript
scope.OPT_MODE_LOW_RES = false;
scope.OPT_MODE_EMBED = false;
scope.OPT_MODE_STRIP_AD_SEGMENTS = true;
scope.OPT_MODE_STRIP_AD_SEGMENTS_NEWEST = true;
scope.OPT_MODE_STRIP_AD_SEGMENTS_NEWEST_WITH_PREFETCH = false;// This will result in a very close match, but often a repeat of a second or so. May be preferred if you dislike the regular 2-3 jump.
scope.OPT_MODE_NOTIFY_ADS_WATCHED = true;
scope.OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS = 1;
scope.OPT_MODE_NOTIFY_ADS_WATCHED_INITIAL_ATTEMPTS = 0;
scope.OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST = true;
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_ASYNC_TOK = false;
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;
@ -83,8 +80,8 @@ twitch-videoad.js application/javascript
}
var newBlobStr = `
${processM3U8.toString()}
${getSegmentTimes.toString()}
${getSegmentUrls.toString()}
${getSegmentInfos.toString()}
${getSegmentInfosLines.toString()}
${hookWorkerFetch.toString()}
${declareOptions.toString()}
${getAccessToken.toString()}
@ -149,33 +146,135 @@ twitch-videoad.js application/javascript
req.send();
return req.responseText.split("'")[1];
}
function getSegmentTimes(lines) {
var result = [];
var lastDate = 0;
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-PROGRAM-DATE-TIME:')) {
lastDate = Date.parse(line.substring(line.indexOf(':') + 1));
} else if (line.startsWith('http')) {
result[lastDate] = line;
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].split(':')[1]);
}
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 getSegmentUrls(lines, includePrefetch) {
var result = [];
for (var i = 0; i < lines.length; i++) {
var line = lines[i];
if (line.startsWith('http')) {
result.push(line);
} else if (includePrefetch && line.startsWith('#EXT-X-TWITCH-PREFETCH:')) {
result.push(line.substring(line.indexOf(':') + 1));
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;
}
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) {
si.BackupSeqNumber = -1;
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 previously modified the sequence number, we need to keep doing so (alternatively pause/playing might work better)
si.FakeSeqNumber += Math.max(0, si.RealSeqNumber - oldRealSeq);
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');
}
}
if (haveAdTags) {
var si = StreamInfosByUrl[url];
if (OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST && si != null && !si.NotifyObservedNoAds) {
@ -202,7 +301,7 @@ twitch-videoad.js application/javascript
console.log('Reload player');
postMessage({key:'UboHideAdBanner'});
postMessage({key:'UboReloadPlayer'});
return "";
return '';
}
}
postMessage({
@ -214,22 +313,13 @@ twitch-videoad.js application/javascript
if (!OPT_MODE_STRIP_AD_SEGMENTS) {
return textStr;
}
// NOTE: midroll ads are intertwined with live segments, always display the banner on midroll ads
if (haveAdTags && (!textStr.includes(LIVE_SIGNIFIER) || textStr.includes('MIDROLL'))) {
postMessage({key:'UboShowAdBanner',isMidroll:textStr.includes('MIDROLL')});
} else if ((LastAdUrl && LastAdUrl == url) || LastAdTime < Date.now() - 10000) {
postMessage({key:'UboHideAdBanner'});
LastAdTime = 0;
}
if (haveAdTags) {
LastAdUrl = url;
LastAdTime = Date.now();
/*if (OPT_MODE_NOTIFY_ADS_WATCHED) {
console.log('Stripping ads (instead of skipping ads)');
}*/
var streamInfo = StreamInfosByUrl[url];
if (streamInfo == null) {
console.log('Unknown stream url! ' + url);
console.log('Unknown stream url ' + url);
postMessage({key:'UboHideAdBanner'});
return textStr;
}
if (!streamInfo.BackupFailed && streamInfo.BackupUrl == null) {
@ -272,71 +362,69 @@ twitch-videoad.js application/javascript
}
}
var lines = textStr.replace('\r', '').split('\n');
if (OPT_MODE_STRIP_AD_SEGMENTS_NEWEST) {
var newLines = [];
if (backupM3u8 != null) {
var backupLines = backupM3u8.replace('\r', '').split('\n');
var segUrls = getSegmentUrls(lines, false);
var backupSegUrls = getSegmentUrls(backupLines, OPT_MODE_STRIP_AD_SEGMENTS_NEWEST_WITH_PREFETCH);
for (var i = segUrls.length - 1, j = backupSegUrls.length - 1; i >= 0 && j >= 0; i--, j--) {
if (streamInfo.SegmentMap[segUrls[i]] == null) {
streamInfo.SegmentMap[segUrls[i]] = backupSegUrls[j];
var seqMatch = /#EXT-X-MEDIA-SEQUENCE:([0-9]*)/.exec(backupM3u8);
if (seqMatch != null) {
var oldBackupSeqNumber = streamInfo.BackupSeqNumber;
streamInfo.BackupSeqNumber = Math.max(0, parseInt(seqMatch[1]));
if (streamInfo.RealSeqNumber > 0) {
// We already have a real stream, this must be a midroll. We should therefore increment rather than just using backup directly.
// - If we don't do this then our sequence number will be broken and the stream will get stuck in a loading state.
if (streamInfo.FakeSeqNumber == 0) {
streamInfo.FakeSeqNumber = streamInfo.RealSeqNumber;
}
if (oldBackupSeqNumber == -1) {
// First backup sequence, assume +1
streamInfo.FakeSeqNumber++;
}
}
for (var i = 0; i < lines.length; i++) {
var line = lines[i];
if (line == null) {
continue;
}
if (line.includes('stitched-ad')) {
lines[i] = '';
}
if (line.startsWith('#EXTINF:') && !line.includes(',live')) {
var newSegUrl = streamInfo.SegmentMap[lines[i + 1]];
//lines[i] = line.substring(0, line.indexOf(',')) + ',live';
lines[i + 1] = newSegUrl != null ? newSegUrl : '';
}
/*if (line.startsWith('#EXT-X-TWITCH-PREFETCH')) {
// TODO
lines[i] = '';
}*/
else {
streamInfo.FakeSeqNumber += Math.max(0, streamInfo.BackupSeqNumber - oldBackupSeqNumber);
}
} else {
var segmentMap = [];
if (backupM3u8 != null) {
streamInfo.FakeSeqNumber = streamInfo.BackupSeqNumber;
}
}
var backupLines = backupM3u8.replace('\r', '').split('\n');
var segTimes = getSegmentTimes(lines);
var backupSegTimes = getSegmentTimes(backupLines);
for (const [segTime, segUrl] of Object.entries(segTimes)) {
//segmentMap[segUrl] = Object.values(backupSegTimes)[Object.keys(backupSegTimes).length-1];
var closestTime = Number.MAX_VALUE;
var matchingBackupTime = Number.MAX_VALUE;
for (const [backupSegTime, backupSegUrl] of Object.entries(backupSegTimes)) {
var timeDiff = Math.abs(segTime - backupSegTime);
if (timeDiff < closestTime) {
closestTime = timeDiff;
matchingBackupTime = backupSegTime;
segmentMap[segUrl] = backupSegUrl;
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 (closestTime != Number.MAX_VALUE) {
backupSegTimes.splice(backupSegTimes.indexOf(matchingBackupTime), 1);
if (segData != null) {
if (segData.isPrefetch) {
pushedPrefetchSegs++;
newLines.push(segData.urlLine);
} else {
//newLines.push(segData.dateTimeLine);
newLines.push(segData.extInfLine);
newLines.push(segData.urlLine);
}
}
}
for (var i = 0; i < lines.length; i++) {
var line = lines[i];
if (line.includes('stitched-ad')) {
lines[i] = '';
}
if (line.startsWith('#EXTINF:') && !line.includes(',live')) {
lines[i] = line.substring(0, line.indexOf(',')) + ',live';
var backupSegment = segmentMap[lines[i + 1]];
lines[i + 1] = backupSegment != null ? backupSegment : ''
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 = lines.join('\n');
textStr = newLines.length > 0 ? newLines.join('\n') : lines.join('\n');
//console.log(textStr);
}
return textStr;
@ -345,6 +433,22 @@ twitch-videoad.js application/javascript
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) {
@ -418,9 +522,13 @@ twitch-videoad.js application/javascript
streamInfo.RootM3U8Params = (new URL(url)).search;
streamInfo.BackupUrl = null;
streamInfo.BackupFailed = false;
streamInfo.SegmentMap = [];
streamInfo.SegmentCache = [];
streamInfo.IsMidroll = false;
streamInfo.NotifyFirstTime = 0;
streamInfo.NotifyObservedNoAds = false;
streamInfo.RealSeqNumber = -1;
streamInfo.BackupSeqNumber = -1;
streamInfo.FakeSeqNumber = 0;
var lines = encodingsM3u8.replace('\r', '').split('\n');
for (var i = 0; i < lines.length; i++) {
if (!lines[i].startsWith('#') && lines[i].includes('.m3u8')) {
@ -608,12 +716,7 @@ twitch-videoad.js application/javascript
if (typeof url === 'string') {
if (url.includes('/access_token') || url.includes('gql')) {
if (OPT_ACCESS_TOKEN_PLAYER_TYPE) {
if (url.includes('/access_token')) {
var modifiedUrl = new URL(url);
modifiedUrl.searchParams.set('player_type', OPT_ACCESS_TOKEN_PLAYER_TYPE);
arguments[0] = modifiedUrl.href;
}
else if (url.includes('gql') && init && typeof init.body === 'string' && init.body.includes('PlaybackAccessToken')) {
if (url.includes('gql') && init && typeof init.body === 'string' && init.body.includes('PlaybackAccessToken')) {
const newBody = JSON.parse(init.body);
newBody.variables.playerType = OPT_ACCESS_TOKEN_PLAYER_TYPE;
init.body = JSON.stringify(newBody);
@ -632,63 +735,13 @@ twitch-videoad.js application/javascript
value: gql_device_id
});
}
if (OPT_MODE_NOTIFY_ADS_WATCHED) {
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) {
if (OPT_MODE_NOTIFY_ADS_WATCHED_ASYNC_TOK) {
var channelName = JSON.parse(init.body).variables.login;
// See if the first response has an ad, if it does then send requests until there is no ad
{
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, -1, responseData.data.streamPlaybackAccessToken.signature, responseData.data.streamPlaybackAccessToken.value) == 1) {
console.log('No ad in main request');
resolve(new Response(responseStr));
return;
} else {
console.log('Ad in main request');
}
}
}
if (!channelName) {
resolve(response);
return;
}
// Ads are being served, try skipping a bunch of ads
var resolved = false;
var skipAdTries = 0;
for (var i = 0; i < OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS; i++) {
new Promise(async (skipAdResolve, skipAdReject) => {
var noAds = false;
var accessTokenResponse = await getAccessToken(channelName, OPT_REGULAR_PLAYER_TYPE, realFetch);
if (accessTokenResponse.status == 200) {
var responseStr = await accessTokenResponse.text();
var responseData = JSON.parse(responseStr);
var hasAd = false;
if (responseData && responseData.data && responseData.data.streamPlaybackAccessToken && responseData.data.streamPlaybackAccessToken.value && responseData.data.streamPlaybackAccessToken.signature) {
hasAd = (await tryNotifyAdsWatchedSigTok(realFetch, -1, responseData.data.streamPlaybackAccessToken.signature, responseData.data.streamPlaybackAccessToken.value)) == 0;
}
var attempt = ++skipAdTries;
console.log('Attempt ' + attempt + ' ' + (hasAd ? 'has ad' : 'no ad'));
if (attempt === OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS && !resolved) {
resolved = true;
resolve(new Response(responseStr));
return;
}
} else if (!resolved) {
resolved = true;
resolve(response);
return;
}
}).catch(console.log);
}
} else {
for (var i = 0; i < OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS; i++) {
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);
@ -704,7 +757,6 @@ twitch-videoad.js application/javascript
}
}
resolve(response);
}
} else {
resolve(response);
}

View File

@ -1,7 +1,7 @@
// ==UserScript==
// @name TwitchAdSolutions
// @namespace https://github.com/pixeltris/TwitchAdSolutions
// @version 1.2
// @version 1.3
// @updateURL https://github.com/pixeltris/TwitchAdSolutions/raw/master/notify-strip/notify-strip.user.js
// @downloadURL https://github.com/pixeltris/TwitchAdSolutions/raw/master/notify-strip/notify-strip.user.js
// @description Multiple solutions for blocking Twitch ads (notify-strip)
@ -19,14 +19,11 @@
scope.OPT_MODE_LOW_RES = false;
scope.OPT_MODE_EMBED = false;
scope.OPT_MODE_STRIP_AD_SEGMENTS = true;
scope.OPT_MODE_STRIP_AD_SEGMENTS_NEWEST = true;
scope.OPT_MODE_STRIP_AD_SEGMENTS_NEWEST_WITH_PREFETCH = false;// This will result in a very close match, but often a repeat of a second or so. May be preferred if you dislike the regular 2-3 jump.
scope.OPT_MODE_NOTIFY_ADS_WATCHED = true;
scope.OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS = 1;
scope.OPT_MODE_NOTIFY_ADS_WATCHED_INITIAL_ATTEMPTS = 0;
scope.OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST = true;
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_ASYNC_TOK = false;
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;
@ -94,8 +91,8 @@
}
var newBlobStr = `
${processM3U8.toString()}
${getSegmentTimes.toString()}
${getSegmentUrls.toString()}
${getSegmentInfos.toString()}
${getSegmentInfosLines.toString()}
${hookWorkerFetch.toString()}
${declareOptions.toString()}
${getAccessToken.toString()}
@ -160,33 +157,135 @@
req.send();
return req.responseText.split("'")[1];
}
function getSegmentTimes(lines) {
var result = [];
var lastDate = 0;
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-PROGRAM-DATE-TIME:')) {
lastDate = Date.parse(line.substring(line.indexOf(':') + 1));
} else if (line.startsWith('http')) {
result[lastDate] = line;
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].split(':')[1]);
}
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 getSegmentUrls(lines, includePrefetch) {
var result = [];
for (var i = 0; i < lines.length; i++) {
var line = lines[i];
if (line.startsWith('http')) {
result.push(line);
} else if (includePrefetch && line.startsWith('#EXT-X-TWITCH-PREFETCH:')) {
result.push(line.substring(line.indexOf(':') + 1));
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;
}
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) {
si.BackupSeqNumber = -1;
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 previously modified the sequence number, we need to keep doing so (alternatively pause/playing might work better)
si.FakeSeqNumber += Math.max(0, si.RealSeqNumber - oldRealSeq);
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');
}
}
if (haveAdTags) {
var si = StreamInfosByUrl[url];
if (OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST && si != null && !si.NotifyObservedNoAds) {
@ -213,7 +312,7 @@
console.log('Reload player');
postMessage({key:'UboHideAdBanner'});
postMessage({key:'UboReloadPlayer'});
return "";
return '';
}
}
postMessage({
@ -225,22 +324,13 @@
if (!OPT_MODE_STRIP_AD_SEGMENTS) {
return textStr;
}
// NOTE: midroll ads are intertwined with live segments, always display the banner on midroll ads
if (haveAdTags && (!textStr.includes(LIVE_SIGNIFIER) || textStr.includes('MIDROLL'))) {
postMessage({key:'UboShowAdBanner',isMidroll:textStr.includes('MIDROLL')});
} else if ((LastAdUrl && LastAdUrl == url) || LastAdTime < Date.now() - 10000) {
postMessage({key:'UboHideAdBanner'});
LastAdTime = 0;
}
if (haveAdTags) {
LastAdUrl = url;
LastAdTime = Date.now();
/*if (OPT_MODE_NOTIFY_ADS_WATCHED) {
console.log('Stripping ads (instead of skipping ads)');
}*/
var streamInfo = StreamInfosByUrl[url];
if (streamInfo == null) {
console.log('Unknown stream url! ' + url);
console.log('Unknown stream url ' + url);
postMessage({key:'UboHideAdBanner'});
return textStr;
}
if (!streamInfo.BackupFailed && streamInfo.BackupUrl == null) {
@ -283,71 +373,69 @@
}
}
var lines = textStr.replace('\r', '').split('\n');
if (OPT_MODE_STRIP_AD_SEGMENTS_NEWEST) {
var newLines = [];
if (backupM3u8 != null) {
var backupLines = backupM3u8.replace('\r', '').split('\n');
var segUrls = getSegmentUrls(lines, false);
var backupSegUrls = getSegmentUrls(backupLines, OPT_MODE_STRIP_AD_SEGMENTS_NEWEST_WITH_PREFETCH);
for (var i = segUrls.length - 1, j = backupSegUrls.length - 1; i >= 0 && j >= 0; i--, j--) {
if (streamInfo.SegmentMap[segUrls[i]] == null) {
streamInfo.SegmentMap[segUrls[i]] = backupSegUrls[j];
var seqMatch = /#EXT-X-MEDIA-SEQUENCE:([0-9]*)/.exec(backupM3u8);
if (seqMatch != null) {
var oldBackupSeqNumber = streamInfo.BackupSeqNumber;
streamInfo.BackupSeqNumber = Math.max(0, parseInt(seqMatch[1]));
if (streamInfo.RealSeqNumber > 0) {
// We already have a real stream, this must be a midroll. We should therefore increment rather than just using backup directly.
// - If we don't do this then our sequence number will be broken and the stream will get stuck in a loading state.
if (streamInfo.FakeSeqNumber == 0) {
streamInfo.FakeSeqNumber = streamInfo.RealSeqNumber;
}
if (oldBackupSeqNumber == -1) {
// First backup sequence, assume +1
streamInfo.FakeSeqNumber++;
}
}
for (var i = 0; i < lines.length; i++) {
var line = lines[i];
if (line == null) {
continue;
}
if (line.includes('stitched-ad')) {
lines[i] = '';
}
if (line.startsWith('#EXTINF:') && !line.includes(',live')) {
var newSegUrl = streamInfo.SegmentMap[lines[i + 1]];
//lines[i] = line.substring(0, line.indexOf(',')) + ',live';
lines[i + 1] = newSegUrl != null ? newSegUrl : '';
}
/*if (line.startsWith('#EXT-X-TWITCH-PREFETCH')) {
// TODO
lines[i] = '';
}*/
else {
streamInfo.FakeSeqNumber += Math.max(0, streamInfo.BackupSeqNumber - oldBackupSeqNumber);
}
} else {
var segmentMap = [];
if (backupM3u8 != null) {
streamInfo.FakeSeqNumber = streamInfo.BackupSeqNumber;
}
}
var backupLines = backupM3u8.replace('\r', '').split('\n');
var segTimes = getSegmentTimes(lines);
var backupSegTimes = getSegmentTimes(backupLines);
for (const [segTime, segUrl] of Object.entries(segTimes)) {
//segmentMap[segUrl] = Object.values(backupSegTimes)[Object.keys(backupSegTimes).length-1];
var closestTime = Number.MAX_VALUE;
var matchingBackupTime = Number.MAX_VALUE;
for (const [backupSegTime, backupSegUrl] of Object.entries(backupSegTimes)) {
var timeDiff = Math.abs(segTime - backupSegTime);
if (timeDiff < closestTime) {
closestTime = timeDiff;
matchingBackupTime = backupSegTime;
segmentMap[segUrl] = backupSegUrl;
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 (closestTime != Number.MAX_VALUE) {
backupSegTimes.splice(backupSegTimes.indexOf(matchingBackupTime), 1);
if (segData != null) {
if (segData.isPrefetch) {
pushedPrefetchSegs++;
newLines.push(segData.urlLine);
} else {
//newLines.push(segData.dateTimeLine);
newLines.push(segData.extInfLine);
newLines.push(segData.urlLine);
}
}
}
for (var i = 0; i < lines.length; i++) {
var line = lines[i];
if (line.includes('stitched-ad')) {
lines[i] = '';
}
if (line.startsWith('#EXTINF:') && !line.includes(',live')) {
lines[i] = line.substring(0, line.indexOf(',')) + ',live';
var backupSegment = segmentMap[lines[i + 1]];
lines[i + 1] = backupSegment != null ? backupSegment : ''
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 = lines.join('\n');
textStr = newLines.length > 0 ? newLines.join('\n') : lines.join('\n');
//console.log(textStr);
}
return textStr;
@ -356,6 +444,22 @@
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) {
@ -429,9 +533,13 @@
streamInfo.RootM3U8Params = (new URL(url)).search;
streamInfo.BackupUrl = null;
streamInfo.BackupFailed = false;
streamInfo.SegmentMap = [];
streamInfo.SegmentCache = [];
streamInfo.IsMidroll = false;
streamInfo.NotifyFirstTime = 0;
streamInfo.NotifyObservedNoAds = false;
streamInfo.RealSeqNumber = -1;
streamInfo.BackupSeqNumber = -1;
streamInfo.FakeSeqNumber = 0;
var lines = encodingsM3u8.replace('\r', '').split('\n');
for (var i = 0; i < lines.length; i++) {
if (!lines[i].startsWith('#') && lines[i].includes('.m3u8')) {
@ -619,12 +727,7 @@
if (typeof url === 'string') {
if (url.includes('/access_token') || url.includes('gql')) {
if (OPT_ACCESS_TOKEN_PLAYER_TYPE) {
if (url.includes('/access_token')) {
var modifiedUrl = new URL(url);
modifiedUrl.searchParams.set('player_type', OPT_ACCESS_TOKEN_PLAYER_TYPE);
arguments[0] = modifiedUrl.href;
}
else if (url.includes('gql') && init && typeof init.body === 'string' && init.body.includes('PlaybackAccessToken')) {
if (url.includes('gql') && init && typeof init.body === 'string' && init.body.includes('PlaybackAccessToken')) {
const newBody = JSON.parse(init.body);
newBody.variables.playerType = OPT_ACCESS_TOKEN_PLAYER_TYPE;
init.body = JSON.stringify(newBody);
@ -643,63 +746,13 @@
value: gql_device_id
});
}
if (OPT_MODE_NOTIFY_ADS_WATCHED) {
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) {
if (OPT_MODE_NOTIFY_ADS_WATCHED_ASYNC_TOK) {
var channelName = JSON.parse(init.body).variables.login;
// See if the first response has an ad, if it does then send requests until there is no ad
{
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, -1, responseData.data.streamPlaybackAccessToken.signature, responseData.data.streamPlaybackAccessToken.value) == 1) {
console.log('No ad in main request');
resolve(new Response(responseStr));
return;
} else {
console.log('Ad in main request');
}
}
}
if (!channelName) {
resolve(response);
return;
}
// Ads are being served, try skipping a bunch of ads
var resolved = false;
var skipAdTries = 0;
for (var i = 0; i < OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS; i++) {
new Promise(async (skipAdResolve, skipAdReject) => {
var noAds = false;
var accessTokenResponse = await getAccessToken(channelName, OPT_REGULAR_PLAYER_TYPE, realFetch);
if (accessTokenResponse.status == 200) {
var responseStr = await accessTokenResponse.text();
var responseData = JSON.parse(responseStr);
var hasAd = false;
if (responseData && responseData.data && responseData.data.streamPlaybackAccessToken && responseData.data.streamPlaybackAccessToken.value && responseData.data.streamPlaybackAccessToken.signature) {
hasAd = (await tryNotifyAdsWatchedSigTok(realFetch, -1, responseData.data.streamPlaybackAccessToken.signature, responseData.data.streamPlaybackAccessToken.value)) == 0;
}
var attempt = ++skipAdTries;
console.log('Attempt ' + attempt + ' ' + (hasAd ? 'has ad' : 'no ad'));
if (attempt === OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS && !resolved) {
resolved = true;
resolve(new Response(responseStr));
return;
}
} else if (!resolved) {
resolved = true;
resolve(response);
return;
}
}).catch(console.log);
}
} else {
for (var i = 0; i < OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS; i++) {
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);
@ -715,7 +768,6 @@
}
}
resolve(response);
}
} else {
resolve(response);
}

View File

@ -7,15 +7,12 @@ twitch-videoad.js application/javascript
scope.OPT_MODE_VIDEO_SWAP = false;
scope.OPT_MODE_LOW_RES = false;
scope.OPT_MODE_EMBED = false;
scope.OPT_MODE_STRIP_AD_SEGMENTS = false;// The default implementation attempts to match segment times (TODO: needs improvements - looping issues - cache matched segments and don't repeat any)
scope.OPT_MODE_STRIP_AD_SEGMENTS_NEWEST = false;// Matches segments ordered by newest
scope.OPT_MODE_STRIP_AD_SEGMENTS_NEWEST_WITH_PREFETCH = false;// This will result in a very close match, but often a repeat of a second or so. May be preferred if you dislike the regular 2-3 jump.
scope.OPT_MODE_STRIP_AD_SEGMENTS = false;
scope.OPT_MODE_NOTIFY_ADS_WATCHED = false;
scope.OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS = 1;
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_ASYNC_TOK = false;
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;
@ -83,8 +80,8 @@ twitch-videoad.js application/javascript
}
var newBlobStr = `
${processM3U8.toString()}
${getSegmentTimes.toString()}
${getSegmentUrls.toString()}
${getSegmentInfos.toString()}
${getSegmentInfosLines.toString()}
${hookWorkerFetch.toString()}
${declareOptions.toString()}
${getAccessToken.toString()}
@ -149,33 +146,135 @@ twitch-videoad.js application/javascript
req.send();
return req.responseText.split("'")[1];
}
function getSegmentTimes(lines) {
var result = [];
var lastDate = 0;
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-PROGRAM-DATE-TIME:')) {
lastDate = Date.parse(line.substring(line.indexOf(':') + 1));
} else if (line.startsWith('http')) {
result[lastDate] = line;
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].split(':')[1]);
}
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 getSegmentUrls(lines, includePrefetch) {
var result = [];
for (var i = 0; i < lines.length; i++) {
var line = lines[i];
if (line.startsWith('http')) {
result.push(line);
} else if (includePrefetch && line.startsWith('#EXT-X-TWITCH-PREFETCH:')) {
result.push(line.substring(line.indexOf(':') + 1));
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;
}
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) {
si.BackupSeqNumber = -1;
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 previously modified the sequence number, we need to keep doing so (alternatively pause/playing might work better)
si.FakeSeqNumber += Math.max(0, si.RealSeqNumber - oldRealSeq);
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');
}
}
if (haveAdTags) {
var si = StreamInfosByUrl[url];
if (OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST && si != null && !si.NotifyObservedNoAds) {
@ -202,7 +301,7 @@ twitch-videoad.js application/javascript
console.log('Reload player');
postMessage({key:'UboHideAdBanner'});
postMessage({key:'UboReloadPlayer'});
return "";
return '';
}
}
postMessage({
@ -214,22 +313,13 @@ twitch-videoad.js application/javascript
if (!OPT_MODE_STRIP_AD_SEGMENTS) {
return textStr;
}
// NOTE: midroll ads are intertwined with live segments, always display the banner on midroll ads
if (haveAdTags && (!textStr.includes(LIVE_SIGNIFIER) || textStr.includes('MIDROLL'))) {
postMessage({key:'UboShowAdBanner',isMidroll:textStr.includes('MIDROLL')});
} else if ((LastAdUrl && LastAdUrl == url) || LastAdTime < Date.now() - 10000) {
postMessage({key:'UboHideAdBanner'});
LastAdTime = 0;
}
if (haveAdTags) {
LastAdUrl = url;
LastAdTime = Date.now();
/*if (OPT_MODE_NOTIFY_ADS_WATCHED) {
console.log('Stripping ads (instead of skipping ads)');
}*/
var streamInfo = StreamInfosByUrl[url];
if (streamInfo == null) {
console.log('Unknown stream url! ' + url);
console.log('Unknown stream url ' + url);
postMessage({key:'UboHideAdBanner'});
return textStr;
}
if (!streamInfo.BackupFailed && streamInfo.BackupUrl == null) {
@ -272,71 +362,69 @@ twitch-videoad.js application/javascript
}
}
var lines = textStr.replace('\r', '').split('\n');
if (OPT_MODE_STRIP_AD_SEGMENTS_NEWEST) {
var newLines = [];
if (backupM3u8 != null) {
var backupLines = backupM3u8.replace('\r', '').split('\n');
var segUrls = getSegmentUrls(lines, false);
var backupSegUrls = getSegmentUrls(backupLines, OPT_MODE_STRIP_AD_SEGMENTS_NEWEST_WITH_PREFETCH);
for (var i = segUrls.length - 1, j = backupSegUrls.length - 1; i >= 0 && j >= 0; i--, j--) {
if (streamInfo.SegmentMap[segUrls[i]] == null) {
streamInfo.SegmentMap[segUrls[i]] = backupSegUrls[j];
var seqMatch = /#EXT-X-MEDIA-SEQUENCE:([0-9]*)/.exec(backupM3u8);
if (seqMatch != null) {
var oldBackupSeqNumber = streamInfo.BackupSeqNumber;
streamInfo.BackupSeqNumber = Math.max(0, parseInt(seqMatch[1]));
if (streamInfo.RealSeqNumber > 0) {
// We already have a real stream, this must be a midroll. We should therefore increment rather than just using backup directly.
// - If we don't do this then our sequence number will be broken and the stream will get stuck in a loading state.
if (streamInfo.FakeSeqNumber == 0) {
streamInfo.FakeSeqNumber = streamInfo.RealSeqNumber;
}
if (oldBackupSeqNumber == -1) {
// First backup sequence, assume +1
streamInfo.FakeSeqNumber++;
}
}
for (var i = 0; i < lines.length; i++) {
var line = lines[i];
if (line == null) {
continue;
}
if (line.includes('stitched-ad')) {
lines[i] = '';
}
if (line.startsWith('#EXTINF:') && !line.includes(',live')) {
var newSegUrl = streamInfo.SegmentMap[lines[i + 1]];
//lines[i] = line.substring(0, line.indexOf(',')) + ',live';
lines[i + 1] = newSegUrl != null ? newSegUrl : '';
}
/*if (line.startsWith('#EXT-X-TWITCH-PREFETCH')) {
// TODO
lines[i] = '';
}*/
else {
streamInfo.FakeSeqNumber += Math.max(0, streamInfo.BackupSeqNumber - oldBackupSeqNumber);
}
} else {
var segmentMap = [];
if (backupM3u8 != null) {
streamInfo.FakeSeqNumber = streamInfo.BackupSeqNumber;
}
}
var backupLines = backupM3u8.replace('\r', '').split('\n');
var segTimes = getSegmentTimes(lines);
var backupSegTimes = getSegmentTimes(backupLines);
for (const [segTime, segUrl] of Object.entries(segTimes)) {
//segmentMap[segUrl] = Object.values(backupSegTimes)[Object.keys(backupSegTimes).length-1];
var closestTime = Number.MAX_VALUE;
var matchingBackupTime = Number.MAX_VALUE;
for (const [backupSegTime, backupSegUrl] of Object.entries(backupSegTimes)) {
var timeDiff = Math.abs(segTime - backupSegTime);
if (timeDiff < closestTime) {
closestTime = timeDiff;
matchingBackupTime = backupSegTime;
segmentMap[segUrl] = backupSegUrl;
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 (closestTime != Number.MAX_VALUE) {
backupSegTimes.splice(backupSegTimes.indexOf(matchingBackupTime), 1);
if (segData != null) {
if (segData.isPrefetch) {
pushedPrefetchSegs++;
newLines.push(segData.urlLine);
} else {
//newLines.push(segData.dateTimeLine);
newLines.push(segData.extInfLine);
newLines.push(segData.urlLine);
}
}
}
for (var i = 0; i < lines.length; i++) {
var line = lines[i];
if (line.includes('stitched-ad')) {
lines[i] = '';
}
if (line.startsWith('#EXTINF:') && !line.includes(',live')) {
lines[i] = line.substring(0, line.indexOf(',')) + ',live';
var backupSegment = segmentMap[lines[i + 1]];
lines[i + 1] = backupSegment != null ? backupSegment : ''
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 = lines.join('\n');
textStr = newLines.length > 0 ? newLines.join('\n') : lines.join('\n');
//console.log(textStr);
}
return textStr;
@ -345,6 +433,22 @@ twitch-videoad.js application/javascript
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) {
@ -418,9 +522,13 @@ twitch-videoad.js application/javascript
streamInfo.RootM3U8Params = (new URL(url)).search;
streamInfo.BackupUrl = null;
streamInfo.BackupFailed = false;
streamInfo.SegmentMap = [];
streamInfo.SegmentCache = [];
streamInfo.IsMidroll = false;
streamInfo.NotifyFirstTime = 0;
streamInfo.NotifyObservedNoAds = false;
streamInfo.RealSeqNumber = -1;
streamInfo.BackupSeqNumber = -1;
streamInfo.FakeSeqNumber = 0;
var lines = encodingsM3u8.replace('\r', '').split('\n');
for (var i = 0; i < lines.length; i++) {
if (!lines[i].startsWith('#') && lines[i].includes('.m3u8')) {
@ -608,12 +716,7 @@ twitch-videoad.js application/javascript
if (typeof url === 'string') {
if (url.includes('/access_token') || url.includes('gql')) {
if (OPT_ACCESS_TOKEN_PLAYER_TYPE) {
if (url.includes('/access_token')) {
var modifiedUrl = new URL(url);
modifiedUrl.searchParams.set('player_type', OPT_ACCESS_TOKEN_PLAYER_TYPE);
arguments[0] = modifiedUrl.href;
}
else if (url.includes('gql') && init && typeof init.body === 'string' && init.body.includes('PlaybackAccessToken')) {
if (url.includes('gql') && init && typeof init.body === 'string' && init.body.includes('PlaybackAccessToken')) {
const newBody = JSON.parse(init.body);
newBody.variables.playerType = OPT_ACCESS_TOKEN_PLAYER_TYPE;
init.body = JSON.stringify(newBody);
@ -632,63 +735,13 @@ twitch-videoad.js application/javascript
value: gql_device_id
});
}
if (OPT_MODE_NOTIFY_ADS_WATCHED) {
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) {
if (OPT_MODE_NOTIFY_ADS_WATCHED_ASYNC_TOK) {
var channelName = JSON.parse(init.body).variables.login;
// See if the first response has an ad, if it does then send requests until there is no ad
{
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, -1, responseData.data.streamPlaybackAccessToken.signature, responseData.data.streamPlaybackAccessToken.value) == 1) {
console.log('No ad in main request');
resolve(new Response(responseStr));
return;
} else {
console.log('Ad in main request');
}
}
}
if (!channelName) {
resolve(response);
return;
}
// Ads are being served, try skipping a bunch of ads
var resolved = false;
var skipAdTries = 0;
for (var i = 0; i < OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS; i++) {
new Promise(async (skipAdResolve, skipAdReject) => {
var noAds = false;
var accessTokenResponse = await getAccessToken(channelName, OPT_REGULAR_PLAYER_TYPE, realFetch);
if (accessTokenResponse.status == 200) {
var responseStr = await accessTokenResponse.text();
var responseData = JSON.parse(responseStr);
var hasAd = false;
if (responseData && responseData.data && responseData.data.streamPlaybackAccessToken && responseData.data.streamPlaybackAccessToken.value && responseData.data.streamPlaybackAccessToken.signature) {
hasAd = (await tryNotifyAdsWatchedSigTok(realFetch, -1, responseData.data.streamPlaybackAccessToken.signature, responseData.data.streamPlaybackAccessToken.value)) == 0;
}
var attempt = ++skipAdTries;
console.log('Attempt ' + attempt + ' ' + (hasAd ? 'has ad' : 'no ad'));
if (attempt === OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS && !resolved) {
resolved = true;
resolve(new Response(responseStr));
return;
}
} else if (!resolved) {
resolved = true;
resolve(response);
return;
}
}).catch(console.log);
}
} else {
for (var i = 0; i < OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS; i++) {
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);
@ -704,7 +757,6 @@ twitch-videoad.js application/javascript
}
}
resolve(response);
}
} else {
resolve(response);
}

View File

@ -1,7 +1,7 @@
// ==UserScript==
// @name TwitchAdSolutions
// @namespace https://github.com/pixeltris/TwitchAdSolutions
// @version 1.2
// @version 1.3
// @updateURL https://github.com/pixeltris/TwitchAdSolutions/raw/master/proxy-m3u8/proxy-m3u8.user.js
// @downloadURL https://github.com/pixeltris/TwitchAdSolutions/raw/master/proxy-m3u8/proxy-m3u8.user.js
// @description Multiple solutions for blocking Twitch ads (proxy-m3u8)
@ -18,15 +18,12 @@
scope.OPT_MODE_VIDEO_SWAP = false;
scope.OPT_MODE_LOW_RES = false;
scope.OPT_MODE_EMBED = false;
scope.OPT_MODE_STRIP_AD_SEGMENTS = false;// The default implementation attempts to match segment times (TODO: needs improvements - looping issues - cache matched segments and don't repeat any)
scope.OPT_MODE_STRIP_AD_SEGMENTS_NEWEST = false;// Matches segments ordered by newest
scope.OPT_MODE_STRIP_AD_SEGMENTS_NEWEST_WITH_PREFETCH = false;// This will result in a very close match, but often a repeat of a second or so. May be preferred if you dislike the regular 2-3 jump.
scope.OPT_MODE_STRIP_AD_SEGMENTS = false;
scope.OPT_MODE_NOTIFY_ADS_WATCHED = false;
scope.OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS = 1;
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_ASYNC_TOK = false;
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;
@ -94,8 +91,8 @@
}
var newBlobStr = `
${processM3U8.toString()}
${getSegmentTimes.toString()}
${getSegmentUrls.toString()}
${getSegmentInfos.toString()}
${getSegmentInfosLines.toString()}
${hookWorkerFetch.toString()}
${declareOptions.toString()}
${getAccessToken.toString()}
@ -160,33 +157,135 @@
req.send();
return req.responseText.split("'")[1];
}
function getSegmentTimes(lines) {
var result = [];
var lastDate = 0;
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-PROGRAM-DATE-TIME:')) {
lastDate = Date.parse(line.substring(line.indexOf(':') + 1));
} else if (line.startsWith('http')) {
result[lastDate] = line;
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].split(':')[1]);
}
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 getSegmentUrls(lines, includePrefetch) {
var result = [];
for (var i = 0; i < lines.length; i++) {
var line = lines[i];
if (line.startsWith('http')) {
result.push(line);
} else if (includePrefetch && line.startsWith('#EXT-X-TWITCH-PREFETCH:')) {
result.push(line.substring(line.indexOf(':') + 1));
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;
}
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) {
si.BackupSeqNumber = -1;
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 previously modified the sequence number, we need to keep doing so (alternatively pause/playing might work better)
si.FakeSeqNumber += Math.max(0, si.RealSeqNumber - oldRealSeq);
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');
}
}
if (haveAdTags) {
var si = StreamInfosByUrl[url];
if (OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST && si != null && !si.NotifyObservedNoAds) {
@ -213,7 +312,7 @@
console.log('Reload player');
postMessage({key:'UboHideAdBanner'});
postMessage({key:'UboReloadPlayer'});
return "";
return '';
}
}
postMessage({
@ -225,22 +324,13 @@
if (!OPT_MODE_STRIP_AD_SEGMENTS) {
return textStr;
}
// NOTE: midroll ads are intertwined with live segments, always display the banner on midroll ads
if (haveAdTags && (!textStr.includes(LIVE_SIGNIFIER) || textStr.includes('MIDROLL'))) {
postMessage({key:'UboShowAdBanner',isMidroll:textStr.includes('MIDROLL')});
} else if ((LastAdUrl && LastAdUrl == url) || LastAdTime < Date.now() - 10000) {
postMessage({key:'UboHideAdBanner'});
LastAdTime = 0;
}
if (haveAdTags) {
LastAdUrl = url;
LastAdTime = Date.now();
/*if (OPT_MODE_NOTIFY_ADS_WATCHED) {
console.log('Stripping ads (instead of skipping ads)');
}*/
var streamInfo = StreamInfosByUrl[url];
if (streamInfo == null) {
console.log('Unknown stream url! ' + url);
console.log('Unknown stream url ' + url);
postMessage({key:'UboHideAdBanner'});
return textStr;
}
if (!streamInfo.BackupFailed && streamInfo.BackupUrl == null) {
@ -283,71 +373,69 @@
}
}
var lines = textStr.replace('\r', '').split('\n');
if (OPT_MODE_STRIP_AD_SEGMENTS_NEWEST) {
var newLines = [];
if (backupM3u8 != null) {
var backupLines = backupM3u8.replace('\r', '').split('\n');
var segUrls = getSegmentUrls(lines, false);
var backupSegUrls = getSegmentUrls(backupLines, OPT_MODE_STRIP_AD_SEGMENTS_NEWEST_WITH_PREFETCH);
for (var i = segUrls.length - 1, j = backupSegUrls.length - 1; i >= 0 && j >= 0; i--, j--) {
if (streamInfo.SegmentMap[segUrls[i]] == null) {
streamInfo.SegmentMap[segUrls[i]] = backupSegUrls[j];
var seqMatch = /#EXT-X-MEDIA-SEQUENCE:([0-9]*)/.exec(backupM3u8);
if (seqMatch != null) {
var oldBackupSeqNumber = streamInfo.BackupSeqNumber;
streamInfo.BackupSeqNumber = Math.max(0, parseInt(seqMatch[1]));
if (streamInfo.RealSeqNumber > 0) {
// We already have a real stream, this must be a midroll. We should therefore increment rather than just using backup directly.
// - If we don't do this then our sequence number will be broken and the stream will get stuck in a loading state.
if (streamInfo.FakeSeqNumber == 0) {
streamInfo.FakeSeqNumber = streamInfo.RealSeqNumber;
}
if (oldBackupSeqNumber == -1) {
// First backup sequence, assume +1
streamInfo.FakeSeqNumber++;
}
}
for (var i = 0; i < lines.length; i++) {
var line = lines[i];
if (line == null) {
continue;
}
if (line.includes('stitched-ad')) {
lines[i] = '';
}
if (line.startsWith('#EXTINF:') && !line.includes(',live')) {
var newSegUrl = streamInfo.SegmentMap[lines[i + 1]];
//lines[i] = line.substring(0, line.indexOf(',')) + ',live';
lines[i + 1] = newSegUrl != null ? newSegUrl : '';
}
/*if (line.startsWith('#EXT-X-TWITCH-PREFETCH')) {
// TODO
lines[i] = '';
}*/
else {
streamInfo.FakeSeqNumber += Math.max(0, streamInfo.BackupSeqNumber - oldBackupSeqNumber);
}
} else {
var segmentMap = [];
if (backupM3u8 != null) {
streamInfo.FakeSeqNumber = streamInfo.BackupSeqNumber;
}
}
var backupLines = backupM3u8.replace('\r', '').split('\n');
var segTimes = getSegmentTimes(lines);
var backupSegTimes = getSegmentTimes(backupLines);
for (const [segTime, segUrl] of Object.entries(segTimes)) {
//segmentMap[segUrl] = Object.values(backupSegTimes)[Object.keys(backupSegTimes).length-1];
var closestTime = Number.MAX_VALUE;
var matchingBackupTime = Number.MAX_VALUE;
for (const [backupSegTime, backupSegUrl] of Object.entries(backupSegTimes)) {
var timeDiff = Math.abs(segTime - backupSegTime);
if (timeDiff < closestTime) {
closestTime = timeDiff;
matchingBackupTime = backupSegTime;
segmentMap[segUrl] = backupSegUrl;
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 (closestTime != Number.MAX_VALUE) {
backupSegTimes.splice(backupSegTimes.indexOf(matchingBackupTime), 1);
if (segData != null) {
if (segData.isPrefetch) {
pushedPrefetchSegs++;
newLines.push(segData.urlLine);
} else {
//newLines.push(segData.dateTimeLine);
newLines.push(segData.extInfLine);
newLines.push(segData.urlLine);
}
}
}
for (var i = 0; i < lines.length; i++) {
var line = lines[i];
if (line.includes('stitched-ad')) {
lines[i] = '';
}
if (line.startsWith('#EXTINF:') && !line.includes(',live')) {
lines[i] = line.substring(0, line.indexOf(',')) + ',live';
var backupSegment = segmentMap[lines[i + 1]];
lines[i + 1] = backupSegment != null ? backupSegment : ''
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 = lines.join('\n');
textStr = newLines.length > 0 ? newLines.join('\n') : lines.join('\n');
//console.log(textStr);
}
return textStr;
@ -356,6 +444,22 @@
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) {
@ -429,9 +533,13 @@
streamInfo.RootM3U8Params = (new URL(url)).search;
streamInfo.BackupUrl = null;
streamInfo.BackupFailed = false;
streamInfo.SegmentMap = [];
streamInfo.SegmentCache = [];
streamInfo.IsMidroll = false;
streamInfo.NotifyFirstTime = 0;
streamInfo.NotifyObservedNoAds = false;
streamInfo.RealSeqNumber = -1;
streamInfo.BackupSeqNumber = -1;
streamInfo.FakeSeqNumber = 0;
var lines = encodingsM3u8.replace('\r', '').split('\n');
for (var i = 0; i < lines.length; i++) {
if (!lines[i].startsWith('#') && lines[i].includes('.m3u8')) {
@ -619,12 +727,7 @@
if (typeof url === 'string') {
if (url.includes('/access_token') || url.includes('gql')) {
if (OPT_ACCESS_TOKEN_PLAYER_TYPE) {
if (url.includes('/access_token')) {
var modifiedUrl = new URL(url);
modifiedUrl.searchParams.set('player_type', OPT_ACCESS_TOKEN_PLAYER_TYPE);
arguments[0] = modifiedUrl.href;
}
else if (url.includes('gql') && init && typeof init.body === 'string' && init.body.includes('PlaybackAccessToken')) {
if (url.includes('gql') && init && typeof init.body === 'string' && init.body.includes('PlaybackAccessToken')) {
const newBody = JSON.parse(init.body);
newBody.variables.playerType = OPT_ACCESS_TOKEN_PLAYER_TYPE;
init.body = JSON.stringify(newBody);
@ -643,63 +746,13 @@
value: gql_device_id
});
}
if (OPT_MODE_NOTIFY_ADS_WATCHED) {
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) {
if (OPT_MODE_NOTIFY_ADS_WATCHED_ASYNC_TOK) {
var channelName = JSON.parse(init.body).variables.login;
// See if the first response has an ad, if it does then send requests until there is no ad
{
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, -1, responseData.data.streamPlaybackAccessToken.signature, responseData.data.streamPlaybackAccessToken.value) == 1) {
console.log('No ad in main request');
resolve(new Response(responseStr));
return;
} else {
console.log('Ad in main request');
}
}
}
if (!channelName) {
resolve(response);
return;
}
// Ads are being served, try skipping a bunch of ads
var resolved = false;
var skipAdTries = 0;
for (var i = 0; i < OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS; i++) {
new Promise(async (skipAdResolve, skipAdReject) => {
var noAds = false;
var accessTokenResponse = await getAccessToken(channelName, OPT_REGULAR_PLAYER_TYPE, realFetch);
if (accessTokenResponse.status == 200) {
var responseStr = await accessTokenResponse.text();
var responseData = JSON.parse(responseStr);
var hasAd = false;
if (responseData && responseData.data && responseData.data.streamPlaybackAccessToken && responseData.data.streamPlaybackAccessToken.value && responseData.data.streamPlaybackAccessToken.signature) {
hasAd = (await tryNotifyAdsWatchedSigTok(realFetch, -1, responseData.data.streamPlaybackAccessToken.signature, responseData.data.streamPlaybackAccessToken.value)) == 0;
}
var attempt = ++skipAdTries;
console.log('Attempt ' + attempt + ' ' + (hasAd ? 'has ad' : 'no ad'));
if (attempt === OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS && !resolved) {
resolved = true;
resolve(new Response(responseStr));
return;
}
} else if (!resolved) {
resolved = true;
resolve(response);
return;
}
}).catch(console.log);
}
} else {
for (var i = 0; i < OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS; i++) {
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);
@ -715,7 +768,6 @@
}
}
resolve(response);
}
} else {
resolve(response);
}

View File

@ -8,14 +8,11 @@ twitch-videoad.js application/javascript
scope.OPT_MODE_LOW_RES = false;
scope.OPT_MODE_EMBED = false;
scope.OPT_MODE_STRIP_AD_SEGMENTS = true;
scope.OPT_MODE_STRIP_AD_SEGMENTS_NEWEST = false;// Matches segments ordered by newest
scope.OPT_MODE_STRIP_AD_SEGMENTS_NEWEST_WITH_PREFETCH = false;// This will result in a very close match, but often a repeat of a second or so. May be preferred if you dislike the regular 2-3 jump.
scope.OPT_MODE_NOTIFY_ADS_WATCHED = false;
scope.OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS = 1;
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_ASYNC_TOK = false;
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;
@ -83,8 +80,8 @@ twitch-videoad.js application/javascript
}
var newBlobStr = `
${processM3U8.toString()}
${getSegmentTimes.toString()}
${getSegmentUrls.toString()}
${getSegmentInfos.toString()}
${getSegmentInfosLines.toString()}
${hookWorkerFetch.toString()}
${declareOptions.toString()}
${getAccessToken.toString()}
@ -149,33 +146,135 @@ twitch-videoad.js application/javascript
req.send();
return req.responseText.split("'")[1];
}
function getSegmentTimes(lines) {
var result = [];
var lastDate = 0;
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-PROGRAM-DATE-TIME:')) {
lastDate = Date.parse(line.substring(line.indexOf(':') + 1));
} else if (line.startsWith('http')) {
result[lastDate] = line;
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].split(':')[1]);
}
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 getSegmentUrls(lines, includePrefetch) {
var result = [];
for (var i = 0; i < lines.length; i++) {
var line = lines[i];
if (line.startsWith('http')) {
result.push(line);
} else if (includePrefetch && line.startsWith('#EXT-X-TWITCH-PREFETCH:')) {
result.push(line.substring(line.indexOf(':') + 1));
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;
}
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) {
si.BackupSeqNumber = -1;
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 previously modified the sequence number, we need to keep doing so (alternatively pause/playing might work better)
si.FakeSeqNumber += Math.max(0, si.RealSeqNumber - oldRealSeq);
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');
}
}
if (haveAdTags) {
var si = StreamInfosByUrl[url];
if (OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST && si != null && !si.NotifyObservedNoAds) {
@ -202,7 +301,7 @@ twitch-videoad.js application/javascript
console.log('Reload player');
postMessage({key:'UboHideAdBanner'});
postMessage({key:'UboReloadPlayer'});
return "";
return '';
}
}
postMessage({
@ -214,22 +313,13 @@ twitch-videoad.js application/javascript
if (!OPT_MODE_STRIP_AD_SEGMENTS) {
return textStr;
}
// NOTE: midroll ads are intertwined with live segments, always display the banner on midroll ads
if (haveAdTags && (!textStr.includes(LIVE_SIGNIFIER) || textStr.includes('MIDROLL'))) {
postMessage({key:'UboShowAdBanner',isMidroll:textStr.includes('MIDROLL')});
} else if ((LastAdUrl && LastAdUrl == url) || LastAdTime < Date.now() - 10000) {
postMessage({key:'UboHideAdBanner'});
LastAdTime = 0;
}
if (haveAdTags) {
LastAdUrl = url;
LastAdTime = Date.now();
/*if (OPT_MODE_NOTIFY_ADS_WATCHED) {
console.log('Stripping ads (instead of skipping ads)');
}*/
var streamInfo = StreamInfosByUrl[url];
if (streamInfo == null) {
console.log('Unknown stream url! ' + url);
console.log('Unknown stream url ' + url);
postMessage({key:'UboHideAdBanner'});
return textStr;
}
if (!streamInfo.BackupFailed && streamInfo.BackupUrl == null) {
@ -272,71 +362,69 @@ twitch-videoad.js application/javascript
}
}
var lines = textStr.replace('\r', '').split('\n');
if (OPT_MODE_STRIP_AD_SEGMENTS_NEWEST) {
var newLines = [];
if (backupM3u8 != null) {
var backupLines = backupM3u8.replace('\r', '').split('\n');
var segUrls = getSegmentUrls(lines, false);
var backupSegUrls = getSegmentUrls(backupLines, OPT_MODE_STRIP_AD_SEGMENTS_NEWEST_WITH_PREFETCH);
for (var i = segUrls.length - 1, j = backupSegUrls.length - 1; i >= 0 && j >= 0; i--, j--) {
if (streamInfo.SegmentMap[segUrls[i]] == null) {
streamInfo.SegmentMap[segUrls[i]] = backupSegUrls[j];
var seqMatch = /#EXT-X-MEDIA-SEQUENCE:([0-9]*)/.exec(backupM3u8);
if (seqMatch != null) {
var oldBackupSeqNumber = streamInfo.BackupSeqNumber;
streamInfo.BackupSeqNumber = Math.max(0, parseInt(seqMatch[1]));
if (streamInfo.RealSeqNumber > 0) {
// We already have a real stream, this must be a midroll. We should therefore increment rather than just using backup directly.
// - If we don't do this then our sequence number will be broken and the stream will get stuck in a loading state.
if (streamInfo.FakeSeqNumber == 0) {
streamInfo.FakeSeqNumber = streamInfo.RealSeqNumber;
}
if (oldBackupSeqNumber == -1) {
// First backup sequence, assume +1
streamInfo.FakeSeqNumber++;
}
}
for (var i = 0; i < lines.length; i++) {
var line = lines[i];
if (line == null) {
continue;
}
if (line.includes('stitched-ad')) {
lines[i] = '';
}
if (line.startsWith('#EXTINF:') && !line.includes(',live')) {
var newSegUrl = streamInfo.SegmentMap[lines[i + 1]];
//lines[i] = line.substring(0, line.indexOf(',')) + ',live';
lines[i + 1] = newSegUrl != null ? newSegUrl : '';
}
/*if (line.startsWith('#EXT-X-TWITCH-PREFETCH')) {
// TODO
lines[i] = '';
}*/
else {
streamInfo.FakeSeqNumber += Math.max(0, streamInfo.BackupSeqNumber - oldBackupSeqNumber);
}
} else {
var segmentMap = [];
if (backupM3u8 != null) {
streamInfo.FakeSeqNumber = streamInfo.BackupSeqNumber;
}
}
var backupLines = backupM3u8.replace('\r', '').split('\n');
var segTimes = getSegmentTimes(lines);
var backupSegTimes = getSegmentTimes(backupLines);
for (const [segTime, segUrl] of Object.entries(segTimes)) {
//segmentMap[segUrl] = Object.values(backupSegTimes)[Object.keys(backupSegTimes).length-1];
var closestTime = Number.MAX_VALUE;
var matchingBackupTime = Number.MAX_VALUE;
for (const [backupSegTime, backupSegUrl] of Object.entries(backupSegTimes)) {
var timeDiff = Math.abs(segTime - backupSegTime);
if (timeDiff < closestTime) {
closestTime = timeDiff;
matchingBackupTime = backupSegTime;
segmentMap[segUrl] = backupSegUrl;
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 (closestTime != Number.MAX_VALUE) {
backupSegTimes.splice(backupSegTimes.indexOf(matchingBackupTime), 1);
if (segData != null) {
if (segData.isPrefetch) {
pushedPrefetchSegs++;
newLines.push(segData.urlLine);
} else {
//newLines.push(segData.dateTimeLine);
newLines.push(segData.extInfLine);
newLines.push(segData.urlLine);
}
}
}
for (var i = 0; i < lines.length; i++) {
var line = lines[i];
if (line.includes('stitched-ad')) {
lines[i] = '';
}
if (line.startsWith('#EXTINF:') && !line.includes(',live')) {
lines[i] = line.substring(0, line.indexOf(',')) + ',live';
var backupSegment = segmentMap[lines[i + 1]];
lines[i + 1] = backupSegment != null ? backupSegment : ''
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 = lines.join('\n');
textStr = newLines.length > 0 ? newLines.join('\n') : lines.join('\n');
//console.log(textStr);
}
return textStr;
@ -345,6 +433,22 @@ twitch-videoad.js application/javascript
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) {
@ -418,9 +522,13 @@ twitch-videoad.js application/javascript
streamInfo.RootM3U8Params = (new URL(url)).search;
streamInfo.BackupUrl = null;
streamInfo.BackupFailed = false;
streamInfo.SegmentMap = [];
streamInfo.SegmentCache = [];
streamInfo.IsMidroll = false;
streamInfo.NotifyFirstTime = 0;
streamInfo.NotifyObservedNoAds = false;
streamInfo.RealSeqNumber = -1;
streamInfo.BackupSeqNumber = -1;
streamInfo.FakeSeqNumber = 0;
var lines = encodingsM3u8.replace('\r', '').split('\n');
for (var i = 0; i < lines.length; i++) {
if (!lines[i].startsWith('#') && lines[i].includes('.m3u8')) {
@ -608,12 +716,7 @@ twitch-videoad.js application/javascript
if (typeof url === 'string') {
if (url.includes('/access_token') || url.includes('gql')) {
if (OPT_ACCESS_TOKEN_PLAYER_TYPE) {
if (url.includes('/access_token')) {
var modifiedUrl = new URL(url);
modifiedUrl.searchParams.set('player_type', OPT_ACCESS_TOKEN_PLAYER_TYPE);
arguments[0] = modifiedUrl.href;
}
else if (url.includes('gql') && init && typeof init.body === 'string' && init.body.includes('PlaybackAccessToken')) {
if (url.includes('gql') && init && typeof init.body === 'string' && init.body.includes('PlaybackAccessToken')) {
const newBody = JSON.parse(init.body);
newBody.variables.playerType = OPT_ACCESS_TOKEN_PLAYER_TYPE;
init.body = JSON.stringify(newBody);
@ -632,63 +735,13 @@ twitch-videoad.js application/javascript
value: gql_device_id
});
}
if (OPT_MODE_NOTIFY_ADS_WATCHED) {
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) {
if (OPT_MODE_NOTIFY_ADS_WATCHED_ASYNC_TOK) {
var channelName = JSON.parse(init.body).variables.login;
// See if the first response has an ad, if it does then send requests until there is no ad
{
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, -1, responseData.data.streamPlaybackAccessToken.signature, responseData.data.streamPlaybackAccessToken.value) == 1) {
console.log('No ad in main request');
resolve(new Response(responseStr));
return;
} else {
console.log('Ad in main request');
}
}
}
if (!channelName) {
resolve(response);
return;
}
// Ads are being served, try skipping a bunch of ads
var resolved = false;
var skipAdTries = 0;
for (var i = 0; i < OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS; i++) {
new Promise(async (skipAdResolve, skipAdReject) => {
var noAds = false;
var accessTokenResponse = await getAccessToken(channelName, OPT_REGULAR_PLAYER_TYPE, realFetch);
if (accessTokenResponse.status == 200) {
var responseStr = await accessTokenResponse.text();
var responseData = JSON.parse(responseStr);
var hasAd = false;
if (responseData && responseData.data && responseData.data.streamPlaybackAccessToken && responseData.data.streamPlaybackAccessToken.value && responseData.data.streamPlaybackAccessToken.signature) {
hasAd = (await tryNotifyAdsWatchedSigTok(realFetch, -1, responseData.data.streamPlaybackAccessToken.signature, responseData.data.streamPlaybackAccessToken.value)) == 0;
}
var attempt = ++skipAdTries;
console.log('Attempt ' + attempt + ' ' + (hasAd ? 'has ad' : 'no ad'));
if (attempt === OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS && !resolved) {
resolved = true;
resolve(new Response(responseStr));
return;
}
} else if (!resolved) {
resolved = true;
resolve(response);
return;
}
}).catch(console.log);
}
} else {
for (var i = 0; i < OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS; i++) {
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);
@ -704,7 +757,6 @@ twitch-videoad.js application/javascript
}
}
resolve(response);
}
} else {
resolve(response);
}

View File

@ -1,7 +1,7 @@
// ==UserScript==
// @name TwitchAdSolutions
// @namespace https://github.com/pixeltris/TwitchAdSolutions
// @version 1.2
// @version 1.3
// @updateURL https://github.com/pixeltris/TwitchAdSolutions/raw/master/strip/strip.user.js
// @downloadURL https://github.com/pixeltris/TwitchAdSolutions/raw/master/strip/strip.user.js
// @description Multiple solutions for blocking Twitch ads (strip)
@ -19,14 +19,11 @@
scope.OPT_MODE_LOW_RES = false;
scope.OPT_MODE_EMBED = false;
scope.OPT_MODE_STRIP_AD_SEGMENTS = true;
scope.OPT_MODE_STRIP_AD_SEGMENTS_NEWEST = false;// Matches segments ordered by newest
scope.OPT_MODE_STRIP_AD_SEGMENTS_NEWEST_WITH_PREFETCH = false;// This will result in a very close match, but often a repeat of a second or so. May be preferred if you dislike the regular 2-3 jump.
scope.OPT_MODE_NOTIFY_ADS_WATCHED = false;
scope.OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS = 1;
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_ASYNC_TOK = false;
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;
@ -94,8 +91,8 @@
}
var newBlobStr = `
${processM3U8.toString()}
${getSegmentTimes.toString()}
${getSegmentUrls.toString()}
${getSegmentInfos.toString()}
${getSegmentInfosLines.toString()}
${hookWorkerFetch.toString()}
${declareOptions.toString()}
${getAccessToken.toString()}
@ -160,33 +157,135 @@
req.send();
return req.responseText.split("'")[1];
}
function getSegmentTimes(lines) {
var result = [];
var lastDate = 0;
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-PROGRAM-DATE-TIME:')) {
lastDate = Date.parse(line.substring(line.indexOf(':') + 1));
} else if (line.startsWith('http')) {
result[lastDate] = line;
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].split(':')[1]);
}
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 getSegmentUrls(lines, includePrefetch) {
var result = [];
for (var i = 0; i < lines.length; i++) {
var line = lines[i];
if (line.startsWith('http')) {
result.push(line);
} else if (includePrefetch && line.startsWith('#EXT-X-TWITCH-PREFETCH:')) {
result.push(line.substring(line.indexOf(':') + 1));
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;
}
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) {
si.BackupSeqNumber = -1;
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 previously modified the sequence number, we need to keep doing so (alternatively pause/playing might work better)
si.FakeSeqNumber += Math.max(0, si.RealSeqNumber - oldRealSeq);
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');
}
}
if (haveAdTags) {
var si = StreamInfosByUrl[url];
if (OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST && si != null && !si.NotifyObservedNoAds) {
@ -213,7 +312,7 @@
console.log('Reload player');
postMessage({key:'UboHideAdBanner'});
postMessage({key:'UboReloadPlayer'});
return "";
return '';
}
}
postMessage({
@ -225,22 +324,13 @@
if (!OPT_MODE_STRIP_AD_SEGMENTS) {
return textStr;
}
// NOTE: midroll ads are intertwined with live segments, always display the banner on midroll ads
if (haveAdTags && (!textStr.includes(LIVE_SIGNIFIER) || textStr.includes('MIDROLL'))) {
postMessage({key:'UboShowAdBanner',isMidroll:textStr.includes('MIDROLL')});
} else if ((LastAdUrl && LastAdUrl == url) || LastAdTime < Date.now() - 10000) {
postMessage({key:'UboHideAdBanner'});
LastAdTime = 0;
}
if (haveAdTags) {
LastAdUrl = url;
LastAdTime = Date.now();
/*if (OPT_MODE_NOTIFY_ADS_WATCHED) {
console.log('Stripping ads (instead of skipping ads)');
}*/
var streamInfo = StreamInfosByUrl[url];
if (streamInfo == null) {
console.log('Unknown stream url! ' + url);
console.log('Unknown stream url ' + url);
postMessage({key:'UboHideAdBanner'});
return textStr;
}
if (!streamInfo.BackupFailed && streamInfo.BackupUrl == null) {
@ -283,71 +373,69 @@
}
}
var lines = textStr.replace('\r', '').split('\n');
if (OPT_MODE_STRIP_AD_SEGMENTS_NEWEST) {
var newLines = [];
if (backupM3u8 != null) {
var backupLines = backupM3u8.replace('\r', '').split('\n');
var segUrls = getSegmentUrls(lines, false);
var backupSegUrls = getSegmentUrls(backupLines, OPT_MODE_STRIP_AD_SEGMENTS_NEWEST_WITH_PREFETCH);
for (var i = segUrls.length - 1, j = backupSegUrls.length - 1; i >= 0 && j >= 0; i--, j--) {
if (streamInfo.SegmentMap[segUrls[i]] == null) {
streamInfo.SegmentMap[segUrls[i]] = backupSegUrls[j];
var seqMatch = /#EXT-X-MEDIA-SEQUENCE:([0-9]*)/.exec(backupM3u8);
if (seqMatch != null) {
var oldBackupSeqNumber = streamInfo.BackupSeqNumber;
streamInfo.BackupSeqNumber = Math.max(0, parseInt(seqMatch[1]));
if (streamInfo.RealSeqNumber > 0) {
// We already have a real stream, this must be a midroll. We should therefore increment rather than just using backup directly.
// - If we don't do this then our sequence number will be broken and the stream will get stuck in a loading state.
if (streamInfo.FakeSeqNumber == 0) {
streamInfo.FakeSeqNumber = streamInfo.RealSeqNumber;
}
if (oldBackupSeqNumber == -1) {
// First backup sequence, assume +1
streamInfo.FakeSeqNumber++;
}
}
for (var i = 0; i < lines.length; i++) {
var line = lines[i];
if (line == null) {
continue;
}
if (line.includes('stitched-ad')) {
lines[i] = '';
}
if (line.startsWith('#EXTINF:') && !line.includes(',live')) {
var newSegUrl = streamInfo.SegmentMap[lines[i + 1]];
//lines[i] = line.substring(0, line.indexOf(',')) + ',live';
lines[i + 1] = newSegUrl != null ? newSegUrl : '';
}
/*if (line.startsWith('#EXT-X-TWITCH-PREFETCH')) {
// TODO
lines[i] = '';
}*/
else {
streamInfo.FakeSeqNumber += Math.max(0, streamInfo.BackupSeqNumber - oldBackupSeqNumber);
}
} else {
var segmentMap = [];
if (backupM3u8 != null) {
streamInfo.FakeSeqNumber = streamInfo.BackupSeqNumber;
}
}
var backupLines = backupM3u8.replace('\r', '').split('\n');
var segTimes = getSegmentTimes(lines);
var backupSegTimes = getSegmentTimes(backupLines);
for (const [segTime, segUrl] of Object.entries(segTimes)) {
//segmentMap[segUrl] = Object.values(backupSegTimes)[Object.keys(backupSegTimes).length-1];
var closestTime = Number.MAX_VALUE;
var matchingBackupTime = Number.MAX_VALUE;
for (const [backupSegTime, backupSegUrl] of Object.entries(backupSegTimes)) {
var timeDiff = Math.abs(segTime - backupSegTime);
if (timeDiff < closestTime) {
closestTime = timeDiff;
matchingBackupTime = backupSegTime;
segmentMap[segUrl] = backupSegUrl;
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 (closestTime != Number.MAX_VALUE) {
backupSegTimes.splice(backupSegTimes.indexOf(matchingBackupTime), 1);
if (segData != null) {
if (segData.isPrefetch) {
pushedPrefetchSegs++;
newLines.push(segData.urlLine);
} else {
//newLines.push(segData.dateTimeLine);
newLines.push(segData.extInfLine);
newLines.push(segData.urlLine);
}
}
}
for (var i = 0; i < lines.length; i++) {
var line = lines[i];
if (line.includes('stitched-ad')) {
lines[i] = '';
}
if (line.startsWith('#EXTINF:') && !line.includes(',live')) {
lines[i] = line.substring(0, line.indexOf(',')) + ',live';
var backupSegment = segmentMap[lines[i + 1]];
lines[i + 1] = backupSegment != null ? backupSegment : ''
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 = lines.join('\n');
textStr = newLines.length > 0 ? newLines.join('\n') : lines.join('\n');
//console.log(textStr);
}
return textStr;
@ -356,6 +444,22 @@
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) {
@ -429,9 +533,13 @@
streamInfo.RootM3U8Params = (new URL(url)).search;
streamInfo.BackupUrl = null;
streamInfo.BackupFailed = false;
streamInfo.SegmentMap = [];
streamInfo.SegmentCache = [];
streamInfo.IsMidroll = false;
streamInfo.NotifyFirstTime = 0;
streamInfo.NotifyObservedNoAds = false;
streamInfo.RealSeqNumber = -1;
streamInfo.BackupSeqNumber = -1;
streamInfo.FakeSeqNumber = 0;
var lines = encodingsM3u8.replace('\r', '').split('\n');
for (var i = 0; i < lines.length; i++) {
if (!lines[i].startsWith('#') && lines[i].includes('.m3u8')) {
@ -619,12 +727,7 @@
if (typeof url === 'string') {
if (url.includes('/access_token') || url.includes('gql')) {
if (OPT_ACCESS_TOKEN_PLAYER_TYPE) {
if (url.includes('/access_token')) {
var modifiedUrl = new URL(url);
modifiedUrl.searchParams.set('player_type', OPT_ACCESS_TOKEN_PLAYER_TYPE);
arguments[0] = modifiedUrl.href;
}
else if (url.includes('gql') && init && typeof init.body === 'string' && init.body.includes('PlaybackAccessToken')) {
if (url.includes('gql') && init && typeof init.body === 'string' && init.body.includes('PlaybackAccessToken')) {
const newBody = JSON.parse(init.body);
newBody.variables.playerType = OPT_ACCESS_TOKEN_PLAYER_TYPE;
init.body = JSON.stringify(newBody);
@ -643,63 +746,13 @@
value: gql_device_id
});
}
if (OPT_MODE_NOTIFY_ADS_WATCHED) {
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) {
if (OPT_MODE_NOTIFY_ADS_WATCHED_ASYNC_TOK) {
var channelName = JSON.parse(init.body).variables.login;
// See if the first response has an ad, if it does then send requests until there is no ad
{
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, -1, responseData.data.streamPlaybackAccessToken.signature, responseData.data.streamPlaybackAccessToken.value) == 1) {
console.log('No ad in main request');
resolve(new Response(responseStr));
return;
} else {
console.log('Ad in main request');
}
}
}
if (!channelName) {
resolve(response);
return;
}
// Ads are being served, try skipping a bunch of ads
var resolved = false;
var skipAdTries = 0;
for (var i = 0; i < OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS; i++) {
new Promise(async (skipAdResolve, skipAdReject) => {
var noAds = false;
var accessTokenResponse = await getAccessToken(channelName, OPT_REGULAR_PLAYER_TYPE, realFetch);
if (accessTokenResponse.status == 200) {
var responseStr = await accessTokenResponse.text();
var responseData = JSON.parse(responseStr);
var hasAd = false;
if (responseData && responseData.data && responseData.data.streamPlaybackAccessToken && responseData.data.streamPlaybackAccessToken.value && responseData.data.streamPlaybackAccessToken.signature) {
hasAd = (await tryNotifyAdsWatchedSigTok(realFetch, -1, responseData.data.streamPlaybackAccessToken.signature, responseData.data.streamPlaybackAccessToken.value)) == 0;
}
var attempt = ++skipAdTries;
console.log('Attempt ' + attempt + ' ' + (hasAd ? 'has ad' : 'no ad'));
if (attempt === OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS && !resolved) {
resolved = true;
resolve(new Response(responseStr));
return;
}
} else if (!resolved) {
resolved = true;
resolve(response);
return;
}
}).catch(console.log);
}
} else {
for (var i = 0; i < OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS; i++) {
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);
@ -715,7 +768,6 @@
}
}
resolve(response);
}
} else {
resolve(response);
}

View File

@ -67,14 +67,14 @@ namespace TwitchAdUtils
Console.Write("Enter channel name: ");
string channel = Console.ReadLine().ToLower();
Console.WriteLine("Fetching channel '" + channel + "'");
//RunImpl(RunnerMode.Normal, channel);
RunImpl(RunnerMode.Embed, channel);
RunImpl(RunnerMode.Normal, channel);
//RunImpl(RunnerMode.Embed, channel);
//RunImpl(RunnerMode.MiniNoAd, channel);
}
static void BuildScripts()
{
string[] deprecated = { "dyn-skip-midroll-alt", "dyn-skip-midroll", "dyn-video-swap", "dyn" };
string[] deprecated = { "dyn-skip-midroll-alt", "dyn-skip-midroll", "dyn-video-swap", "dyn", "dyn-skip" };
string baseScriptName = "base";
string suffixConfg = ".cfg";
string suffixUserscript = ".user.js";

View File

@ -7,15 +7,12 @@ twitch-videoad.js application/javascript
scope.OPT_MODE_VIDEO_SWAP = true;
scope.OPT_MODE_LOW_RES = false;
scope.OPT_MODE_EMBED = false;
scope.OPT_MODE_STRIP_AD_SEGMENTS = false;// The default implementation attempts to match segment times (TODO: needs improvements - looping issues - cache matched segments and don't repeat any)
scope.OPT_MODE_STRIP_AD_SEGMENTS_NEWEST = false;// Matches segments ordered by newest
scope.OPT_MODE_STRIP_AD_SEGMENTS_NEWEST_WITH_PREFETCH = false;// This will result in a very close match, but often a repeat of a second or so. May be preferred if you dislike the regular 2-3 jump.
scope.OPT_MODE_STRIP_AD_SEGMENTS = false;
scope.OPT_MODE_NOTIFY_ADS_WATCHED = false;
scope.OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS = 1;
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_ASYNC_TOK = false;
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;
@ -83,8 +80,8 @@ twitch-videoad.js application/javascript
}
var newBlobStr = `
${processM3U8.toString()}
${getSegmentTimes.toString()}
${getSegmentUrls.toString()}
${getSegmentInfos.toString()}
${getSegmentInfosLines.toString()}
${hookWorkerFetch.toString()}
${declareOptions.toString()}
${getAccessToken.toString()}
@ -149,33 +146,135 @@ twitch-videoad.js application/javascript
req.send();
return req.responseText.split("'")[1];
}
function getSegmentTimes(lines) {
var result = [];
var lastDate = 0;
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-PROGRAM-DATE-TIME:')) {
lastDate = Date.parse(line.substring(line.indexOf(':') + 1));
} else if (line.startsWith('http')) {
result[lastDate] = line;
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].split(':')[1]);
}
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 getSegmentUrls(lines, includePrefetch) {
var result = [];
for (var i = 0; i < lines.length; i++) {
var line = lines[i];
if (line.startsWith('http')) {
result.push(line);
} else if (includePrefetch && line.startsWith('#EXT-X-TWITCH-PREFETCH:')) {
result.push(line.substring(line.indexOf(':') + 1));
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;
}
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) {
si.BackupSeqNumber = -1;
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 previously modified the sequence number, we need to keep doing so (alternatively pause/playing might work better)
si.FakeSeqNumber += Math.max(0, si.RealSeqNumber - oldRealSeq);
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');
}
}
if (haveAdTags) {
var si = StreamInfosByUrl[url];
if (OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST && si != null && !si.NotifyObservedNoAds) {
@ -202,7 +301,7 @@ twitch-videoad.js application/javascript
console.log('Reload player');
postMessage({key:'UboHideAdBanner'});
postMessage({key:'UboReloadPlayer'});
return "";
return '';
}
}
postMessage({
@ -214,22 +313,13 @@ twitch-videoad.js application/javascript
if (!OPT_MODE_STRIP_AD_SEGMENTS) {
return textStr;
}
// NOTE: midroll ads are intertwined with live segments, always display the banner on midroll ads
if (haveAdTags && (!textStr.includes(LIVE_SIGNIFIER) || textStr.includes('MIDROLL'))) {
postMessage({key:'UboShowAdBanner',isMidroll:textStr.includes('MIDROLL')});
} else if ((LastAdUrl && LastAdUrl == url) || LastAdTime < Date.now() - 10000) {
postMessage({key:'UboHideAdBanner'});
LastAdTime = 0;
}
if (haveAdTags) {
LastAdUrl = url;
LastAdTime = Date.now();
/*if (OPT_MODE_NOTIFY_ADS_WATCHED) {
console.log('Stripping ads (instead of skipping ads)');
}*/
var streamInfo = StreamInfosByUrl[url];
if (streamInfo == null) {
console.log('Unknown stream url! ' + url);
console.log('Unknown stream url ' + url);
postMessage({key:'UboHideAdBanner'});
return textStr;
}
if (!streamInfo.BackupFailed && streamInfo.BackupUrl == null) {
@ -272,71 +362,69 @@ twitch-videoad.js application/javascript
}
}
var lines = textStr.replace('\r', '').split('\n');
if (OPT_MODE_STRIP_AD_SEGMENTS_NEWEST) {
var newLines = [];
if (backupM3u8 != null) {
var backupLines = backupM3u8.replace('\r', '').split('\n');
var segUrls = getSegmentUrls(lines, false);
var backupSegUrls = getSegmentUrls(backupLines, OPT_MODE_STRIP_AD_SEGMENTS_NEWEST_WITH_PREFETCH);
for (var i = segUrls.length - 1, j = backupSegUrls.length - 1; i >= 0 && j >= 0; i--, j--) {
if (streamInfo.SegmentMap[segUrls[i]] == null) {
streamInfo.SegmentMap[segUrls[i]] = backupSegUrls[j];
var seqMatch = /#EXT-X-MEDIA-SEQUENCE:([0-9]*)/.exec(backupM3u8);
if (seqMatch != null) {
var oldBackupSeqNumber = streamInfo.BackupSeqNumber;
streamInfo.BackupSeqNumber = Math.max(0, parseInt(seqMatch[1]));
if (streamInfo.RealSeqNumber > 0) {
// We already have a real stream, this must be a midroll. We should therefore increment rather than just using backup directly.
// - If we don't do this then our sequence number will be broken and the stream will get stuck in a loading state.
if (streamInfo.FakeSeqNumber == 0) {
streamInfo.FakeSeqNumber = streamInfo.RealSeqNumber;
}
if (oldBackupSeqNumber == -1) {
// First backup sequence, assume +1
streamInfo.FakeSeqNumber++;
}
}
for (var i = 0; i < lines.length; i++) {
var line = lines[i];
if (line == null) {
continue;
}
if (line.includes('stitched-ad')) {
lines[i] = '';
}
if (line.startsWith('#EXTINF:') && !line.includes(',live')) {
var newSegUrl = streamInfo.SegmentMap[lines[i + 1]];
//lines[i] = line.substring(0, line.indexOf(',')) + ',live';
lines[i + 1] = newSegUrl != null ? newSegUrl : '';
}
/*if (line.startsWith('#EXT-X-TWITCH-PREFETCH')) {
// TODO
lines[i] = '';
}*/
else {
streamInfo.FakeSeqNumber += Math.max(0, streamInfo.BackupSeqNumber - oldBackupSeqNumber);
}
} else {
var segmentMap = [];
if (backupM3u8 != null) {
streamInfo.FakeSeqNumber = streamInfo.BackupSeqNumber;
}
}
var backupLines = backupM3u8.replace('\r', '').split('\n');
var segTimes = getSegmentTimes(lines);
var backupSegTimes = getSegmentTimes(backupLines);
for (const [segTime, segUrl] of Object.entries(segTimes)) {
//segmentMap[segUrl] = Object.values(backupSegTimes)[Object.keys(backupSegTimes).length-1];
var closestTime = Number.MAX_VALUE;
var matchingBackupTime = Number.MAX_VALUE;
for (const [backupSegTime, backupSegUrl] of Object.entries(backupSegTimes)) {
var timeDiff = Math.abs(segTime - backupSegTime);
if (timeDiff < closestTime) {
closestTime = timeDiff;
matchingBackupTime = backupSegTime;
segmentMap[segUrl] = backupSegUrl;
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 (closestTime != Number.MAX_VALUE) {
backupSegTimes.splice(backupSegTimes.indexOf(matchingBackupTime), 1);
if (segData != null) {
if (segData.isPrefetch) {
pushedPrefetchSegs++;
newLines.push(segData.urlLine);
} else {
//newLines.push(segData.dateTimeLine);
newLines.push(segData.extInfLine);
newLines.push(segData.urlLine);
}
}
}
for (var i = 0; i < lines.length; i++) {
var line = lines[i];
if (line.includes('stitched-ad')) {
lines[i] = '';
}
if (line.startsWith('#EXTINF:') && !line.includes(',live')) {
lines[i] = line.substring(0, line.indexOf(',')) + ',live';
var backupSegment = segmentMap[lines[i + 1]];
lines[i + 1] = backupSegment != null ? backupSegment : ''
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 = lines.join('\n');
textStr = newLines.length > 0 ? newLines.join('\n') : lines.join('\n');
//console.log(textStr);
}
return textStr;
@ -345,6 +433,22 @@ twitch-videoad.js application/javascript
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) {
@ -418,9 +522,13 @@ twitch-videoad.js application/javascript
streamInfo.RootM3U8Params = (new URL(url)).search;
streamInfo.BackupUrl = null;
streamInfo.BackupFailed = false;
streamInfo.SegmentMap = [];
streamInfo.SegmentCache = [];
streamInfo.IsMidroll = false;
streamInfo.NotifyFirstTime = 0;
streamInfo.NotifyObservedNoAds = false;
streamInfo.RealSeqNumber = -1;
streamInfo.BackupSeqNumber = -1;
streamInfo.FakeSeqNumber = 0;
var lines = encodingsM3u8.replace('\r', '').split('\n');
for (var i = 0; i < lines.length; i++) {
if (!lines[i].startsWith('#') && lines[i].includes('.m3u8')) {
@ -608,12 +716,7 @@ twitch-videoad.js application/javascript
if (typeof url === 'string') {
if (url.includes('/access_token') || url.includes('gql')) {
if (OPT_ACCESS_TOKEN_PLAYER_TYPE) {
if (url.includes('/access_token')) {
var modifiedUrl = new URL(url);
modifiedUrl.searchParams.set('player_type', OPT_ACCESS_TOKEN_PLAYER_TYPE);
arguments[0] = modifiedUrl.href;
}
else if (url.includes('gql') && init && typeof init.body === 'string' && init.body.includes('PlaybackAccessToken')) {
if (url.includes('gql') && init && typeof init.body === 'string' && init.body.includes('PlaybackAccessToken')) {
const newBody = JSON.parse(init.body);
newBody.variables.playerType = OPT_ACCESS_TOKEN_PLAYER_TYPE;
init.body = JSON.stringify(newBody);
@ -632,63 +735,13 @@ twitch-videoad.js application/javascript
value: gql_device_id
});
}
if (OPT_MODE_NOTIFY_ADS_WATCHED) {
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) {
if (OPT_MODE_NOTIFY_ADS_WATCHED_ASYNC_TOK) {
var channelName = JSON.parse(init.body).variables.login;
// See if the first response has an ad, if it does then send requests until there is no ad
{
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, -1, responseData.data.streamPlaybackAccessToken.signature, responseData.data.streamPlaybackAccessToken.value) == 1) {
console.log('No ad in main request');
resolve(new Response(responseStr));
return;
} else {
console.log('Ad in main request');
}
}
}
if (!channelName) {
resolve(response);
return;
}
// Ads are being served, try skipping a bunch of ads
var resolved = false;
var skipAdTries = 0;
for (var i = 0; i < OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS; i++) {
new Promise(async (skipAdResolve, skipAdReject) => {
var noAds = false;
var accessTokenResponse = await getAccessToken(channelName, OPT_REGULAR_PLAYER_TYPE, realFetch);
if (accessTokenResponse.status == 200) {
var responseStr = await accessTokenResponse.text();
var responseData = JSON.parse(responseStr);
var hasAd = false;
if (responseData && responseData.data && responseData.data.streamPlaybackAccessToken && responseData.data.streamPlaybackAccessToken.value && responseData.data.streamPlaybackAccessToken.signature) {
hasAd = (await tryNotifyAdsWatchedSigTok(realFetch, -1, responseData.data.streamPlaybackAccessToken.signature, responseData.data.streamPlaybackAccessToken.value)) == 0;
}
var attempt = ++skipAdTries;
console.log('Attempt ' + attempt + ' ' + (hasAd ? 'has ad' : 'no ad'));
if (attempt === OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS && !resolved) {
resolved = true;
resolve(new Response(responseStr));
return;
}
} else if (!resolved) {
resolved = true;
resolve(response);
return;
}
}).catch(console.log);
}
} else {
for (var i = 0; i < OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS; i++) {
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);
@ -704,7 +757,6 @@ twitch-videoad.js application/javascript
}
}
resolve(response);
}
} else {
resolve(response);
}

View File

@ -1,7 +1,7 @@
// ==UserScript==
// @name TwitchAdSolutions
// @namespace https://github.com/pixeltris/TwitchAdSolutions
// @version 1.2
// @version 1.3
// @updateURL https://github.com/pixeltris/TwitchAdSolutions/raw/master/video-swap/video-swap.user.js
// @downloadURL https://github.com/pixeltris/TwitchAdSolutions/raw/master/video-swap/video-swap.user.js
// @description Multiple solutions for blocking Twitch ads (video-swap)
@ -18,15 +18,12 @@
scope.OPT_MODE_VIDEO_SWAP = true;
scope.OPT_MODE_LOW_RES = false;
scope.OPT_MODE_EMBED = false;
scope.OPT_MODE_STRIP_AD_SEGMENTS = false;// The default implementation attempts to match segment times (TODO: needs improvements - looping issues - cache matched segments and don't repeat any)
scope.OPT_MODE_STRIP_AD_SEGMENTS_NEWEST = false;// Matches segments ordered by newest
scope.OPT_MODE_STRIP_AD_SEGMENTS_NEWEST_WITH_PREFETCH = false;// This will result in a very close match, but often a repeat of a second or so. May be preferred if you dislike the regular 2-3 jump.
scope.OPT_MODE_STRIP_AD_SEGMENTS = false;
scope.OPT_MODE_NOTIFY_ADS_WATCHED = false;
scope.OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS = 1;
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_ASYNC_TOK = false;
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;
@ -94,8 +91,8 @@
}
var newBlobStr = `
${processM3U8.toString()}
${getSegmentTimes.toString()}
${getSegmentUrls.toString()}
${getSegmentInfos.toString()}
${getSegmentInfosLines.toString()}
${hookWorkerFetch.toString()}
${declareOptions.toString()}
${getAccessToken.toString()}
@ -160,33 +157,135 @@
req.send();
return req.responseText.split("'")[1];
}
function getSegmentTimes(lines) {
var result = [];
var lastDate = 0;
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-PROGRAM-DATE-TIME:')) {
lastDate = Date.parse(line.substring(line.indexOf(':') + 1));
} else if (line.startsWith('http')) {
result[lastDate] = line;
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].split(':')[1]);
}
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 getSegmentUrls(lines, includePrefetch) {
var result = [];
for (var i = 0; i < lines.length; i++) {
var line = lines[i];
if (line.startsWith('http')) {
result.push(line);
} else if (includePrefetch && line.startsWith('#EXT-X-TWITCH-PREFETCH:')) {
result.push(line.substring(line.indexOf(':') + 1));
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;
}
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) {
si.BackupSeqNumber = -1;
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 previously modified the sequence number, we need to keep doing so (alternatively pause/playing might work better)
si.FakeSeqNumber += Math.max(0, si.RealSeqNumber - oldRealSeq);
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');
}
}
if (haveAdTags) {
var si = StreamInfosByUrl[url];
if (OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST && si != null && !si.NotifyObservedNoAds) {
@ -213,7 +312,7 @@
console.log('Reload player');
postMessage({key:'UboHideAdBanner'});
postMessage({key:'UboReloadPlayer'});
return "";
return '';
}
}
postMessage({
@ -225,22 +324,13 @@
if (!OPT_MODE_STRIP_AD_SEGMENTS) {
return textStr;
}
// NOTE: midroll ads are intertwined with live segments, always display the banner on midroll ads
if (haveAdTags && (!textStr.includes(LIVE_SIGNIFIER) || textStr.includes('MIDROLL'))) {
postMessage({key:'UboShowAdBanner',isMidroll:textStr.includes('MIDROLL')});
} else if ((LastAdUrl && LastAdUrl == url) || LastAdTime < Date.now() - 10000) {
postMessage({key:'UboHideAdBanner'});
LastAdTime = 0;
}
if (haveAdTags) {
LastAdUrl = url;
LastAdTime = Date.now();
/*if (OPT_MODE_NOTIFY_ADS_WATCHED) {
console.log('Stripping ads (instead of skipping ads)');
}*/
var streamInfo = StreamInfosByUrl[url];
if (streamInfo == null) {
console.log('Unknown stream url! ' + url);
console.log('Unknown stream url ' + url);
postMessage({key:'UboHideAdBanner'});
return textStr;
}
if (!streamInfo.BackupFailed && streamInfo.BackupUrl == null) {
@ -283,71 +373,69 @@
}
}
var lines = textStr.replace('\r', '').split('\n');
if (OPT_MODE_STRIP_AD_SEGMENTS_NEWEST) {
var newLines = [];
if (backupM3u8 != null) {
var backupLines = backupM3u8.replace('\r', '').split('\n');
var segUrls = getSegmentUrls(lines, false);
var backupSegUrls = getSegmentUrls(backupLines, OPT_MODE_STRIP_AD_SEGMENTS_NEWEST_WITH_PREFETCH);
for (var i = segUrls.length - 1, j = backupSegUrls.length - 1; i >= 0 && j >= 0; i--, j--) {
if (streamInfo.SegmentMap[segUrls[i]] == null) {
streamInfo.SegmentMap[segUrls[i]] = backupSegUrls[j];
var seqMatch = /#EXT-X-MEDIA-SEQUENCE:([0-9]*)/.exec(backupM3u8);
if (seqMatch != null) {
var oldBackupSeqNumber = streamInfo.BackupSeqNumber;
streamInfo.BackupSeqNumber = Math.max(0, parseInt(seqMatch[1]));
if (streamInfo.RealSeqNumber > 0) {
// We already have a real stream, this must be a midroll. We should therefore increment rather than just using backup directly.
// - If we don't do this then our sequence number will be broken and the stream will get stuck in a loading state.
if (streamInfo.FakeSeqNumber == 0) {
streamInfo.FakeSeqNumber = streamInfo.RealSeqNumber;
}
if (oldBackupSeqNumber == -1) {
// First backup sequence, assume +1
streamInfo.FakeSeqNumber++;
}
}
for (var i = 0; i < lines.length; i++) {
var line = lines[i];
if (line == null) {
continue;
}
if (line.includes('stitched-ad')) {
lines[i] = '';
}
if (line.startsWith('#EXTINF:') && !line.includes(',live')) {
var newSegUrl = streamInfo.SegmentMap[lines[i + 1]];
//lines[i] = line.substring(0, line.indexOf(',')) + ',live';
lines[i + 1] = newSegUrl != null ? newSegUrl : '';
}
/*if (line.startsWith('#EXT-X-TWITCH-PREFETCH')) {
// TODO
lines[i] = '';
}*/
else {
streamInfo.FakeSeqNumber += Math.max(0, streamInfo.BackupSeqNumber - oldBackupSeqNumber);
}
} else {
var segmentMap = [];
if (backupM3u8 != null) {
streamInfo.FakeSeqNumber = streamInfo.BackupSeqNumber;
}
}
var backupLines = backupM3u8.replace('\r', '').split('\n');
var segTimes = getSegmentTimes(lines);
var backupSegTimes = getSegmentTimes(backupLines);
for (const [segTime, segUrl] of Object.entries(segTimes)) {
//segmentMap[segUrl] = Object.values(backupSegTimes)[Object.keys(backupSegTimes).length-1];
var closestTime = Number.MAX_VALUE;
var matchingBackupTime = Number.MAX_VALUE;
for (const [backupSegTime, backupSegUrl] of Object.entries(backupSegTimes)) {
var timeDiff = Math.abs(segTime - backupSegTime);
if (timeDiff < closestTime) {
closestTime = timeDiff;
matchingBackupTime = backupSegTime;
segmentMap[segUrl] = backupSegUrl;
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 (closestTime != Number.MAX_VALUE) {
backupSegTimes.splice(backupSegTimes.indexOf(matchingBackupTime), 1);
if (segData != null) {
if (segData.isPrefetch) {
pushedPrefetchSegs++;
newLines.push(segData.urlLine);
} else {
//newLines.push(segData.dateTimeLine);
newLines.push(segData.extInfLine);
newLines.push(segData.urlLine);
}
}
}
for (var i = 0; i < lines.length; i++) {
var line = lines[i];
if (line.includes('stitched-ad')) {
lines[i] = '';
}
if (line.startsWith('#EXTINF:') && !line.includes(',live')) {
lines[i] = line.substring(0, line.indexOf(',')) + ',live';
var backupSegment = segmentMap[lines[i + 1]];
lines[i + 1] = backupSegment != null ? backupSegment : ''
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 = lines.join('\n');
textStr = newLines.length > 0 ? newLines.join('\n') : lines.join('\n');
//console.log(textStr);
}
return textStr;
@ -356,6 +444,22 @@
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) {
@ -429,9 +533,13 @@
streamInfo.RootM3U8Params = (new URL(url)).search;
streamInfo.BackupUrl = null;
streamInfo.BackupFailed = false;
streamInfo.SegmentMap = [];
streamInfo.SegmentCache = [];
streamInfo.IsMidroll = false;
streamInfo.NotifyFirstTime = 0;
streamInfo.NotifyObservedNoAds = false;
streamInfo.RealSeqNumber = -1;
streamInfo.BackupSeqNumber = -1;
streamInfo.FakeSeqNumber = 0;
var lines = encodingsM3u8.replace('\r', '').split('\n');
for (var i = 0; i < lines.length; i++) {
if (!lines[i].startsWith('#') && lines[i].includes('.m3u8')) {
@ -619,12 +727,7 @@
if (typeof url === 'string') {
if (url.includes('/access_token') || url.includes('gql')) {
if (OPT_ACCESS_TOKEN_PLAYER_TYPE) {
if (url.includes('/access_token')) {
var modifiedUrl = new URL(url);
modifiedUrl.searchParams.set('player_type', OPT_ACCESS_TOKEN_PLAYER_TYPE);
arguments[0] = modifiedUrl.href;
}
else if (url.includes('gql') && init && typeof init.body === 'string' && init.body.includes('PlaybackAccessToken')) {
if (url.includes('gql') && init && typeof init.body === 'string' && init.body.includes('PlaybackAccessToken')) {
const newBody = JSON.parse(init.body);
newBody.variables.playerType = OPT_ACCESS_TOKEN_PLAYER_TYPE;
init.body = JSON.stringify(newBody);
@ -643,63 +746,13 @@
value: gql_device_id
});
}
if (OPT_MODE_NOTIFY_ADS_WATCHED) {
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) {
if (OPT_MODE_NOTIFY_ADS_WATCHED_ASYNC_TOK) {
var channelName = JSON.parse(init.body).variables.login;
// See if the first response has an ad, if it does then send requests until there is no ad
{
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, -1, responseData.data.streamPlaybackAccessToken.signature, responseData.data.streamPlaybackAccessToken.value) == 1) {
console.log('No ad in main request');
resolve(new Response(responseStr));
return;
} else {
console.log('Ad in main request');
}
}
}
if (!channelName) {
resolve(response);
return;
}
// Ads are being served, try skipping a bunch of ads
var resolved = false;
var skipAdTries = 0;
for (var i = 0; i < OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS; i++) {
new Promise(async (skipAdResolve, skipAdReject) => {
var noAds = false;
var accessTokenResponse = await getAccessToken(channelName, OPT_REGULAR_PLAYER_TYPE, realFetch);
if (accessTokenResponse.status == 200) {
var responseStr = await accessTokenResponse.text();
var responseData = JSON.parse(responseStr);
var hasAd = false;
if (responseData && responseData.data && responseData.data.streamPlaybackAccessToken && responseData.data.streamPlaybackAccessToken.value && responseData.data.streamPlaybackAccessToken.signature) {
hasAd = (await tryNotifyAdsWatchedSigTok(realFetch, -1, responseData.data.streamPlaybackAccessToken.signature, responseData.data.streamPlaybackAccessToken.value)) == 0;
}
var attempt = ++skipAdTries;
console.log('Attempt ' + attempt + ' ' + (hasAd ? 'has ad' : 'no ad'));
if (attempt === OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS && !resolved) {
resolved = true;
resolve(new Response(responseStr));
return;
}
} else if (!resolved) {
resolved = true;
resolve(response);
return;
}
}).catch(console.log);
}
} else {
for (var i = 0; i < OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS; i++) {
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);
@ -715,7 +768,6 @@
}
}
resolve(response);
}
} else {
resolve(response);
}