video-swap-new fix embed player pausing (#432) / video latency stat (#119) / fallback stream improvements (#450)

This commit is contained in:
pixeltris
2025-10-23 16:33:12 +01:00
parent c8749a2846
commit 7bc595fb70
2 changed files with 229 additions and 275 deletions
+114 -137
View File
@@ -1,7 +1,7 @@
twitch-videoad.js text/javascript twitch-videoad.js text/javascript
(function() { (function() {
if ( /(^|\.)twitch\.tv$/.test(document.location.hostname) === false ) { return; } if ( /(^|\.)twitch\.tv$/.test(document.location.hostname) === false ) { return; }
var ourTwitchAdSolutionsVersion = 11;// Used to prevent conflicts with outdated versions of the scripts var ourTwitchAdSolutionsVersion = 12;// Used to prevent conflicts with outdated versions of the scripts
if (typeof unsafeWindow === 'undefined') { if (typeof unsafeWindow === 'undefined') {
unsafeWindow = window; unsafeWindow = window;
} }
@@ -13,13 +13,11 @@ twitch-videoad.js text/javascript
unsafeWindow.twitchAdSolutionsVersion = ourTwitchAdSolutionsVersion; unsafeWindow.twitchAdSolutionsVersion = ourTwitchAdSolutionsVersion;
function declareOptions(scope) { function declareOptions(scope) {
// Options / globals // Options / globals
scope.OPT_MODE_STRIP_AD_SEGMENTS = true; scope.OPT_PREROLL_BACKUP_PLAYER_TYPES = [ 'autoplay', 'picture-by-picture', 'embed' ];
scope.OPT_MODE_NOTIFY_ADS_WATCHED = true; scope.OPT_MIDROLL_BACKUP_PLAYER_TYPES = [ 'autoplay', 'picture-by-picture', 'embed' ];
scope.OPT_MODE_NOTIFY_ADS_WATCHED_MIN_REQUESTS = false;
scope.OPT_PREROLL_BACKUP_PLAYER_TYPES = [ 'autoplay', 'embed' ];
scope.OPT_MIDROLL_BACKUP_PLAYER_TYPES = [ 'autoplay', 'picture-by-picture' ,'embed' ];
scope.OPT_BACKUP_PLATFORM = 'android'; scope.OPT_BACKUP_PLATFORM = 'android';
scope.OPT_ACCESS_TOKEN_PLAYER_TYPE = null; scope.OPT_ACCESS_TOKEN_PLAYER_TYPE = 'site';
scope.OPT_STRIP_PARENT_DOMAINS = true;
scope.OPT_SHOW_AD_BANNER = true; scope.OPT_SHOW_AD_BANNER = true;
scope.AD_SIGNIFIER = 'stitched-ad'; scope.AD_SIGNIFIER = 'stitched-ad';
scope.LIVE_SIGNIFIER = ',live'; scope.LIVE_SIGNIFIER = ',live';
@@ -32,6 +30,7 @@ twitch-videoad.js text/javascript
scope.gql_device_id = null; scope.gql_device_id = null;
scope.ClientIntegrityHeader = null; scope.ClientIntegrityHeader = null;
scope.AuthorizationHeader = null; scope.AuthorizationHeader = null;
scope.SimulatedAdsDepth = 0;
} }
var twitchWorkers = []; var twitchWorkers = [];
var workerStringConflicts = [ var workerStringConflicts = [
@@ -111,13 +110,17 @@ twitch-videoad.js text/javascript
${getAccessToken.toString()} ${getAccessToken.toString()}
${gqlRequest.toString()} ${gqlRequest.toString()}
${makeGraphQlPacket.toString()} ${makeGraphQlPacket.toString()}
${tryNotifyAdsWatchedM3U8.toString()}
${parseAttributes.toString()} ${parseAttributes.toString()}
${setStreamInfoUrls.toString()} ${setStreamInfoUrls.toString()}
${onFoundAd.toString()} ${onFoundAd.toString()}
${getWasmWorkerJs.toString()} ${getWasmWorkerJs.toString()}
${getServerTimeFromM3u8.toString()}
${replaceServerTimeInM3u8.toString()}
var workerString = getWasmWorkerJs('${twitchBlobUrl.replaceAll("'", "%27")}'); var workerString = getWasmWorkerJs('${twitchBlobUrl.replaceAll("'", "%27")}');
declareOptions(self); declareOptions(self);
gql_device_id = ${gql_device_id ? "'" + gql_device_id + "'" : null};
AuthorizationHeader = ${AuthorizationHeader ? "'" + AuthorizationHeader + "'" : null};
ClientIntegrityHeader = ${AuthorizationHeader ? "'" + ClientIntegrityHeader + "'" : null};
self.addEventListener('message', function(e) { self.addEventListener('message', function(e) {
if (e.data.key == 'UboUpdateDeviceId') { if (e.data.key == 'UboUpdateDeviceId') {
gql_device_id = e.data.value; gql_device_id = e.data.value;
@@ -142,6 +145,9 @@ twitch-videoad.js text/javascript
resolve(response); resolve(response);
} }
} }
} else if (e.data.key == 'SimulateAds') {
SimulatedAdsDepth = e.data.value;
console.log('SimulatedAdsDepth:' + SimulatedAdsDepth);
} }
}); });
hookWorkerFetch(); hookWorkerFetch();
@@ -239,13 +245,12 @@ twitch-videoad.js text/javascript
return textStr; return textStr;
} }
if (streamInfo.BackupEncodings && !streamInfo.BackupEncodings.includes(url)) { if (streamInfo.BackupEncodings && !streamInfo.BackupEncodings.includes(url)) {
// Disabled for now as this may cause some problems // NOTE: This might cause some freezing issues
/*console.log('Double request before it managed to switch to the backup?');
var streamM3u8Url = streamInfo.BackupEncodings.match(/^https:.*\.m3u8.*$/m)[0]; var streamM3u8Url = streamInfo.BackupEncodings.match(/^https:.*\.m3u8.*$/m)[0];
var streamM3u8Response = await realFetch(streamM3u8Url); var streamM3u8Response = await realFetch(streamM3u8Url);
if (streamM3u8Response.status === 200) { if (streamM3u8Response.status === 200) {
return await streamM3u8Response.text(); return await streamM3u8Response.text();
}*/ }
} }
var backupPlayerTypeInfo = ''; var backupPlayerTypeInfo = '';
for (var i = 0; i < playerTypes.length; i++) { for (var i = 0; i < playerTypes.length; i++) {
@@ -265,10 +270,11 @@ twitch-videoad.js text/javascript
var streamM3u8Response = await realFetch(streamM3u8Url); var streamM3u8Response = await realFetch(streamM3u8Url);
if (streamM3u8Response.status === 200) { if (streamM3u8Response.status === 200) {
var backTextStr = await streamM3u8Response.text(); var backTextStr = await streamM3u8Response.text();
if (!backTextStr.includes(AD_SIGNIFIER) || streamInfo.BackupEncodingsStatus.size >= playerTypes.length - 1) { if ((!backTextStr.includes(AD_SIGNIFIER) && (SimulatedAdsDepth == 0 || i >= SimulatedAdsDepth - 1)) || streamInfo.BackupEncodingsStatus.size >= playerTypes.length - 1) {
result = backTextStr; result = backTextStr;
backupPlayerTypeInfo = ' (' + playerType + ')'; backupPlayerTypeInfo = ' (' + playerType + ')';
streamInfo.BackupEncodingsStatus.set(playerType, 1); streamInfo.BackupEncodingsStatus.set(playerType, 1);
streamInfo.BackupEncodingsPlayerTypeIndex = i;
if (playerType !== 'embed') { if (playerType !== 'embed') {
// Low resolution streams will reduce the number of resolutions in the UI. To fix this we merge the highest low res into the main m3u8 // Low resolution streams will reduce the number of resolutions in the UI. To fix this we merge the highest low res into the main m3u8
// TODO: Do a better matching up of the resolutions rather than picking the highest low res for all // TODO: Do a better matching up of the resolutions rather than picking the highest low res for all
@@ -323,24 +329,26 @@ twitch-videoad.js text/javascript
async function processM3U8(url, textStr, realFetch) { async function processM3U8(url, textStr, realFetch) {
var streamInfo = StreamInfosByUrl[url]; var streamInfo = StreamInfosByUrl[url];
if (streamInfo == null) { if (streamInfo == null) {
console.log('Unknown stream url ' + url); //console.log('Unknown stream url ' + url);
//postMessage({key:'UboHideAdBanner'}); //postMessage({key:'UboHideAdBanner'});
return textStr; return textStr;
} }
if (!OPT_MODE_STRIP_AD_SEGMENTS) {
return textStr;
}
var haveAdTags = textStr.includes(AD_SIGNIFIER); var haveAdTags = textStr.includes(AD_SIGNIFIER);
if (SimulatedAdsDepth && (!streamInfo.BackupEncodings || !streamInfo.BackupEncodings.includes(url) || SimulatedAdsDepth - 1 > streamInfo.BackupEncodingsPlayerTypeIndex)) {
haveAdTags = true;
}
if (streamInfo.BackupEncodings) { if (streamInfo.BackupEncodings) {
var streamM3u8Url = streamInfo.Encodings.match(/^https:.*\.m3u8$/m)[0]; var streamM3u8Url = streamInfo.Encodings.match(/^https:.*\.m3u8$/m)[0];
var streamM3u8Response = await realFetch(streamM3u8Url); var streamM3u8Response = await realFetch(streamM3u8Url);
if (streamM3u8Response.status == 200) { if (streamM3u8Response.status == 200) {
var streamM3u8 = await streamM3u8Response.text(); var streamM3u8 = await streamM3u8Response.text();
if (streamM3u8 != null) { if (streamM3u8 != null) {
if (!streamM3u8.includes(AD_SIGNIFIER)) { if (!streamM3u8.includes(AD_SIGNIFIER) && !SimulatedAdsDepth) {
console.log('No more ads on main stream. Triggering player reload to go back to main stream...'); console.log('No more ads on main stream. Triggering player reload to go back to main stream...');
streamInfo.IsMovingOffBackupEncodings = true;
streamInfo.BackupEncodings = null; streamInfo.BackupEncodings = null;
streamInfo.BackupEncodingsStatus.clear(); streamInfo.BackupEncodingsStatus.clear();
streamInfo.BackupEncodingsPlayerTypeIndex = -1;
postMessage({key:'UboHideAdBanner'}); postMessage({key:'UboHideAdBanner'});
postMessage({key:'UboReloadPlayer'}); postMessage({key:'UboReloadPlayer'});
} else if (!streamM3u8.includes('"MIDROLL"') && !streamM3u8.includes('"midroll"')) { } else if (!streamM3u8.includes('"MIDROLL"') && !streamM3u8.includes('"midroll"')) {
@@ -363,7 +371,7 @@ twitch-videoad.js text/javascript
if (streamInfo.BackupEncodings && haveAdTags) { if (streamInfo.BackupEncodings && haveAdTags) {
textStr = await onFoundAd(streamInfo, textStr, true, realFetch, url); textStr = await onFoundAd(streamInfo, textStr, true, realFetch, url);
} }
} else if (haveAdTags) { } else if (haveAdTags && !streamInfo.IsMovingOffBackupEncodings) {
textStr = await onFoundAd(streamInfo, textStr, true, realFetch, url); textStr = await onFoundAd(streamInfo, textStr, true, realFetch, url);
} else { } else {
postMessage({key:'UboHideAdBanner'}); postMessage({key:'UboHideAdBanner'});
@@ -410,59 +418,77 @@ twitch-videoad.js text/javascript
}); });
} }
CurrentChannelNameFromM3U8 = channelName; CurrentChannelNameFromM3U8 = channelName;
if (OPT_MODE_STRIP_AD_SEGMENTS) { if (OPT_STRIP_PARENT_DOMAINS) {
return new Promise(async function(resolve, reject) { // parent_domains is used to determine if the player is embeded and stripping it gets rid of fake ads
// - First m3u8 request is the m3u8 with the video encodings (360p,480p,720p,etc). var tempUrl = new URL(url);
// - Second m3u8 request is the m3u8 for the given encoding obtained in the first request. At this point we will know if there's ads. tempUrl.searchParams.delete('parent_domains');
var streamInfo = StreamInfos[channelName]; url = tempUrl.toString();
if (streamInfo != null && streamInfo.Encodings != null && (await realFetch(streamInfo.Encodings.match(/^https:.*\.m3u8$/m)[0])).status !== 200) { }
// The cached encodings are dead (the stream probably restarted) return new Promise(async function(resolve, reject) {
streamInfo = null; // - First m3u8 request is the m3u8 with the video encodings (360p,480p,720p,etc).
} // - Second m3u8 request is the m3u8 for the given encoding obtained in the first request. At this point we will know if there's ads.
if (streamInfo == null || streamInfo.Encodings == null) { var streamInfo = StreamInfos[channelName];
StreamInfos[channelName] = streamInfo = { if (streamInfo != null && streamInfo.Encodings != null && (await realFetch(streamInfo.Encodings.match(/^https:.*\.m3u8$/m)[0])).status !== 200) {
RequestedAds: new Set(), // The cached encodings are dead (the stream probably restarted)
Encodings: null, streamInfo = null;
BackupEncodings: null, }
BackupEncodingsStatus: new Map(), var serverTime = null;
IsMidroll: false, if (streamInfo == null || streamInfo.Encodings == null) {
UseFallbackStream: false, StreamInfos[channelName] = streamInfo = {
ChannelName: channelName, RequestedAds: new Set(),
UsherParams: (new URL(url)).search Encodings: null,
}; BackupEncodings: null,
var encodingsM3u8Response = await realFetch(url, options); BackupEncodingsStatus: new Map(),
if (encodingsM3u8Response != null && encodingsM3u8Response.status === 200) { BackupEncodingsPlayerTypeIndex: -1,
var encodingsM3u8 = await encodingsM3u8Response.text(); IsMovingOffBackupEncodings: false,
streamInfo.Encodings = encodingsM3u8; IsMidroll: false,
setStreamInfoUrls(streamInfo, encodingsM3u8); UseFallbackStream: false,
var streamM3u8Url = encodingsM3u8.match(/^https:.*\.m3u8$/m)[0]; ChannelName: channelName,
var streamM3u8Response = await realFetch(streamM3u8Url); UsherParams: (new URL(url)).search
if (streamM3u8Response.status == 200) { };
var streamM3u8 = await streamM3u8Response.text(); var encodingsM3u8Response = await realFetch(url, options);
if (streamM3u8.includes(AD_SIGNIFIER)) { if (encodingsM3u8Response != null && encodingsM3u8Response.status === 200) {
await onFoundAd(streamInfo, streamM3u8, false, realFetch, streamM3u8Url); var encodingsM3u8 = await encodingsM3u8Response.text();
} streamInfo.Encodings = encodingsM3u8;
} else { setStreamInfoUrls(streamInfo, encodingsM3u8);
resolve(streamM3u8Response); serverTime = getServerTimeFromM3u8(encodingsM3u8);
return; var streamM3u8Url = encodingsM3u8.match(/^https:.*\.m3u8$/m)[0];
var streamM3u8Response = await realFetch(streamM3u8Url);
if (streamM3u8Response.status == 200) {
var streamM3u8 = await streamM3u8Response.text();
if (streamM3u8.includes(AD_SIGNIFIER)) {
await onFoundAd(streamInfo, streamM3u8, false, realFetch, streamM3u8Url);
} }
} else { } else {
resolve(encodingsM3u8Response); resolve(streamM3u8Response);
return; return;
} }
}
if (streamInfo.BackupEncodings) {
resolve(new Response(streamInfo.BackupEncodings));
} else { } else {
resolve(new Response(streamInfo.Encodings)); resolve(encodingsM3u8Response);
return;
} }
}); }
} if (!serverTime) {
var encodingsM3u8Response = await realFetch(url, options);
if (encodingsM3u8Response != null && encodingsM3u8Response.status === 200) {
serverTime = getServerTimeFromM3u8(await encodingsM3u8Response.text());
}
}
streamInfo.IsMovingOffBackupEncodings = false;
resolve(new Response(replaceServerTimeInM3u8(streamInfo.BackupEncodings ? streamInfo.BackupEncodings : streamInfo.Encodings, serverTime)));
});
} }
} }
return realFetch.apply(this, arguments); return realFetch.apply(this, arguments);
} }
} }
function getServerTimeFromM3u8(encodingsM3u8) {
var matches = encodingsM3u8.match('SERVER-TIME="([0-9.]+)"');
return matches.length > 1 ? matches[1] : null;
}
function replaceServerTimeInM3u8(encodingsM3u8, newServerTime) {
return newServerTime ? encodingsM3u8.replace(new RegExp('(SERVER-TIME=")[0-9.]+"'), `SERVER-TIME="${newServerTime}"`) : encodingsM3u8;
}
function makeGraphQlPacket(event, radToken, payload) { function makeGraphQlPacket(event, radToken, payload) {
return [{ return [{
operationName: 'ClientSideAdEventHandling_RecordAdEvent', operationName: 'ClientSideAdEventHandling_RecordAdEvent',
@@ -546,65 +572,6 @@ twitch-videoad.js text/javascript
return [key, Number.isNaN(num) ? value.startsWith('"') ? JSON.parse(value) : value : num] return [key, Number.isNaN(num) ? value.startsWith('"') ? JSON.parse(value) : value : num]
})); }));
} }
async function tryNotifyAdsWatchedM3U8(streamM3u8) {
try {
//console.log(streamM3u8);
if (!streamM3u8 || !streamM3u8.includes(AD_SIGNIFIER)) {
return 1;
}
var matches = streamM3u8.match(/#EXT-X-DATERANGE:(ID="stitched-ad-[^\n]+)\n/);
if (matches.length > 1) {
const attrString = matches[1];
const attr = parseAttributes(attrString);
var podLength = parseInt(attr['X-TV-TWITCH-AD-POD-LENGTH'] ? attr['X-TV-TWITCH-AD-POD-LENGTH'] : '1');
var podPosition = parseInt(attr['X-TV-TWITCH-AD-POD-POSITION'] ? attr['X-TV-TWITCH-AD-POD-POSITION'] : '0');
var radToken = attr['X-TV-TWITCH-AD-RADS-TOKEN'];
var lineItemId = attr['X-TV-TWITCH-AD-LINE-ITEM-ID'];
var orderId = attr['X-TV-TWITCH-AD-ORDER-ID'];
var creativeId = attr['X-TV-TWITCH-AD-CREATIVE-ID'];
var adId = attr['X-TV-TWITCH-AD-ADVERTISER-ID'];
var rollType = attr['X-TV-TWITCH-AD-ROLL-TYPE'].toLowerCase();
const baseData = {
stitched: true,
roll_type: rollType,
player_mute: false,
player_volume: 0.5,
visible: true,
};
for (let podPosition = 0; podPosition < podLength; podPosition++) {
if (OPT_MODE_NOTIFY_ADS_WATCHED_MIN_REQUESTS) {
// This is all that's actually required at the moment
await gqlRequest(makeGraphQlPacket('video_ad_pod_complete', radToken, baseData));
} else {
const extendedData = {
...baseData,
ad_id: adId,
ad_position: podPosition,
duration: 30,
creative_id: creativeId,
total_ads: podLength,
order_id: orderId,
line_item_id: lineItemId,
};
await gqlRequest(makeGraphQlPacket('video_ad_impression', radToken, extendedData));
for (let quartile = 0; quartile < 4; quartile++) {
await gqlRequest(
makeGraphQlPacket('video_ad_quartile_complete', radToken, {
...extendedData,
quartile: quartile + 1,
})
);
}
await gqlRequest(makeGraphQlPacket('video_ad_pod_complete', radToken, baseData));
}
}
}
return 0;
} catch (err) {
console.log(err);
return 0;
}
}
function postTwitchWorkerMessage(key, value) { function postTwitchWorkerMessage(key, value) {
twitchWorkers.forEach((worker) => { twitchWorkers.forEach((worker) => {
worker.postMessage({key: key, value: value}); worker.postMessage({key: key, value: value});
@@ -700,30 +667,32 @@ twitch-videoad.js text/javascript
if (gql_device_id) { if (gql_device_id) {
postTwitchWorkerMessage('UboUpdateDeviceId', gql_device_id); postTwitchWorkerMessage('UboUpdateDeviceId', gql_device_id);
} }
if (typeof init.body === 'string' && init.body.includes('PlaybackAccessToken')) { if (OPT_ACCESS_TOKEN_PLAYER_TYPE && typeof init.body === 'string' && init.body.includes('PlaybackAccessToken') && !init.body.includes('picture-by-picture')) {
if (OPT_ACCESS_TOKEN_PLAYER_TYPE) { let replacedPlayerType = '';
const newBody = JSON.parse(init.body); const newBody = JSON.parse(init.body);
if (Array.isArray(newBody)) { if (Array.isArray(newBody)) {
for (let i = 0; i < newBody.length; i++) { for (let i = 0; i < newBody.length; i++) {
if (newBody[i]?.variables?.playerType && newBody[i]?.variables?.playerType !== OPT_ACCESS_TOKEN_PLAYER_TYPE) {
replacedPlayerType = newBody[i].variables.playerType;
newBody[i].variables.playerType = OPT_ACCESS_TOKEN_PLAYER_TYPE; newBody[i].variables.playerType = OPT_ACCESS_TOKEN_PLAYER_TYPE;
} }
} else { }
} else {
if (newBody?.variables?.playerType && newBody?.variables?.playerType !== OPT_ACCESS_TOKEN_PLAYER_TYPE) {
replacedPlayerType = newBody.variables.playerType;
newBody.variables.playerType = OPT_ACCESS_TOKEN_PLAYER_TYPE; newBody.variables.playerType = OPT_ACCESS_TOKEN_PLAYER_TYPE;
} }
}
if (replacedPlayerType) {
console.log(`Replaced '${replacedPlayerType}' player type with '${OPT_ACCESS_TOKEN_PLAYER_TYPE}' player type`);
init.body = JSON.stringify(newBody); init.body = JSON.stringify(newBody);
} }
if (typeof init.headers['Client-Integrity'] === 'string') { }
ClientIntegrityHeader = init.headers['Client-Integrity']; if (typeof init.headers['Client-Integrity'] === 'string' && init.headers['Client-Integrity'] !== ClientIntegrityHeader) {
if (ClientIntegrityHeader) { postTwitchWorkerMessage('UpdateClientIntegrityHeader', ClientIntegrityHeader = init.headers['Client-Integrity']);
postTwitchWorkerMessage('UpdateClientIntegrityHeader', init.headers['Client-Integrity']); }
} if (typeof init.headers['Authorization'] === 'string' && init.headers['Authorization'] !== AuthorizationHeader) {
} postTwitchWorkerMessage('UpdateAuthorizationHeader', AuthorizationHeader = init.headers['Authorization']);
if (typeof init.headers['Authorization'] === 'string') {
AuthorizationHeader = init.headers['Authorization'];
if (AuthorizationHeader) {
postTwitchWorkerMessage('UpdateAuthorizationHeader', init.headers['Authorization']);
}
}
} }
} }
} }
@@ -807,6 +776,7 @@ twitch-videoad.js text/javascript
localStorage.setItem(lsKeyQuality, JSON.stringify({default:player.core.state.quality.group})); localStorage.setItem(lsKeyQuality, JSON.stringify({default:player.core.state.quality.group}));
} }
playerState.setSrc({ isNewMediaPlayerInstance: true, refreshAccessToken: true }); playerState.setSrc({ isNewMediaPlayerInstance: true, refreshAccessToken: true });
player.play();
setTimeout(() => { setTimeout(() => {
localStorage.setItem(lsKeyQuality, currentQualityLS); localStorage.setItem(lsKeyQuality, currentQualityLS);
localStorage.setItem(lsKeyMuted, currentMutedLS); localStorage.setItem(lsKeyMuted, currentMutedLS);
@@ -908,4 +878,11 @@ twitch-videoad.js text/javascript
onContentLoaded(); onContentLoaded();
}); });
} }
unsafeWindow.simulateAds = (depth) => {
if (depth === undefined || depth < 0) {
console.log('Ad depth paramter required (0 = no simulated ad, 1+ = use backup player for given depth)');
return;
}
postTwitchWorkerMessage('SimulateAds', depth);
};
})(); })();
+115 -138
View File
@@ -1,7 +1,7 @@
// ==UserScript== // ==UserScript==
// @name TwitchAdSolutions (video-swap-new) // @name TwitchAdSolutions (video-swap-new)
// @namespace https://github.com/pixeltris/TwitchAdSolutions // @namespace https://github.com/pixeltris/TwitchAdSolutions
// @version 1.43 // @version 1.44
// @updateURL https://github.com/pixeltris/TwitchAdSolutions/raw/master/video-swap-new/video-swap-new.user.js // @updateURL https://github.com/pixeltris/TwitchAdSolutions/raw/master/video-swap-new/video-swap-new.user.js
// @downloadURL https://github.com/pixeltris/TwitchAdSolutions/raw/master/video-swap-new/video-swap-new.user.js // @downloadURL https://github.com/pixeltris/TwitchAdSolutions/raw/master/video-swap-new/video-swap-new.user.js
// @description Multiple solutions for blocking Twitch ads (video-swap-new) // @description Multiple solutions for blocking Twitch ads (video-swap-new)
@@ -14,7 +14,7 @@
// ==/UserScript== // ==/UserScript==
(function() { (function() {
'use strict'; 'use strict';
var ourTwitchAdSolutionsVersion = 11;// Used to prevent conflicts with outdated versions of the scripts var ourTwitchAdSolutionsVersion = 12;// Used to prevent conflicts with outdated versions of the scripts
if (typeof unsafeWindow === 'undefined') { if (typeof unsafeWindow === 'undefined') {
unsafeWindow = window; unsafeWindow = window;
} }
@@ -26,13 +26,11 @@
unsafeWindow.twitchAdSolutionsVersion = ourTwitchAdSolutionsVersion; unsafeWindow.twitchAdSolutionsVersion = ourTwitchAdSolutionsVersion;
function declareOptions(scope) { function declareOptions(scope) {
// Options / globals // Options / globals
scope.OPT_MODE_STRIP_AD_SEGMENTS = true; scope.OPT_PREROLL_BACKUP_PLAYER_TYPES = [ 'autoplay', 'picture-by-picture', 'embed' ];
scope.OPT_MODE_NOTIFY_ADS_WATCHED = true; scope.OPT_MIDROLL_BACKUP_PLAYER_TYPES = [ 'autoplay', 'picture-by-picture', 'embed' ];
scope.OPT_MODE_NOTIFY_ADS_WATCHED_MIN_REQUESTS = false;
scope.OPT_PREROLL_BACKUP_PLAYER_TYPES = [ 'autoplay', 'embed' ];
scope.OPT_MIDROLL_BACKUP_PLAYER_TYPES = [ 'autoplay', 'picture-by-picture' ,'embed' ];
scope.OPT_BACKUP_PLATFORM = 'android'; scope.OPT_BACKUP_PLATFORM = 'android';
scope.OPT_ACCESS_TOKEN_PLAYER_TYPE = null; scope.OPT_ACCESS_TOKEN_PLAYER_TYPE = 'site';
scope.OPT_STRIP_PARENT_DOMAINS = true;
scope.OPT_SHOW_AD_BANNER = true; scope.OPT_SHOW_AD_BANNER = true;
scope.AD_SIGNIFIER = 'stitched-ad'; scope.AD_SIGNIFIER = 'stitched-ad';
scope.LIVE_SIGNIFIER = ',live'; scope.LIVE_SIGNIFIER = ',live';
@@ -45,6 +43,7 @@
scope.gql_device_id = null; scope.gql_device_id = null;
scope.ClientIntegrityHeader = null; scope.ClientIntegrityHeader = null;
scope.AuthorizationHeader = null; scope.AuthorizationHeader = null;
scope.SimulatedAdsDepth = 0;
} }
var twitchWorkers = []; var twitchWorkers = [];
var workerStringConflicts = [ var workerStringConflicts = [
@@ -124,13 +123,17 @@
${getAccessToken.toString()} ${getAccessToken.toString()}
${gqlRequest.toString()} ${gqlRequest.toString()}
${makeGraphQlPacket.toString()} ${makeGraphQlPacket.toString()}
${tryNotifyAdsWatchedM3U8.toString()}
${parseAttributes.toString()} ${parseAttributes.toString()}
${setStreamInfoUrls.toString()} ${setStreamInfoUrls.toString()}
${onFoundAd.toString()} ${onFoundAd.toString()}
${getWasmWorkerJs.toString()} ${getWasmWorkerJs.toString()}
${getServerTimeFromM3u8.toString()}
${replaceServerTimeInM3u8.toString()}
var workerString = getWasmWorkerJs('${twitchBlobUrl.replaceAll("'", "%27")}'); var workerString = getWasmWorkerJs('${twitchBlobUrl.replaceAll("'", "%27")}');
declareOptions(self); declareOptions(self);
gql_device_id = ${gql_device_id ? "'" + gql_device_id + "'" : null};
AuthorizationHeader = ${AuthorizationHeader ? "'" + AuthorizationHeader + "'" : null};
ClientIntegrityHeader = ${AuthorizationHeader ? "'" + ClientIntegrityHeader + "'" : null};
self.addEventListener('message', function(e) { self.addEventListener('message', function(e) {
if (e.data.key == 'UboUpdateDeviceId') { if (e.data.key == 'UboUpdateDeviceId') {
gql_device_id = e.data.value; gql_device_id = e.data.value;
@@ -155,6 +158,9 @@
resolve(response); resolve(response);
} }
} }
} else if (e.data.key == 'SimulateAds') {
SimulatedAdsDepth = e.data.value;
console.log('SimulatedAdsDepth:' + SimulatedAdsDepth);
} }
}); });
hookWorkerFetch(); hookWorkerFetch();
@@ -252,13 +258,12 @@
return textStr; return textStr;
} }
if (streamInfo.BackupEncodings && !streamInfo.BackupEncodings.includes(url)) { if (streamInfo.BackupEncodings && !streamInfo.BackupEncodings.includes(url)) {
// Disabled for now as this may cause some problems // NOTE: This might cause some freezing issues
/*console.log('Double request before it managed to switch to the backup?');
var streamM3u8Url = streamInfo.BackupEncodings.match(/^https:.*\.m3u8.*$/m)[0]; var streamM3u8Url = streamInfo.BackupEncodings.match(/^https:.*\.m3u8.*$/m)[0];
var streamM3u8Response = await realFetch(streamM3u8Url); var streamM3u8Response = await realFetch(streamM3u8Url);
if (streamM3u8Response.status === 200) { if (streamM3u8Response.status === 200) {
return await streamM3u8Response.text(); return await streamM3u8Response.text();
}*/ }
} }
var backupPlayerTypeInfo = ''; var backupPlayerTypeInfo = '';
for (var i = 0; i < playerTypes.length; i++) { for (var i = 0; i < playerTypes.length; i++) {
@@ -278,10 +283,11 @@
var streamM3u8Response = await realFetch(streamM3u8Url); var streamM3u8Response = await realFetch(streamM3u8Url);
if (streamM3u8Response.status === 200) { if (streamM3u8Response.status === 200) {
var backTextStr = await streamM3u8Response.text(); var backTextStr = await streamM3u8Response.text();
if (!backTextStr.includes(AD_SIGNIFIER) || streamInfo.BackupEncodingsStatus.size >= playerTypes.length - 1) { if ((!backTextStr.includes(AD_SIGNIFIER) && (SimulatedAdsDepth == 0 || i >= SimulatedAdsDepth - 1)) || streamInfo.BackupEncodingsStatus.size >= playerTypes.length - 1) {
result = backTextStr; result = backTextStr;
backupPlayerTypeInfo = ' (' + playerType + ')'; backupPlayerTypeInfo = ' (' + playerType + ')';
streamInfo.BackupEncodingsStatus.set(playerType, 1); streamInfo.BackupEncodingsStatus.set(playerType, 1);
streamInfo.BackupEncodingsPlayerTypeIndex = i;
if (playerType !== 'embed') { if (playerType !== 'embed') {
// Low resolution streams will reduce the number of resolutions in the UI. To fix this we merge the highest low res into the main m3u8 // Low resolution streams will reduce the number of resolutions in the UI. To fix this we merge the highest low res into the main m3u8
// TODO: Do a better matching up of the resolutions rather than picking the highest low res for all // TODO: Do a better matching up of the resolutions rather than picking the highest low res for all
@@ -336,24 +342,26 @@
async function processM3U8(url, textStr, realFetch) { async function processM3U8(url, textStr, realFetch) {
var streamInfo = StreamInfosByUrl[url]; var streamInfo = StreamInfosByUrl[url];
if (streamInfo == null) { if (streamInfo == null) {
console.log('Unknown stream url ' + url); //console.log('Unknown stream url ' + url);
//postMessage({key:'UboHideAdBanner'}); //postMessage({key:'UboHideAdBanner'});
return textStr; return textStr;
} }
if (!OPT_MODE_STRIP_AD_SEGMENTS) {
return textStr;
}
var haveAdTags = textStr.includes(AD_SIGNIFIER); var haveAdTags = textStr.includes(AD_SIGNIFIER);
if (SimulatedAdsDepth && (!streamInfo.BackupEncodings || !streamInfo.BackupEncodings.includes(url) || SimulatedAdsDepth - 1 > streamInfo.BackupEncodingsPlayerTypeIndex)) {
haveAdTags = true;
}
if (streamInfo.BackupEncodings) { if (streamInfo.BackupEncodings) {
var streamM3u8Url = streamInfo.Encodings.match(/^https:.*\.m3u8$/m)[0]; var streamM3u8Url = streamInfo.Encodings.match(/^https:.*\.m3u8$/m)[0];
var streamM3u8Response = await realFetch(streamM3u8Url); var streamM3u8Response = await realFetch(streamM3u8Url);
if (streamM3u8Response.status == 200) { if (streamM3u8Response.status == 200) {
var streamM3u8 = await streamM3u8Response.text(); var streamM3u8 = await streamM3u8Response.text();
if (streamM3u8 != null) { if (streamM3u8 != null) {
if (!streamM3u8.includes(AD_SIGNIFIER)) { if (!streamM3u8.includes(AD_SIGNIFIER) && !SimulatedAdsDepth) {
console.log('No more ads on main stream. Triggering player reload to go back to main stream...'); console.log('No more ads on main stream. Triggering player reload to go back to main stream...');
streamInfo.IsMovingOffBackupEncodings = true;
streamInfo.BackupEncodings = null; streamInfo.BackupEncodings = null;
streamInfo.BackupEncodingsStatus.clear(); streamInfo.BackupEncodingsStatus.clear();
streamInfo.BackupEncodingsPlayerTypeIndex = -1;
postMessage({key:'UboHideAdBanner'}); postMessage({key:'UboHideAdBanner'});
postMessage({key:'UboReloadPlayer'}); postMessage({key:'UboReloadPlayer'});
} else if (!streamM3u8.includes('"MIDROLL"') && !streamM3u8.includes('"midroll"')) { } else if (!streamM3u8.includes('"MIDROLL"') && !streamM3u8.includes('"midroll"')) {
@@ -376,7 +384,7 @@
if (streamInfo.BackupEncodings && haveAdTags) { if (streamInfo.BackupEncodings && haveAdTags) {
textStr = await onFoundAd(streamInfo, textStr, true, realFetch, url); textStr = await onFoundAd(streamInfo, textStr, true, realFetch, url);
} }
} else if (haveAdTags) { } else if (haveAdTags && !streamInfo.IsMovingOffBackupEncodings) {
textStr = await onFoundAd(streamInfo, textStr, true, realFetch, url); textStr = await onFoundAd(streamInfo, textStr, true, realFetch, url);
} else { } else {
postMessage({key:'UboHideAdBanner'}); postMessage({key:'UboHideAdBanner'});
@@ -423,59 +431,77 @@
}); });
} }
CurrentChannelNameFromM3U8 = channelName; CurrentChannelNameFromM3U8 = channelName;
if (OPT_MODE_STRIP_AD_SEGMENTS) { if (OPT_STRIP_PARENT_DOMAINS) {
return new Promise(async function(resolve, reject) { // parent_domains is used to determine if the player is embeded and stripping it gets rid of fake ads
// - First m3u8 request is the m3u8 with the video encodings (360p,480p,720p,etc). var tempUrl = new URL(url);
// - Second m3u8 request is the m3u8 for the given encoding obtained in the first request. At this point we will know if there's ads. tempUrl.searchParams.delete('parent_domains');
var streamInfo = StreamInfos[channelName]; url = tempUrl.toString();
if (streamInfo != null && streamInfo.Encodings != null && (await realFetch(streamInfo.Encodings.match(/^https:.*\.m3u8$/m)[0])).status !== 200) { }
// The cached encodings are dead (the stream probably restarted) return new Promise(async function(resolve, reject) {
streamInfo = null; // - First m3u8 request is the m3u8 with the video encodings (360p,480p,720p,etc).
} // - Second m3u8 request is the m3u8 for the given encoding obtained in the first request. At this point we will know if there's ads.
if (streamInfo == null || streamInfo.Encodings == null) { var streamInfo = StreamInfos[channelName];
StreamInfos[channelName] = streamInfo = { if (streamInfo != null && streamInfo.Encodings != null && (await realFetch(streamInfo.Encodings.match(/^https:.*\.m3u8$/m)[0])).status !== 200) {
RequestedAds: new Set(), // The cached encodings are dead (the stream probably restarted)
Encodings: null, streamInfo = null;
BackupEncodings: null, }
BackupEncodingsStatus: new Map(), var serverTime = null;
IsMidroll: false, if (streamInfo == null || streamInfo.Encodings == null) {
UseFallbackStream: false, StreamInfos[channelName] = streamInfo = {
ChannelName: channelName, RequestedAds: new Set(),
UsherParams: (new URL(url)).search Encodings: null,
}; BackupEncodings: null,
var encodingsM3u8Response = await realFetch(url, options); BackupEncodingsStatus: new Map(),
if (encodingsM3u8Response != null && encodingsM3u8Response.status === 200) { BackupEncodingsPlayerTypeIndex: -1,
var encodingsM3u8 = await encodingsM3u8Response.text(); IsMovingOffBackupEncodings: false,
streamInfo.Encodings = encodingsM3u8; IsMidroll: false,
setStreamInfoUrls(streamInfo, encodingsM3u8); UseFallbackStream: false,
var streamM3u8Url = encodingsM3u8.match(/^https:.*\.m3u8$/m)[0]; ChannelName: channelName,
var streamM3u8Response = await realFetch(streamM3u8Url); UsherParams: (new URL(url)).search
if (streamM3u8Response.status == 200) { };
var streamM3u8 = await streamM3u8Response.text(); var encodingsM3u8Response = await realFetch(url, options);
if (streamM3u8.includes(AD_SIGNIFIER)) { if (encodingsM3u8Response != null && encodingsM3u8Response.status === 200) {
await onFoundAd(streamInfo, streamM3u8, false, realFetch, streamM3u8Url); var encodingsM3u8 = await encodingsM3u8Response.text();
} streamInfo.Encodings = encodingsM3u8;
} else { setStreamInfoUrls(streamInfo, encodingsM3u8);
resolve(streamM3u8Response); serverTime = getServerTimeFromM3u8(encodingsM3u8);
return; var streamM3u8Url = encodingsM3u8.match(/^https:.*\.m3u8$/m)[0];
var streamM3u8Response = await realFetch(streamM3u8Url);
if (streamM3u8Response.status == 200) {
var streamM3u8 = await streamM3u8Response.text();
if (streamM3u8.includes(AD_SIGNIFIER)) {
await onFoundAd(streamInfo, streamM3u8, false, realFetch, streamM3u8Url);
} }
} else { } else {
resolve(encodingsM3u8Response); resolve(streamM3u8Response);
return; return;
} }
}
if (streamInfo.BackupEncodings) {
resolve(new Response(streamInfo.BackupEncodings));
} else { } else {
resolve(new Response(streamInfo.Encodings)); resolve(encodingsM3u8Response);
return;
} }
}); }
} if (!serverTime) {
var encodingsM3u8Response = await realFetch(url, options);
if (encodingsM3u8Response != null && encodingsM3u8Response.status === 200) {
serverTime = getServerTimeFromM3u8(await encodingsM3u8Response.text());
}
}
streamInfo.IsMovingOffBackupEncodings = false;
resolve(new Response(replaceServerTimeInM3u8(streamInfo.BackupEncodings ? streamInfo.BackupEncodings : streamInfo.Encodings, serverTime)));
});
} }
} }
return realFetch.apply(this, arguments); return realFetch.apply(this, arguments);
} }
} }
function getServerTimeFromM3u8(encodingsM3u8) {
var matches = encodingsM3u8.match('SERVER-TIME="([0-9.]+)"');
return matches.length > 1 ? matches[1] : null;
}
function replaceServerTimeInM3u8(encodingsM3u8, newServerTime) {
return newServerTime ? encodingsM3u8.replace(new RegExp('(SERVER-TIME=")[0-9.]+"'), `SERVER-TIME="${newServerTime}"`) : encodingsM3u8;
}
function makeGraphQlPacket(event, radToken, payload) { function makeGraphQlPacket(event, radToken, payload) {
return [{ return [{
operationName: 'ClientSideAdEventHandling_RecordAdEvent', operationName: 'ClientSideAdEventHandling_RecordAdEvent',
@@ -559,65 +585,6 @@
return [key, Number.isNaN(num) ? value.startsWith('"') ? JSON.parse(value) : value : num] return [key, Number.isNaN(num) ? value.startsWith('"') ? JSON.parse(value) : value : num]
})); }));
} }
async function tryNotifyAdsWatchedM3U8(streamM3u8) {
try {
//console.log(streamM3u8);
if (!streamM3u8 || !streamM3u8.includes(AD_SIGNIFIER)) {
return 1;
}
var matches = streamM3u8.match(/#EXT-X-DATERANGE:(ID="stitched-ad-[^\n]+)\n/);
if (matches.length > 1) {
const attrString = matches[1];
const attr = parseAttributes(attrString);
var podLength = parseInt(attr['X-TV-TWITCH-AD-POD-LENGTH'] ? attr['X-TV-TWITCH-AD-POD-LENGTH'] : '1');
var podPosition = parseInt(attr['X-TV-TWITCH-AD-POD-POSITION'] ? attr['X-TV-TWITCH-AD-POD-POSITION'] : '0');
var radToken = attr['X-TV-TWITCH-AD-RADS-TOKEN'];
var lineItemId = attr['X-TV-TWITCH-AD-LINE-ITEM-ID'];
var orderId = attr['X-TV-TWITCH-AD-ORDER-ID'];
var creativeId = attr['X-TV-TWITCH-AD-CREATIVE-ID'];
var adId = attr['X-TV-TWITCH-AD-ADVERTISER-ID'];
var rollType = attr['X-TV-TWITCH-AD-ROLL-TYPE'].toLowerCase();
const baseData = {
stitched: true,
roll_type: rollType,
player_mute: false,
player_volume: 0.5,
visible: true,
};
for (let podPosition = 0; podPosition < podLength; podPosition++) {
if (OPT_MODE_NOTIFY_ADS_WATCHED_MIN_REQUESTS) {
// This is all that's actually required at the moment
await gqlRequest(makeGraphQlPacket('video_ad_pod_complete', radToken, baseData));
} else {
const extendedData = {
...baseData,
ad_id: adId,
ad_position: podPosition,
duration: 30,
creative_id: creativeId,
total_ads: podLength,
order_id: orderId,
line_item_id: lineItemId,
};
await gqlRequest(makeGraphQlPacket('video_ad_impression', radToken, extendedData));
for (let quartile = 0; quartile < 4; quartile++) {
await gqlRequest(
makeGraphQlPacket('video_ad_quartile_complete', radToken, {
...extendedData,
quartile: quartile + 1,
})
);
}
await gqlRequest(makeGraphQlPacket('video_ad_pod_complete', radToken, baseData));
}
}
}
return 0;
} catch (err) {
console.log(err);
return 0;
}
}
function postTwitchWorkerMessage(key, value) { function postTwitchWorkerMessage(key, value) {
twitchWorkers.forEach((worker) => { twitchWorkers.forEach((worker) => {
worker.postMessage({key: key, value: value}); worker.postMessage({key: key, value: value});
@@ -713,30 +680,32 @@
if (gql_device_id) { if (gql_device_id) {
postTwitchWorkerMessage('UboUpdateDeviceId', gql_device_id); postTwitchWorkerMessage('UboUpdateDeviceId', gql_device_id);
} }
if (typeof init.body === 'string' && init.body.includes('PlaybackAccessToken')) { if (OPT_ACCESS_TOKEN_PLAYER_TYPE && typeof init.body === 'string' && init.body.includes('PlaybackAccessToken') && !init.body.includes('picture-by-picture')) {
if (OPT_ACCESS_TOKEN_PLAYER_TYPE) { let replacedPlayerType = '';
const newBody = JSON.parse(init.body); const newBody = JSON.parse(init.body);
if (Array.isArray(newBody)) { if (Array.isArray(newBody)) {
for (let i = 0; i < newBody.length; i++) { for (let i = 0; i < newBody.length; i++) {
if (newBody[i]?.variables?.playerType && newBody[i]?.variables?.playerType !== OPT_ACCESS_TOKEN_PLAYER_TYPE) {
replacedPlayerType = newBody[i].variables.playerType;
newBody[i].variables.playerType = OPT_ACCESS_TOKEN_PLAYER_TYPE; newBody[i].variables.playerType = OPT_ACCESS_TOKEN_PLAYER_TYPE;
} }
} else { }
} else {
if (newBody?.variables?.playerType && newBody?.variables?.playerType !== OPT_ACCESS_TOKEN_PLAYER_TYPE) {
replacedPlayerType = newBody.variables.playerType;
newBody.variables.playerType = OPT_ACCESS_TOKEN_PLAYER_TYPE; newBody.variables.playerType = OPT_ACCESS_TOKEN_PLAYER_TYPE;
} }
}
if (replacedPlayerType) {
console.log(`Replaced '${replacedPlayerType}' player type with '${OPT_ACCESS_TOKEN_PLAYER_TYPE}' player type`);
init.body = JSON.stringify(newBody); init.body = JSON.stringify(newBody);
} }
if (typeof init.headers['Client-Integrity'] === 'string') { }
ClientIntegrityHeader = init.headers['Client-Integrity']; if (typeof init.headers['Client-Integrity'] === 'string' && init.headers['Client-Integrity'] !== ClientIntegrityHeader) {
if (ClientIntegrityHeader) { postTwitchWorkerMessage('UpdateClientIntegrityHeader', ClientIntegrityHeader = init.headers['Client-Integrity']);
postTwitchWorkerMessage('UpdateClientIntegrityHeader', init.headers['Client-Integrity']); }
} if (typeof init.headers['Authorization'] === 'string' && init.headers['Authorization'] !== AuthorizationHeader) {
} postTwitchWorkerMessage('UpdateAuthorizationHeader', AuthorizationHeader = init.headers['Authorization']);
if (typeof init.headers['Authorization'] === 'string') {
AuthorizationHeader = init.headers['Authorization'];
if (AuthorizationHeader) {
postTwitchWorkerMessage('UpdateAuthorizationHeader', init.headers['Authorization']);
}
}
} }
} }
} }
@@ -820,6 +789,7 @@
localStorage.setItem(lsKeyQuality, JSON.stringify({default:player.core.state.quality.group})); localStorage.setItem(lsKeyQuality, JSON.stringify({default:player.core.state.quality.group}));
} }
playerState.setSrc({ isNewMediaPlayerInstance: true, refreshAccessToken: true }); playerState.setSrc({ isNewMediaPlayerInstance: true, refreshAccessToken: true });
player.play();
setTimeout(() => { setTimeout(() => {
localStorage.setItem(lsKeyQuality, currentQualityLS); localStorage.setItem(lsKeyQuality, currentQualityLS);
localStorage.setItem(lsKeyMuted, currentMutedLS); localStorage.setItem(lsKeyMuted, currentMutedLS);
@@ -921,4 +891,11 @@
onContentLoaded(); onContentLoaded();
}); });
} }
unsafeWindow.simulateAds = (depth) => {
if (depth === undefined || depth < 0) {
console.log('Ad depth paramter required (0 = no simulated ad, 1+ = use backup player for given depth)');
return;
}
postTwitchWorkerMessage('SimulateAds', depth);
};
})(); })();