mirror of
https://github.com/pixeltris/TwitchAdSolutions.git
synced 2025-04-29 22:24:29 +02:00
- Change notify-strip and notify-reload algorithms to handle midrolls better and hopefully fix audio desync - Fetch native resolution earlier - Enforce site player type to avoid issues on embedded sites - Removed unused / broken scripts - Improve readme and full list info - Experimentally testing rolling device id, may remove
This commit is contained in:
parent
ebef7c16a0
commit
f37af09192
46
README.md
46
README.md
@ -6,7 +6,7 @@ This repo aims to provide multiple solutions for blocking Twitch ads.
|
||||
|
||||
M3U8 proxies (or full proxies / VPNs) are the most reliable way of avoiding ads.
|
||||
|
||||
- `TTV.LOL` - [chrome](https://chrome.google.com/webstore/detail/ttv-lol/ofbbahodfeppoklmgjiokgfdgcndngjm) / [firefox](https://addons.mozilla.org/en-US/firefox/addon/ttv-lol/) / [code](https://github.com/TTV-LOL/extensions)
|
||||
- `TTV LOL` - [chrome](https://chrome.google.com/webstore/detail/ttv-lol/ofbbahodfeppoklmgjiokgfdgcndngjm) / [firefox](https://addons.mozilla.org/en-US/firefox/addon/ttv-lol/) / [code](https://github.com/TTV-LOL/extensions)
|
||||
- `Purple AdBlock` - [chrome](https://chrome.google.com/webstore/detail/purple-adblock/lkgcfobnmghhbhgekffaadadhmeoindg) / [firefox](https://addons.mozilla.org/en-US/firefox/addon/purpleadblock/) / [code](https://github.com/arthurbolsoni/Purple-adblock/)
|
||||
|
||||
Alternatively:
|
||||
@ -17,14 +17,14 @@ Alternatively:
|
||||
- `ttv-tools` - [firefox (manual install)](https://github.com/Nerixyz/ttv-tools/releases) / [code](https://github.com/Nerixyz/ttv-tools)
|
||||
- `notify-strip` - see below
|
||||
|
||||
[Read this for more info.](other-solutions.md)
|
||||
[Read this for a full list and descriptions.](full-list.md)
|
||||
|
||||
## Current solutions
|
||||
## Scripts
|
||||
|
||||
**There are better / easier to use methods in the above** `Recommendations`.
|
||||
|
||||
*Don't combine these scripts with other Twitch specific ad blockers.*
|
||||
|
||||
**There are more suitable / easier to use methods in the above** `Recommendations`.
|
||||
|
||||
- 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))
|
||||
- Ad segments are replaced by low resolution stream segments.
|
||||
- Notifies Twitch that ads were "watched" (reduces preroll ad frequency).
|
||||
@ -36,46 +36,14 @@ Alternatively:
|
||||
- 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.
|
||||
- The stream is 480p for the duration of the stream.
|
||||
- mute-black ([ublock](https://github.com/pixeltris/TwitchAdSolutions/raw/master/mute-black/mute-black-ublock-origin.js) / [userscript](https://github.com/pixeltris/TwitchAdSolutions/raw/master/mute-black/mute-black.user.js))
|
||||
- Ads are muted / blacked out for the duration of the ad.
|
||||
- You might see tiny bits of the ad.
|
||||
|
||||
## Applying a solution (uBlock Origin)
|
||||
## Applying a script (uBlock Origin)
|
||||
|
||||
- Navigate to the uBlock Origin Dashboard (the extension options)
|
||||
- Under the `My filters` tab add `twitch.tv##+js(twitch-videoad)`.
|
||||
- Under the `Settings` tab, enable `I am an advanced user`, then click the cog that appears. Modify the value of `userResourcesLocation` from `unset` to the full url of the solution you wish to use (if a url is already in use, add a space after the existing url). e.g. `userResourcesLocation https://github.com/pixeltris/TwitchAdSolutions/raw/master/notify-strip/notify-strip-ublock-origin.js`
|
||||
- To ensure uBlock Origin loads the script I recommend that you disable/enable the uBlock Origin extension (or restart your browser).
|
||||
|
||||
## Applying a solution (userscript)
|
||||
## Applying a script (userscript)
|
||||
|
||||
- Viewing one of the userscript files should prompt the given script to be added.
|
||||
|
||||
## Other solutions / projects
|
||||
|
||||
For a more detailed description of the following please refer to [this](other-solutions.md).
|
||||
|
||||
- https://github.com/odensc/ttv-ublock (extension - purple screen may display every 10-15 mins)
|
||||
- https://github.com/Nerixyz/ttv-tools (Firefox extension)
|
||||
- https://github.com/LeonHeidelbach/ttv_adEraser (extension)
|
||||
- https://github.com/instance01/Twitch-HLS-AdBlock (extension)
|
||||
- https://github.com/Wilkolicious/twitchAdSkip (UserScript + FrankerFaceZ)
|
||||
- https://gist.github.com/simple-hacker/ddd81964b3e8bca47e0aead5ad19a707 (UserScript + FrankerFaceZ(optional))
|
||||
- https://greasyfork.org/en/scripts/415412-twitch-refresh-on-advert/code (UserScript + FrankerFaceZ(optional))
|
||||
- [Alternate Player for Twitch.tv](https://chrome.google.com/webstore/detail/bhplkbgoehhhddaoolmakpocnenplmhf) - [code](https://robwu.nl/crxviewer/?crx=bhplkbgoehhhddaoolmakpocnenplmhf&qf=player.js) (extension)
|
||||
- https://github.com/TTV-LOL/extensions (extension)
|
||||
- https://github.com/arthurbolsoni/Purple-adblock (extension)
|
||||
- [Video Ad-Block, for Twitch](https://github.com/saucettv/VideoAdBlockForTwitch) - [chrome](https://chrome.google.com/webstore/detail/video-ad-block-for-twitch/kgeglempfkhalebjlogemlmeakondflc) / [firefox](https://addons.mozilla.org/en-US/firefox/addon/video-ad-block-for-twitch)
|
||||
|
||||
---
|
||||
|
||||
- https://github.com/streamlink/streamlink (desktop application)
|
||||
- [multiChat for Twitch](https://play.google.com/store/apps/details?id=org.mchatty) (android app)
|
||||
- https://twitchls.com/ (external site - purple screen may display every 10-15 mins)
|
||||
- [Use a VPN targeting a region without ads](https://reddit.com/r/Twitch/comments/kisdsy/i_did_a_little_test_regarding_ads_on_twitch_and/)
|
||||
|
||||
## NOTE/TODO
|
||||
|
||||
NOTE: Many of these solutions could do with improvements.
|
||||
TODO: Test midroll ads.
|
||||
TODO: More testing in general.
|
||||
|
41
full-list.md
Normal file
41
full-list.md
Normal file
@ -0,0 +1,41 @@
|
||||
## Web browser extensions
|
||||
|
||||
- `TTV LOL` - [chrome](https://chrome.google.com/webstore/detail/ttv-lol/ofbbahodfeppoklmgjiokgfdgcndngjm) / [firefox](https://addons.mozilla.org/en-US/firefox/addon/ttv-lol/) / [code](https://github.com/TTV-LOL/extensions)
|
||||
- Uses a proxy on the main m3u8 file to get a stream without ads.
|
||||
- `Purple AdBlock` - [chrome](https://chrome.google.com/webstore/detail/purple-adblock/lkgcfobnmghhbhgekffaadadhmeoindg) / [firefox](https://addons.mozilla.org/en-US/firefox/addon/purpleadblock/) / [code](https://github.com/arthurbolsoni/Purple-adblock/)
|
||||
- Uses a proxy on the main m3u8 file to get a stream without ads.
|
||||
- `Video Ad-Block, for Twitch` - [chrome](https://chrome.google.com/webstore/detail/video-ad-block-for-twitch/kgeglempfkhalebjlogemlmeakondflc) / [firefox](https://addons.mozilla.org/en-US/firefox/addon/video-ad-block-for-twitch/) / [code](https://github.com/saucettv/VideoAdBlockForTwitch)
|
||||
- Replaces ad segments with ad-free segments (at either 480p or 1080p). Afterwards it invokes a pause/play to resync the player which then continues normally.
|
||||
- `Alternate Player for Twitch.tv` - [chrome](https://chrome.google.com/webstore/detail/alternate-player-for-twit/bhplkbgoehhhddaoolmakpocnenplmhf) / [firefox](https://addons.mozilla.org/en-US/firefox/addon/twitch_5/)
|
||||
- Removes ad segments (no playback until ad-free stream).
|
||||
- `ttv_adEraser` - [chrome](https://chrome.google.com/webstore/detail/ttv-aderaser/pjnopimdnmhiaanhjfficogijajbhjnc) / [firefox (manual install)](https://github.com/LeonHeidelbach/ttv_adEraser#mozilla-firefox) / [code](https://github.com/LeonHeidelbach/ttv_adEraser)
|
||||
- Switches to the `embed` player when there's ads. May display purple screen if both ads and purple screen show at the same time?
|
||||
- `ttv-tools` - [firefox (manual install)](https://github.com/Nerixyz/ttv-tools/releases) / [code](https://github.com/Nerixyz/ttv-tools)
|
||||
- Removes ad segments (no playback until ad-free stream).
|
||||
- `ttv-ublock` - [chrome](https://chrome.google.com/webstore/detail/ttv-ad-block/kndhknfnihidhcfnaacnndbolonbimai) / [firefox](https://addons.mozilla.org/en-US/firefox/addon/ttv-adblock/) / [code](https://github.com/odensc/ttv-ublock)
|
||||
- Switches to the `embed` player at a network level. No ads but Twitch detects this and may display a purple screen every 10-15 mins asking the user to remove ad blockers (depends on time of day).
|
||||
- `Twitch-HLS-AdBlock` - [chrome / firefox (manual install)](https://github.com/instance01/Twitch-HLS-AdBlock#installation) / [code](https://github.com/instance01/Twitch-HLS-AdBlock)
|
||||
- Removes ad segments (no playback until ad-free stream).
|
||||
|
||||
## Web browser scripts (uBlock Origin / userscript)
|
||||
|
||||
- https://github.com/pixeltris/TwitchAdSolutions#Scripts
|
||||
- A few scripts using different techniques.
|
||||
- https://github.com/Wilkolicious/twitchAdSkip
|
||||
- https://gist.github.com/simple-hacker/ddd81964b3e8bca47e0aead5ad19a707/
|
||||
- https://greasyfork.org/en/scripts/415412-twitch-refresh-on-advert/code
|
||||
- Reloads the player (or page) when it detects the ad banner in DOM.
|
||||
|
||||
## Applications / third party websites
|
||||
- `streamlink` - [code](https://github.com/streamlink/streamlink) / [website](https://streamlink.github.io/streamlink-twitch-gui/)
|
||||
- Removes ad segments (no playback until ad-free stream).
|
||||
- `multiChat for Twitch` - [android](https://play.google.com/store/apps/details?id=org.mchatty)
|
||||
- Unsure how this one blocks ads, but it claims that it does.
|
||||
- https://twitchls.com/
|
||||
- Uses the `embed` player. Purple screen may display every 10-15 mins.
|
||||
- https://reddit.com/r/Twitch/comments/kisdsy/i_did_a_little_test_regarding_ads_on_twitch_and/
|
||||
- Some countries don't get ads. A simple VPN/VPS could be used to block ads by proxying the m3u8 without having to proxy all your traffic (just the initial m3u8).
|
||||
|
||||
## Additional lists
|
||||
|
||||
- https://github.com/saucettv/WorkingTwitchAdBlockers
|
File diff suppressed because it is too large
Load Diff
@ -1 +0,0 @@
|
||||
OPT_MODE_MUTE_BLACK true
|
File diff suppressed because it is too large
Load Diff
@ -3,66 +3,36 @@ twitch-videoad.js application/javascript
|
||||
if ( /(^|\.)twitch\.tv$/.test(document.location.hostname) === false ) { return; }
|
||||
function declareOptions(scope) {
|
||||
// Options / globals
|
||||
scope.OPT_MODE_MUTE_BLACK = false;
|
||||
scope.OPT_MODE_VIDEO_SWAP = false;
|
||||
scope.OPT_MODE_LOW_RES = false;
|
||||
scope.OPT_MODE_EMBED = false;
|
||||
scope.OPT_MODE_STRIP_AD_SEGMENTS = false;
|
||||
scope.OPT_ROLLING_DEVICE_ID = true;
|
||||
scope.OPT_MODE_STRIP_AD_SEGMENTS = true;
|
||||
scope.OPT_MODE_NOTIFY_ADS_WATCHED = true;
|
||||
scope.OPT_MODE_NOTIFY_ADS_WATCHED_INITIAL_ATTEMPTS = 0;
|
||||
scope.OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST = false;
|
||||
scope.OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST_AND_RELOAD = false;
|
||||
scope.OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST_EXPECTED_DURATION = 10000;// In milliseconds
|
||||
scope.OPT_MODE_NOTIFY_ADS_WATCHED_MIN_REQUESTS = true;
|
||||
scope.OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT = true;
|
||||
scope.OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT_DELAY = 0;
|
||||
scope.OPT_MODE_PROXY_M3U8 = '';
|
||||
scope.OPT_MODE_PROXY_M3U8_OBFUSCATED = false;
|
||||
scope.OPT_MODE_PROXY_M3U8_FULL_URL = false;
|
||||
scope.OPT_MODE_PROXY_M3U8_PARTIAL_URL = false;
|
||||
scope.OPT_VIDEO_SWAP_PLAYER_TYPE = 'picture-by-picture';
|
||||
scope.OPT_BACKUP_PLAYER_TYPE = 'picture-by-picture';
|
||||
scope.OPT_BACKUP_PLAYER_TYPE = 'picture-by-picture';//'picture-by-picture';'thunderdome';
|
||||
scope.OPT_REGULAR_PLAYER_TYPE = 'site';
|
||||
scope.OPT_INITIAL_M3U8_ATTEMPTS = 1;
|
||||
scope.OPT_ACCESS_TOKEN_PLAYER_TYPE = '';
|
||||
scope.OPT_ACCESS_TOKEN_TEMPLATE = true;
|
||||
scope.OPT_ACCESS_TOKEN_PLAYER_TYPE = 'site';
|
||||
scope.AD_SIGNIFIER = 'stitched-ad';
|
||||
scope.LIVE_SIGNIFIER = ',live';
|
||||
scope.CLIENT_ID = 'kimne78kx3ncx6brgo4mv6wki5h1ko';
|
||||
// Modify options based on mode
|
||||
if (!scope.OPT_ACCESS_TOKEN_PLAYER_TYPE && scope.OPT_MODE_LOW_RES) {
|
||||
scope.OPT_ACCESS_TOKEN_PLAYER_TYPE = 'thunderdome';//480p
|
||||
//scope.OPT_ACCESS_TOKEN_PLAYER_TYPE = 'picture-by-picture';//360p
|
||||
}
|
||||
if (!scope.OPT_ACCESS_TOKEN_PLAYER_TYPE && scope.OPT_MODE_EMBED) {
|
||||
scope.OPT_ACCESS_TOKEN_PLAYER_TYPE = 'embed';
|
||||
}
|
||||
if (scope.OPT_MODE_PROXY_M3U8 && scope.OPT_MODE_PROXY_M3U8_OBFUSCATED) {
|
||||
var newStr = '';
|
||||
scope.OPT_MODE_PROXY_M3U8 = atob(scope.OPT_MODE_PROXY_M3U8);
|
||||
for (var i = 0; i < scope.OPT_MODE_PROXY_M3U8.length; i++) {
|
||||
newStr += String.fromCharCode(scope.OPT_MODE_PROXY_M3U8.charCodeAt(i) ^ scope.CLIENT_ID.charCodeAt(i % scope.CLIENT_ID.length));
|
||||
}
|
||||
scope.OPT_MODE_PROXY_M3U8 = newStr;
|
||||
}
|
||||
// These are only really for Worker scope...
|
||||
scope.StreamInfos = [];
|
||||
scope.StreamInfosByUrl = [];
|
||||
scope.CurrentChannelNameFromM3U8 = null;
|
||||
// Need this in both scopes. Window scope needs to update this to worker scope.
|
||||
scope.gql_device_id = null;
|
||||
scope.gql_device_id_rolling = '';
|
||||
// Rolling device id crap... TODO: improve this
|
||||
var charTable = []; for (var i = 97; i <= 122; i++) { charTable.push(String.fromCharCode(i)); } for (var i = 65; i <= 90; i++) { charTable.push(String.fromCharCode(i)); } for (var i = 48; i <= 57; i++) { charTable.push(String.fromCharCode(i)); }
|
||||
var bs = 'eVI6jx47kJvCFfFowK86eVI6jx47kJvC';
|
||||
var di = (new Date()).getYear() + (new Date()).getMonth() + ((new Date()).getDate() / 7) | 0;
|
||||
for (var i = 0; i < bs.length; i++) {
|
||||
scope.gql_device_id_rolling += charTable[(bs.charCodeAt(i) ^ di) % charTable.length];
|
||||
}
|
||||
}
|
||||
declareOptions(window);
|
||||
////////////////////////////////////
|
||||
// stream swap / stream mute
|
||||
////////////////////////////////////
|
||||
var tempVideo = null;// A temporary video container to hold a lower resolution stream without ads
|
||||
var disabledVideo = null;// The original video element (disabled for the duration of the ad)
|
||||
var originalVolume = 0;// The volume of the original video element
|
||||
var foundAdContainer = false;// Have ad containers been found (the clickable ad)
|
||||
var foundAdBanner = false;// Is the ad banner visible (top left of screen)
|
||||
////////////////////////////////////
|
||||
var notifyAdsWatchedReloadNextTime = 0;
|
||||
var twitchMainWorker = null;
|
||||
const oldWorker = window.Worker;
|
||||
window.Worker = class Worker extends oldWorker {
|
||||
@ -78,9 +48,6 @@ twitch-videoad.js application/javascript
|
||||
}
|
||||
var newBlobStr = `
|
||||
${processM3U8.toString()}
|
||||
${getSegmentInfos.toString()}
|
||||
${getSegmentInfosLines.toString()}
|
||||
${getFinalSegUrl.toString()}
|
||||
${hookWorkerFetch.toString()}
|
||||
${declareOptions.toString()}
|
||||
${getAccessToken.toString()}
|
||||
@ -99,28 +66,34 @@ twitch-videoad.js application/javascript
|
||||
`
|
||||
super(URL.createObjectURL(new Blob([newBlobStr])));
|
||||
twitchMainWorker = this;
|
||||
var adDiv = null;
|
||||
this.onmessage = function(e) {
|
||||
// NOTE: Removed adDiv caching as '.video-player' can change between streams?
|
||||
if (e.data.key == 'UboShowAdBanner') {
|
||||
if (adDiv == null) { adDiv = getAdDiv(); }
|
||||
adDiv.P.textContent = 'Waiting for' + (e.data.isMidroll ? ' midroll' : '') + ' ads to finish...';
|
||||
var adDiv = getAdDiv();
|
||||
if (adDiv != null) {
|
||||
adDiv.P.textContent = 'Blocking' + (e.data.isMidroll ? ' midroll' : '') + ' ads...';
|
||||
adDiv.style.display = 'block';
|
||||
}
|
||||
}
|
||||
else if (e.data.key == 'UboHideAdBanner') {
|
||||
if (adDiv == null) { adDiv = getAdDiv(); }
|
||||
var adDiv = getAdDiv();
|
||||
if (adDiv != null) {
|
||||
adDiv.style.display = 'none';
|
||||
}
|
||||
}
|
||||
else if (e.data.key == 'UboFoundAdSegment') {
|
||||
onFoundAd(e.data.hasLiveSeg, e.data.streamM3u8);
|
||||
onFoundAd(e.data.isMidroll, e.data.streamM3u8);
|
||||
}
|
||||
else if (e.data.key == 'UboChannelNameM3U8Changed') {
|
||||
//console.log('M3U8 channel name changed to ' + e.data.value);
|
||||
notifyAdsWatchedReloadNextTime = 0;
|
||||
}
|
||||
else if (e.data.key == 'UboReloadPlayer') {
|
||||
reloadTwitchPlayer();
|
||||
}
|
||||
else if (e.data.key == 'UboPauseResumePlayer') {
|
||||
reloadTwitchPlayer(false, true);
|
||||
}
|
||||
else if (e.data.key == 'UboSeekPlayer') {
|
||||
reloadTwitchPlayer(true);
|
||||
}
|
||||
}
|
||||
@ -148,164 +121,33 @@ twitch-videoad.js application/javascript
|
||||
req.send();
|
||||
return req.responseText.split("'")[1];
|
||||
}
|
||||
function getSegmentInfosLines(streamInfo, lines) {
|
||||
var result = {};
|
||||
result.segs = [];
|
||||
result.targetDuration = 0;
|
||||
result.elapsedSecs = 0;
|
||||
result.totalSecs = 0;
|
||||
result.hasPrefetch = false;
|
||||
result.hasLiveBeforeAd = true;// This most likely means a midroll (live segments before ad segments)
|
||||
var hasLive = false;
|
||||
for (var i = 0; i < lines.length; i++) {
|
||||
var line = lines[i];
|
||||
if (line.startsWith('#EXT-X-TARGETDURATION')) {
|
||||
result.targetDuration = parseInt(line.split(':')[1]);
|
||||
}
|
||||
if (line.startsWith('#EXT-X-TWITCH-ELAPSED-SECS')) {
|
||||
result.elapsedSecs = line.split(':')[1];
|
||||
}
|
||||
if (line.startsWith('#EXT-X-TWITCH-TOTAL-SECS')) {
|
||||
result.totalSecs = line.split(':')[1];
|
||||
}
|
||||
if (line.startsWith('#EXT-X-DATERANGE')) {
|
||||
var attr = parseAttributes(line);
|
||||
if (attr['CLASS'] && attr['CLASS'].includes('stitched-ad')) {
|
||||
streamInfo.IsMidroll = attr['X-TV-TWITCH-AD-ROLL-TYPE'] == 'MIDROLL';
|
||||
}
|
||||
}
|
||||
if (line.startsWith('http')) {
|
||||
var segInfo = {};
|
||||
segInfo.urlLineIndex = i;
|
||||
segInfo.urlLine = lines[i];
|
||||
segInfo.url = lines[i];
|
||||
segInfo.isPrefetch = false;
|
||||
if (i >= 1 && lines[i - 1].startsWith('#EXTINF')) {
|
||||
//#EXTINF:2.002,DCM|2435256
|
||||
//#EXTINF:2.002,Amazon|8493257483
|
||||
//#EXTINF:2.000,live
|
||||
var splitted = lines[i - 1].split(':')[1].split(',');
|
||||
segInfo.extInfLineIndex = i - 1;
|
||||
segInfo.extInfLine = lines[i - 1];
|
||||
segInfo.extInfLen = splitted[0];//2.000 (can be between 2.000 -> 5.000?)
|
||||
segInfo.extInfType = splitted[1].split('|')[0];//live / Amazon / DCM
|
||||
segInfo.isAd = segInfo.extInfType != 'live';
|
||||
if (segInfo.isAd && !hasLive && result.hasLiveBeforeAd) {
|
||||
result.hasLiveBeforeAd = false;
|
||||
}
|
||||
hasLive = !segInfo.isAd;
|
||||
}
|
||||
if (i >= 2 && lines[i - 2].startsWith('#EXT-X-PROGRAM-DATE-TIME')) {
|
||||
segInfo.dateTimeLineIndex = i - 2;
|
||||
segInfo.dateTimeLine = lines[i - 2];
|
||||
segInfo.dateTime = new Date(lines[i - 2].substr(lines[i - 2].indexOf(':')));
|
||||
}
|
||||
result.segs.push(segInfo);
|
||||
}
|
||||
if (lines[i].startsWith('#EXT-X-TWITCH-PREFETCH:')) {
|
||||
var segInfo = {};
|
||||
segInfo.urlLineIndex = i;
|
||||
segInfo.urlLine = lines[i];
|
||||
segInfo.url = lines[i].substr(lines[i].indexOf(':') + 1);
|
||||
segInfo.isPrefetch = true;
|
||||
result.hasPrefetch = true;
|
||||
result.segs.push(segInfo);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
function getSegmentInfos(streamInfo, lines, backupLines) {
|
||||
var result = {};
|
||||
result.segs = [];
|
||||
result.main = getSegmentInfosLines(streamInfo, lines);
|
||||
result.backup = getSegmentInfosLines(streamInfo, backupLines);
|
||||
// Push all backup segments first
|
||||
for (var i = 0; i < result.backup.segs.length; i++) {
|
||||
var seg = {};
|
||||
seg.backup = result.backup.segs[i];
|
||||
result.segs.push(seg);
|
||||
}
|
||||
// Insert any live main segments
|
||||
// NOTE: We might want to make sure we aren't writing over previously established backup segments (make use of streamInfo.SegmentCache)
|
||||
// NOTE: Midroll ads will result in a very long backup stream. Better logic required for midrolls.
|
||||
for (var i = result.main.segs.length - 1, j = result.segs.length - 1; i >= 0 && j >= 0; i--, j--) {
|
||||
while (result.main.segs[i].isPrefetch) {
|
||||
if (result.segs[j].backup.isPrefetch) {
|
||||
result.segs[j].main = result.main.segs[i];
|
||||
j--;
|
||||
}
|
||||
i--;
|
||||
}
|
||||
if (!result.main.segs[i].isAd) {
|
||||
result.segs[j].main = result.main.segs[i];
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Set the segment cache (currently unused)
|
||||
streamInfo.SegmentCache.length = 0;
|
||||
for (var i = 0; i < result.segs.length; i++) {
|
||||
if (result.segs[i].main != null) {
|
||||
streamInfo.SegmentCache[result.segs[i].main.url] = result.segs[i];
|
||||
}
|
||||
if (result.segs[i].backup != null) {
|
||||
streamInfo.SegmentCache[result.segs[i].backup.url] = result.segs[i];
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
function getFinalSegUrl(lines) {
|
||||
for (var i = lines.length - 1; i >= 0; i--) {
|
||||
if (lines[i].startsWith("http")) {
|
||||
return lines[i];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
async function processM3U8(url, textStr, realFetch) {
|
||||
var streamInfo = StreamInfosByUrl[url];
|
||||
if (streamInfo == null) {
|
||||
console.log('Unknown stream url ' + url);
|
||||
postMessage({key:'UboHideAdBanner'});
|
||||
return textStr;
|
||||
}
|
||||
if (!OPT_MODE_STRIP_AD_SEGMENTS) {
|
||||
return textStr;
|
||||
}
|
||||
var haveAdTags = textStr.includes(AD_SIGNIFIER);
|
||||
if (OPT_MODE_STRIP_AD_SEGMENTS) {
|
||||
var si = StreamInfosByUrl[url];
|
||||
if (si != null) {
|
||||
var lines = textStr.replace('\r', '').split('\n');
|
||||
for (var i = 0; i < lines.length; i++) {
|
||||
if (lines[i].startsWith('#EXT-X-MEDIA-SEQUENCE:')) {
|
||||
var oldRealSeq = si.RealSeqNumber;
|
||||
si.RealSeqNumber = parseInt(/#EXT-X-MEDIA-SEQUENCE:([0-9]*)/.exec(lines[i])[1]);
|
||||
if (!haveAdTags && si.FakeSeqNumber > 0) {
|
||||
/*// We have some sequencing issues... for now lets pause/play and stop modifying sequence.
|
||||
// TODO: Improve sequencing (determine if the m3u8 urls have actually changed)
|
||||
si.FakeSeqNumber = 0;
|
||||
si.BackupSeqNumber = -1;
|
||||
postMessage({key:'UboPauseResumePlayer'});*/
|
||||
// We previously modified the sequence number, we need to keep doing so (alternatively pause/playing might work better
|
||||
var finalSegUrl = getFinalSegUrl(lines);
|
||||
if (finalSegUrl != si.FinalSegUrl) {
|
||||
si.FinalSegUrl = finalSegUrl;
|
||||
// TODO: Maybe only do the jump check if there was an ad recently? (within the last 5 m3u8 requests)
|
||||
var jump = Math.max(0, si.RealSeqNumber - oldRealSeq);
|
||||
if (jump <= 3) {
|
||||
si.FakeSeqNumber += Math.max(0, si.RealSeqNumber - oldRealSeq);
|
||||
} else if (jump > 0) {
|
||||
si.FakeSeqNumber++;
|
||||
}
|
||||
}
|
||||
lines[i] = '#EXT-X-MEDIA-SEQUENCE:' + si.FakeSeqNumber;
|
||||
console.log('No ad, but modifying sequence realSeq:' + si.RealSeqNumber + ' fakeSeq:' + si.FakeSeqNumber);
|
||||
}
|
||||
if (haveAdTags) {
|
||||
var currentResolution = null;
|
||||
for (const [resUrl, resName] of Object.entries(streamInfo.Urls)) {
|
||||
if (resUrl == url) {
|
||||
currentResolution = resName;
|
||||
//console.log(resName);
|
||||
break;
|
||||
}
|
||||
}
|
||||
textStr = lines.join('\n');
|
||||
}
|
||||
}
|
||||
if (haveAdTags) {
|
||||
var si = StreamInfosByUrl[url];
|
||||
if (OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST && si != null && !si.NotifyObservedNoAds) {
|
||||
// We only really know it's fully processed when we no loger see ads
|
||||
// NOTE: We probably shouldn't keep sending these requests. Possibly start sending them after expected ad duration?
|
||||
streamInfo.HadAds = true;
|
||||
streamInfo.IsMidroll = textStr.includes('"MIDROLL"') || textStr.includes('"midroll"');
|
||||
postMessage({key:'UboShowAdBanner',isMidroll:streamInfo.IsMidroll});
|
||||
// Notify ads "watched" TODO: Keep crafting these requests even after ad tags are gone as sometimes it stops too early.
|
||||
if (OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST && streamInfo != null && !streamInfo.NotifyObservedNoAds) {
|
||||
var noAds = false;
|
||||
var encodingsM3u8Response = await realFetch(si.RootM3U8Url);
|
||||
var encodingsM3u8Response = await realFetch(streamInfo.RootM3U8Url);
|
||||
if (encodingsM3u8Response.status === 200) {
|
||||
var encodingsM3u8 = await encodingsM3u8Response.text();
|
||||
var streamM3u8Url = encodingsM3u8.match(/^https:.*\.m3u8$/m)[0];
|
||||
@ -315,35 +157,76 @@ twitch-videoad.js application/javascript
|
||||
console.log('Notify ad watched. Response has ads: ' + !noAds);
|
||||
}
|
||||
}
|
||||
if (si.NotifyFirstTime == 0) {
|
||||
si.NotifyFirstTime = Date.now();
|
||||
if (streamInfo.NotifyFirstTime == 0) {
|
||||
streamInfo.NotifyFirstTime = Date.now();
|
||||
}
|
||||
if (noAds && !si.NotifyObservedNoAds && Date.now() >= si.NotifyFirstTime + OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST_EXPECTED_DURATION) {
|
||||
si.NotifyObservedNoAds = true;
|
||||
}
|
||||
if (noAds && OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST_AND_RELOAD && Date.now() >= si.NotifyFirstTime + OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST_EXPECTED_DURATION) {
|
||||
console.log('Reload player');
|
||||
postMessage({key:'UboHideAdBanner'});
|
||||
postMessage({key:'UboReloadPlayer'});
|
||||
return '';
|
||||
if (noAds && !streamInfo.NotifyObservedNoAds && Date.now() >= streamInfo.NotifyFirstTime + OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST_EXPECTED_DURATION) {
|
||||
streamInfo.NotifyObservedNoAds = true;
|
||||
}
|
||||
}
|
||||
postMessage({
|
||||
key: 'UboFoundAdSegment',
|
||||
hasLiveSeg: textStr.includes(LIVE_SIGNIFIER),
|
||||
isMidroll: streamInfo.IsMidroll,
|
||||
streamM3u8: textStr
|
||||
});
|
||||
if (!streamInfo.IsMidroll && OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT) {
|
||||
return '';
|
||||
}
|
||||
if (!OPT_MODE_STRIP_AD_SEGMENTS) {
|
||||
return textStr;
|
||||
// NOTE: Got a bit of code duplication here, merge Backup/BackupReg in some form.
|
||||
// See if there's clean stream url to fetch (only do this after we have a regular backup, this should save time and you're unlikely to find a clean stream on first request)
|
||||
try {
|
||||
if (streamInfo.BackupRegRes != currentResolution) {
|
||||
streamInfo.BackupRegRes = null;
|
||||
streamInfo.BackupRegUrl = null;
|
||||
}
|
||||
if (haveAdTags) {
|
||||
var streamInfo = StreamInfosByUrl[url];
|
||||
if (streamInfo == null) {
|
||||
console.log('Unknown stream url ' + url);
|
||||
postMessage({key:'UboHideAdBanner'});
|
||||
return textStr;
|
||||
if (currentResolution && streamInfo.BackupRegUrl == null && (streamInfo.BackupFailed || streamInfo.BackupUrl != null)) {
|
||||
var accessTokenResponse = await getAccessToken(streamInfo.ChannelName, OPT_REGULAR_PLAYER_TYPE);
|
||||
if (accessTokenResponse.status === 200) {
|
||||
var accessToken = await accessTokenResponse.json();
|
||||
var urlInfo = new URL('https://usher.ttvnw.net/api/channel/hls/' + streamInfo.ChannelName + '.m3u8' + streamInfo.RootM3U8Params);
|
||||
urlInfo.searchParams.set('sig', accessToken.data.streamPlaybackAccessToken.signature);
|
||||
urlInfo.searchParams.set('token', accessToken.data.streamPlaybackAccessToken.value);
|
||||
var encodingsM3u8Response = await realFetch(urlInfo.href);
|
||||
if (encodingsM3u8Response.status === 200) {
|
||||
var encodingsM3u8 = await encodingsM3u8Response.text();
|
||||
var encodingsLines = encodingsM3u8.replace('\r', '').split('\n');
|
||||
for (var i = 0; i < encodingsLines.length; i++) {
|
||||
if (!encodingsLines[i].startsWith('#') && encodingsLines[i].includes('.m3u8')) {
|
||||
if (i > 0 && encodingsLines[i - 1].startsWith('#EXT-X-STREAM-INF')) {
|
||||
var res = parseAttributes(encodingsLines[i - 1])['RESOLUTION'];
|
||||
if (res && res == currentResolution) {
|
||||
streamInfo.BackupRegUrl = encodingsLines[i];
|
||||
streamInfo.BackupRegRes = currentResolution;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (streamInfo.BackupRegUrl != null) {
|
||||
var backupM3u8 = null;
|
||||
var backupM3u8Response = await realFetch(streamInfo.BackupRegUrl);
|
||||
if (backupM3u8Response.status == 200) {
|
||||
backupM3u8 = await backupM3u8Response.text();
|
||||
}
|
||||
if (backupM3u8 != null && !backupM3u8.includes(AD_SIGNIFIER)) {
|
||||
return backupM3u8;
|
||||
} else {
|
||||
//console.log('Try use regular resolution failed');
|
||||
streamInfo.BackupRegRes = null;
|
||||
streamInfo.BackupRegUrl = null;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
streamInfo.BackupRegRes = null;
|
||||
streamInfo.BackupRegUrl = null;
|
||||
console.log('Fetching backup (regular resolution) m3u8 failed');
|
||||
console.log(err);
|
||||
}
|
||||
// Fetch backup url
|
||||
try {
|
||||
if (!streamInfo.BackupFailed && streamInfo.BackupUrl == null) {
|
||||
// NOTE: We currently don't fetch the oauth_token. You wont be able to access private streams like this.
|
||||
streamInfo.BackupFailed = true;
|
||||
@ -375,87 +258,34 @@ twitch-videoad.js application/javascript
|
||||
}
|
||||
}
|
||||
var backupM3u8 = null;
|
||||
if (streamInfo.BackupUrl != null) {
|
||||
var backupM3u8Response = await realFetch(streamInfo.BackupUrl);
|
||||
if (backupM3u8Response.status == 200) {
|
||||
backupM3u8 = await backupM3u8Response.text();
|
||||
}
|
||||
if (backupM3u8 != null && !backupM3u8.includes(AD_SIGNIFIER)) {
|
||||
return backupM3u8;
|
||||
} else {
|
||||
console.log('Backup m3u8 failed with ' + backupM3u8Response.status);
|
||||
}
|
||||
} catch (err) {
|
||||
console.log('Fetching backup m3u8 failed');
|
||||
console.log(err);
|
||||
}
|
||||
var lines = textStr.replace('\r', '').split('\n');
|
||||
var newLines = [];
|
||||
if (backupM3u8 != null) {
|
||||
var backupLines = backupM3u8.replace('\r', '').split('\n');
|
||||
var segInfos = getSegmentInfos(streamInfo, lines, backupLines);
|
||||
newLines.push('#EXTM3U');
|
||||
newLines.push('#EXT-X-VERSION:3');
|
||||
newLines.push('#EXT-X-TARGETDURATION:' + segInfos.backup.targetDuration);
|
||||
newLines.push('');//#EXT-X-MEDIA-SEQUENCE:' + streamInfo.FakeSeqNumber);
|
||||
// The following will could cause issues when we stop stripping segments
|
||||
//newLines.push('#EXT-X-TWITCH-ELAPSED-SECS:' + streamInfo.backup.elapsedSecs);
|
||||
//newLines.push('#EXT-X-TWITCH-TOTAL-SECS:' + streamInfo.backup.totalSecs);
|
||||
var pushedLiveSegs = 0;
|
||||
var pushedBackupSegs = 0;
|
||||
var pushedPrefetchSegs = 0;
|
||||
for (var i = 0; i < segInfos.segs.length; i++) {
|
||||
var seg = segInfos.segs[i];
|
||||
var segData = null;
|
||||
if (seg.main != null && !seg.main.isAd) {
|
||||
pushedLiveSegs++;
|
||||
segData = seg.main;
|
||||
} else if (seg.backup != null) {
|
||||
pushedBackupSegs++;
|
||||
segData = seg.backup;
|
||||
// Backups failed. Return nothing (this will likely result in spam or player error 2000?).
|
||||
console.log('Ad blocking failed. Stream might break.');
|
||||
return '';
|
||||
}
|
||||
if (segData != null) {
|
||||
if (segData.isPrefetch) {
|
||||
pushedPrefetchSegs++;
|
||||
newLines.push(segData.urlLine);
|
||||
} else {
|
||||
//newLines.push(segData.dateTimeLine);
|
||||
newLines.push(segData.extInfLine);
|
||||
newLines.push(segData.urlLine);
|
||||
}
|
||||
}
|
||||
}
|
||||
var finalSegUrl = getFinalSegUrl(newLines);
|
||||
if (finalSegUrl != streamInfo.FinalSegUrl) {
|
||||
streamInfo.FinalSegUrl = finalSegUrl;
|
||||
streamInfo.FakeSeqNumber++;// We might need something better than this for lager jumps in seq?
|
||||
}
|
||||
newLines[3] = '#EXT-X-MEDIA-SEQUENCE:' + streamInfo.FakeSeqNumber;
|
||||
if (pushedLiveSegs > 0 || pushedBackupSegs > 0) {
|
||||
console.log('liveSegs:' + pushedLiveSegs + ' backupSegs:' + pushedBackupSegs + ' prefetch:' + pushedPrefetchSegs + ' realSeq:' + streamInfo.RealSeqNumber + ' fakeSeq:' + streamInfo.FakeSeqNumber);
|
||||
} else {
|
||||
console.log('TODO: If theres no backup data then we need to provide our own .ts file, otherwise the player will spam m3u8 requests (denial-of-service)');
|
||||
}
|
||||
}
|
||||
textStr = newLines.length > 0 ? newLines.join('\n') : lines.join('\n');
|
||||
//console.log(textStr);
|
||||
if (streamInfo.HadAds) {
|
||||
postMessage({key:'UboSeekPlayer'});
|
||||
streamInfo.HadAds = false;
|
||||
}
|
||||
postMessage({key:'UboHideAdBanner'});
|
||||
return textStr;
|
||||
}
|
||||
function hookWorkerFetch() {
|
||||
var realFetch = fetch;
|
||||
fetch = async function(url, options) {
|
||||
if (typeof url === 'string') {
|
||||
if (OPT_MODE_STRIP_AD_SEGMENTS && url.endsWith('.ts')) {
|
||||
var shownAdBanner = false;
|
||||
for (const [channelName, streamInfo] of Object.entries(StreamInfos)) {
|
||||
var seg = streamInfo.SegmentCache[url];
|
||||
if (seg && !seg.isPrefetch) {
|
||||
if (seg.main == null && seg.backup != null) {
|
||||
shownAdBanner = true;
|
||||
postMessage({key:'UboShowAdBanner',isMidroll:streamInfo.IsMidroll});
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!shownAdBanner) {
|
||||
postMessage({key:'UboHideAdBanner'});
|
||||
}
|
||||
}
|
||||
if (url.endsWith('m3u8')) {
|
||||
return new Promise(function(resolve, reject) {
|
||||
var processAfter = async function(response) {
|
||||
@ -482,26 +312,7 @@ twitch-videoad.js application/javascript
|
||||
});
|
||||
}
|
||||
CurrentChannelNameFromM3U8 = channelName;
|
||||
if (OPT_MODE_PROXY_M3U8) {
|
||||
if (OPT_MODE_PROXY_M3U8_FULL_URL || OPT_MODE_PROXY_M3U8_PARTIAL_URL) {
|
||||
if (OPT_MODE_PROXY_M3U8_FULL_URL) {
|
||||
url = OPT_MODE_PROXY_M3U8 + url;
|
||||
} else {
|
||||
url = OPT_MODE_PROXY_M3U8 + url.split('.m3u8')[0];
|
||||
}
|
||||
if (!OPT_MODE_PROXY_M3U8_OBFUSCATED) {
|
||||
console.log('proxy-m3u8: ' + url);
|
||||
}
|
||||
var opt2 = {};
|
||||
opt2.headers = [];
|
||||
opt2.headers['Access-Control-Allow-Origin'] = '*';// This is to appease the currently set proxy
|
||||
return realFetch(url, opt2);
|
||||
} else {
|
||||
url = OPT_MODE_PROXY_M3U8 + channelName;
|
||||
console.log('proxy-m3u8: ' + url);
|
||||
}
|
||||
}
|
||||
else if (OPT_MODE_STRIP_AD_SEGMENTS) {
|
||||
if (OPT_MODE_STRIP_AD_SEGMENTS) {
|
||||
return new Promise(async function(resolve, reject) {
|
||||
// - First m3u8 request is the m3u8 with the video encodings (360p,480p,720p,etc).
|
||||
// - Second m3u8 request is the m3u8 for the given encoding obtained in the first request. At this point we will know if there's ads.
|
||||
@ -524,13 +335,15 @@ twitch-videoad.js application/javascript
|
||||
}
|
||||
// This might potentially backfire... maybe just add the new urls
|
||||
streamInfo.ChannelName = channelName;
|
||||
streamInfo.Urls = [];
|
||||
streamInfo.Urls = [];// xxx.m3u8 -> "284x160" (resolution)
|
||||
streamInfo.RootM3U8Url = url;
|
||||
streamInfo.RootM3U8Params = (new URL(url)).search;
|
||||
streamInfo.BackupUrl = null;
|
||||
streamInfo.BackupFailed = false;
|
||||
streamInfo.SegmentCache = [];
|
||||
streamInfo.BackupRegUrl = null;
|
||||
streamInfo.BackupRegRes = null;
|
||||
streamInfo.IsMidroll = false;
|
||||
streamInfo.HadAds = false;
|
||||
streamInfo.NotifyFirstTime = 0;
|
||||
streamInfo.NotifyObservedNoAds = false;
|
||||
streamInfo.RealSeqNumber = -1;
|
||||
@ -540,7 +353,13 @@ twitch-videoad.js application/javascript
|
||||
var lines = encodingsM3u8.replace('\r', '').split('\n');
|
||||
for (var i = 0; i < lines.length; i++) {
|
||||
if (!lines[i].startsWith('#') && lines[i].includes('.m3u8')) {
|
||||
streamInfo.Urls.push(lines[i]);
|
||||
streamInfo.Urls[lines[i]] = -1;
|
||||
if (i > 0 && lines[i - 1].startsWith('#EXT-X-STREAM-INF')) {
|
||||
var res = parseAttributes(lines[i - 1])['RESOLUTION'];
|
||||
if (res) {
|
||||
streamInfo.Urls[lines[i]] = res;
|
||||
}
|
||||
}
|
||||
StreamInfosByUrl[lines[i]] = streamInfo;
|
||||
}
|
||||
}
|
||||
@ -581,7 +400,6 @@ twitch-videoad.js application/javascript
|
||||
}
|
||||
function getAccessToken(channelName, playerType, realFetch) {
|
||||
var body = null;
|
||||
if (OPT_ACCESS_TOKEN_TEMPLATE) {
|
||||
var templateQuery = 'query PlaybackAccessToken_Template($login: String!, $isLive: Boolean!, $vodID: ID!, $isVod: Boolean!, $playerType: String!) { streamPlaybackAccessToken(channelName: $login, params: {platform: "web", playerBackend: "mediaplayer", playerType: $playerType}) @include(if: $isLive) { value signature __typename } videoPlaybackAccessToken(id: $vodID, params: {platform: "web", playerBackend: "mediaplayer", playerType: $playerType}) @include(if: $isVod) { value signature __typename }}';
|
||||
body = {
|
||||
operationName: 'PlaybackAccessToken_Template',
|
||||
@ -594,24 +412,6 @@ twitch-videoad.js application/javascript
|
||||
'playerType': playerType
|
||||
}
|
||||
};
|
||||
} else {
|
||||
body = {
|
||||
operationName: 'PlaybackAccessToken',
|
||||
variables: {
|
||||
isLive: true,
|
||||
login: channelName,
|
||||
isVod: false,
|
||||
vodID: '',
|
||||
playerType: playerType
|
||||
},
|
||||
extensions: {
|
||||
persistedQuery: {
|
||||
version: 1,
|
||||
sha256Hash: '0828119ded1c13477966434e15800ff57ddacf13ba1911c129dc2200705b0712',
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
return gqlRequest(body, realFetch);
|
||||
}
|
||||
function gqlRequest(body, realFetch) {
|
||||
@ -621,7 +421,7 @@ twitch-videoad.js application/javascript
|
||||
body: JSON.stringify(body),
|
||||
headers: {
|
||||
'client-id': CLIENT_ID,
|
||||
'X-Device-Id': gql_device_id
|
||||
'X-Device-Id': OPT_ROLLING_DEVICE_ID ? gql_device_id_rolling : gql_device_id
|
||||
}
|
||||
});
|
||||
}
|
||||
@ -691,33 +491,6 @@ twitch-videoad.js application/javascript
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
async function tryNotifyAdsWatchedSigTok(realFetch, i, sig, token) {
|
||||
var tokInfo = JSON.parse(token);
|
||||
var channelName = tokInfo.channel;
|
||||
var urlInfo = new URL('https://usher.ttvnw.net/api/channel/hls/' + channelName + '.m3u8');
|
||||
urlInfo.searchParams.set('sig', sig);
|
||||
urlInfo.searchParams.set('token', token);
|
||||
var encodingsM3u8Response = await realFetch(urlInfo.href);
|
||||
if (encodingsM3u8Response.status === 200) {
|
||||
var encodingsM3u8 = await encodingsM3u8Response.text();
|
||||
var streamM3u8Url = encodingsM3u8.match(/^https:.*\.m3u8$/m)[0];
|
||||
var streamM3u8Response = await realFetch(streamM3u8Url);
|
||||
var streamM3u8 = await streamM3u8Response.text();
|
||||
var res = await tryNotifyAdsWatchedM3U8(streamM3u8);
|
||||
if (i >= 0) {
|
||||
if (res == 1) {
|
||||
console.log("no ad at req " + i);
|
||||
} else {
|
||||
console.log('ad at req ' + i);
|
||||
}
|
||||
}
|
||||
return res;
|
||||
} else {
|
||||
// http error
|
||||
return 2;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
function hookFetch() {
|
||||
var realFetch = window.fetch;
|
||||
window.fetch = function(url, init, ...args) {
|
||||
@ -743,32 +516,12 @@ twitch-videoad.js application/javascript
|
||||
value: gql_device_id
|
||||
});
|
||||
}
|
||||
if (OPT_MODE_NOTIFY_ADS_WATCHED && OPT_MODE_NOTIFY_ADS_WATCHED_INITIAL_ATTEMPTS > 0) {
|
||||
var tok = null, sig = null;
|
||||
if (url.includes('gql') && init && typeof init.body === 'string' && init.body.includes('PlaybackAccessToken')) {
|
||||
return new Promise(async function(resolve, reject) {
|
||||
var response = await realFetch(url, init);
|
||||
if (response.status === 200) {
|
||||
for (var i = 0; i < OPT_MODE_NOTIFY_ADS_WATCHED_INITIAL_ATTEMPTS; i++) {
|
||||
var cloned = response.clone();
|
||||
var responseStr = await cloned.text();
|
||||
var responseData = JSON.parse(responseStr);
|
||||
if (responseData && responseData.data && responseData.data.streamPlaybackAccessToken && responseData.data.streamPlaybackAccessToken.value && responseData.data.streamPlaybackAccessToken.signature) {
|
||||
if (await tryNotifyAdsWatchedSigTok(realFetch, i, responseData.data.streamPlaybackAccessToken.signature, responseData.data.streamPlaybackAccessToken.value) == 1) {
|
||||
resolve(new Response(responseStr));
|
||||
return;
|
||||
if (OPT_ROLLING_DEVICE_ID) {
|
||||
if (typeof init.headers['X-Device-Id'] === 'string') {
|
||||
init.headers['X-Device-Id'] = gql_device_id_rolling;
|
||||
}
|
||||
} else {
|
||||
console.log('malformed');
|
||||
console.log(responseData);
|
||||
break;
|
||||
}
|
||||
}
|
||||
resolve(response);
|
||||
} else {
|
||||
resolve(response);
|
||||
}
|
||||
});
|
||||
if (typeof init.headers['Device-ID'] === 'string') {
|
||||
init.headers['Device-ID'] = gql_device_id_rolling;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -776,148 +529,16 @@ twitch-videoad.js application/javascript
|
||||
return realFetch.apply(this, arguments);
|
||||
}
|
||||
}
|
||||
function onFoundAd(hasLiveSeg, streamM3u8) {
|
||||
if (OPT_MODE_NOTIFY_ADS_WATCHED && OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT && Date.now() >= notifyAdsWatchedReloadNextTime) {
|
||||
function onFoundAd(isMidroll, streamM3u8) {
|
||||
if (OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT && !isMidroll) {
|
||||
console.log('OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT');
|
||||
notifyAdsWatchedReloadNextTime = Date.now() + OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT_DELAY;
|
||||
if (streamM3u8) {
|
||||
tryNotifyAdsWatchedM3U8(streamM3u8);
|
||||
}
|
||||
reloadTwitchPlayer();
|
||||
return;
|
||||
}
|
||||
if (hasLiveSeg) {
|
||||
return;
|
||||
}
|
||||
if (!OPT_MODE_MUTE_BLACK && !OPT_MODE_VIDEO_SWAP) {
|
||||
return;
|
||||
}
|
||||
if (OPT_MODE_VIDEO_SWAP && typeof Hls === 'undefined') {
|
||||
return;
|
||||
}
|
||||
if (!foundAdContainer) {
|
||||
// hide ad contianers
|
||||
var adContainers = document.querySelectorAll('[data-test-selector="sad-overlay"]');
|
||||
for (var i = 0; i < adContainers.length; i++) {
|
||||
adContainers[i].style.display = "none";
|
||||
}
|
||||
foundAdContainer = adContainers.length > 0;
|
||||
}
|
||||
if (disabledVideo) {
|
||||
disabledVideo.volume = 0;
|
||||
} else {
|
||||
//get livestream video element
|
||||
var liveVid = document.getElementsByTagName("video");
|
||||
if (liveVid.length) {
|
||||
disabledVideo = liveVid = liveVid[0];
|
||||
if (!disabledVideo) {
|
||||
return;
|
||||
}
|
||||
//mute
|
||||
originalVolume = liveVid.volume;
|
||||
liveVid.volume = 0;
|
||||
//black out
|
||||
liveVid.style.filter = "brightness(0%)";
|
||||
if (OPT_MODE_VIDEO_SWAP) {
|
||||
var createTempStream = async function() {
|
||||
// Create new video stream TODO: Do this with callbacks
|
||||
var channelName = window.location.pathname.substr(1);// TODO: Better way of determining the channel name
|
||||
var tempM3u8Url = null;
|
||||
var accessTokenResponse = await getAccessToken(channelName, OPT_VIDEO_SWAP_PLAYER_TYPE);
|
||||
if (accessTokenResponse.status === 200) {
|
||||
var accessToken = await accessTokenResponse.json();
|
||||
var urlInfo = new URL('https://usher.ttvnw.net/api/channel/hls/' + channelName + '.m3u8?allow_source=true');
|
||||
urlInfo.searchParams.set('sig', accessToken.data.streamPlaybackAccessToken.signature);
|
||||
urlInfo.searchParams.set('token', accessToken.data.streamPlaybackAccessToken.value);
|
||||
var encodingsM3u8Response = await fetch(urlInfo.href);
|
||||
if (encodingsM3u8Response.status === 200) {
|
||||
// TODO: Maybe look for the most optimal m3u8
|
||||
var encodingsM3u8 = await encodingsM3u8Response.text();
|
||||
var streamM3u8Url = encodingsM3u8.match(/^https:.*\.m3u8$/m)[0];
|
||||
// Maybe this request is a bit unnecessary
|
||||
var streamM3u8Response = await fetch(streamM3u8Url);
|
||||
if (streamM3u8Response.status == 200) {
|
||||
tempM3u8Url = streamM3u8Url;
|
||||
} else {
|
||||
console.log('Backup url request (streamM3u8) failed with ' + streamM3u8Response.status);
|
||||
}
|
||||
} else {
|
||||
console.log('Backup url request (encodingsM3u8) failed with ' + encodingsM3u8Response.status);
|
||||
}
|
||||
} else {
|
||||
console.log('Backup url request (accessToken) failed with ' + accessTokenResponse.status);
|
||||
}
|
||||
if (tempM3u8Url != null) {
|
||||
tempVideo = document.createElement('video');
|
||||
tempVideo.autoplay = true;
|
||||
tempVideo.volume = originalVolume;
|
||||
console.log(disabledVideo);
|
||||
disabledVideo.parentElement.insertBefore(tempVideo, disabledVideo.nextSibling);
|
||||
if (Hls.isSupported()) {
|
||||
tempVideo.hls = new Hls();
|
||||
tempVideo.hls.loadSource(tempM3u8Url);
|
||||
tempVideo.hls.attachMedia(tempVideo);
|
||||
}
|
||||
console.log(tempVideo);
|
||||
console.log(tempM3u8Url);
|
||||
}
|
||||
};
|
||||
createTempStream();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
function pollForAds() {
|
||||
//console.log('pollForAds ' + new Date(Date.now()));
|
||||
//check ad by looking for text banner
|
||||
var adBanner = document.querySelectorAll("span.tw-c-text-overlay");
|
||||
var foundAd = false;
|
||||
for (var i = 0; i < adBanner.length; i++) {
|
||||
if (adBanner[i].attributes["data-test-selector"]) {
|
||||
foundAd = true;
|
||||
foundAdBanner = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (tempVideo && disabledVideo && tempVideo.paused != disabledVideo.paused) {
|
||||
if (disabledVideo.paused) {
|
||||
tempVideo.pause();
|
||||
} else {
|
||||
tempVideo.play();//TODO: Fix issue with Firefox
|
||||
}
|
||||
}
|
||||
if (foundAd) {
|
||||
onFoundAd(false);
|
||||
} else if (!foundAd && foundAdBanner) {
|
||||
if (disabledVideo) {
|
||||
disabledVideo.volume = originalVolume;
|
||||
disabledVideo.style.filter = "";
|
||||
disabledVideo = null;
|
||||
foundAdContainer = false;
|
||||
foundAdBanner = false;
|
||||
if (tempVideo) {
|
||||
tempVideo.hls.stopLoad();
|
||||
tempVideo.remove();
|
||||
tempVideo = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
//setTimeout(pollForAds,100);
|
||||
}
|
||||
function pollForAdsObserver() {
|
||||
pollForAds();
|
||||
var vids = document.getElementsByClassName('video-player');
|
||||
for (var i = 0; i < vids.length; i++) {
|
||||
var observer = new MutationObserver(pollForAds);
|
||||
observer.observe(vids[i], {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: false,
|
||||
characterData: false
|
||||
});
|
||||
}
|
||||
}
|
||||
function reloadTwitchPlayer(isPausePlay) {
|
||||
function reloadTwitchPlayer(isSeek, isPausePlay) {
|
||||
// Taken from ttv-tools / ffz
|
||||
// https://github.com/Nerixyz/ttv-tools/blob/master/src/context/twitch-player.ts
|
||||
// https://github.com/FrankerFaceZ/FrankerFaceZ/blob/master/src/sites/twitch-twilight/modules/player.jsx
|
||||
@ -958,6 +579,11 @@ twitch-videoad.js application/javascript
|
||||
if (player.paused) {
|
||||
return;
|
||||
}
|
||||
if (isSeek) {
|
||||
console.log('Force seek to reset player (hopefully fixing any audio desync)');
|
||||
player.seekTo(0);
|
||||
return;
|
||||
}
|
||||
if (isPausePlay) {
|
||||
player.pause();
|
||||
player.play();
|
||||
@ -981,28 +607,5 @@ twitch-videoad.js application/javascript
|
||||
playerState.setSrc({ isNewMediaPlayerInstance: true, refreshAccessToken: true });// ffz sets this false
|
||||
}
|
||||
window.reloadTwitchPlayer = reloadTwitchPlayer;
|
||||
function onContentLoaded() {
|
||||
// These modes use polling of the ad elements (e.g. ad banner text) to show/hide content
|
||||
if (!OPT_MODE_VIDEO_SWAP && !OPT_MODE_MUTE_BLACK) {
|
||||
return;
|
||||
}
|
||||
if (OPT_MODE_VIDEO_SWAP && typeof Hls === 'undefined') {
|
||||
var script = document.createElement('script');
|
||||
script.src = "https://cdn.jsdelivr.net/npm/hls.js@latest";
|
||||
script.onload = function() {
|
||||
pollForAdsObserver();
|
||||
}
|
||||
document.head.appendChild(script);
|
||||
} else {
|
||||
pollForAdsObserver();
|
||||
}
|
||||
}
|
||||
hookFetch();
|
||||
if (document.readyState === "complete" || document.readyState === "loaded" || document.readyState === "interactive") {
|
||||
onContentLoaded();
|
||||
} else {
|
||||
window.addEventListener("DOMContentLoaded", function() {
|
||||
onContentLoaded();
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
@ -1,3 +1,5 @@
|
||||
OPT_ROLLING_DEVICE_ID true
|
||||
OPT_MODE_STRIP_AD_SEGMENTS true
|
||||
OPT_MODE_NOTIFY_ADS_WATCHED true
|
||||
OPT_MODE_NOTIFY_ADS_WATCHED_ATTEMPTS 1
|
||||
OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT true
|
||||
OPT_ACCESS_TOKEN_PLAYER_TYPE 'site'
|
@ -1,7 +1,7 @@
|
||||
// ==UserScript==
|
||||
// @name TwitchAdSolutions (notify-reload)
|
||||
// @namespace https://github.com/pixeltris/TwitchAdSolutions
|
||||
// @version 1.4
|
||||
// @version 1.5
|
||||
// @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)
|
||||
@ -14,66 +14,36 @@
|
||||
'use strict';
|
||||
function declareOptions(scope) {
|
||||
// Options / globals
|
||||
scope.OPT_MODE_MUTE_BLACK = false;
|
||||
scope.OPT_MODE_VIDEO_SWAP = false;
|
||||
scope.OPT_MODE_LOW_RES = false;
|
||||
scope.OPT_MODE_EMBED = false;
|
||||
scope.OPT_MODE_STRIP_AD_SEGMENTS = false;
|
||||
scope.OPT_ROLLING_DEVICE_ID = true;
|
||||
scope.OPT_MODE_STRIP_AD_SEGMENTS = true;
|
||||
scope.OPT_MODE_NOTIFY_ADS_WATCHED = true;
|
||||
scope.OPT_MODE_NOTIFY_ADS_WATCHED_INITIAL_ATTEMPTS = 0;
|
||||
scope.OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST = false;
|
||||
scope.OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST_AND_RELOAD = false;
|
||||
scope.OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST_EXPECTED_DURATION = 10000;// In milliseconds
|
||||
scope.OPT_MODE_NOTIFY_ADS_WATCHED_MIN_REQUESTS = true;
|
||||
scope.OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT = true;
|
||||
scope.OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT_DELAY = 0;
|
||||
scope.OPT_MODE_PROXY_M3U8 = '';
|
||||
scope.OPT_MODE_PROXY_M3U8_OBFUSCATED = false;
|
||||
scope.OPT_MODE_PROXY_M3U8_FULL_URL = false;
|
||||
scope.OPT_MODE_PROXY_M3U8_PARTIAL_URL = false;
|
||||
scope.OPT_VIDEO_SWAP_PLAYER_TYPE = 'picture-by-picture';
|
||||
scope.OPT_BACKUP_PLAYER_TYPE = 'picture-by-picture';
|
||||
scope.OPT_BACKUP_PLAYER_TYPE = 'picture-by-picture';//'picture-by-picture';'thunderdome';
|
||||
scope.OPT_REGULAR_PLAYER_TYPE = 'site';
|
||||
scope.OPT_INITIAL_M3U8_ATTEMPTS = 1;
|
||||
scope.OPT_ACCESS_TOKEN_PLAYER_TYPE = '';
|
||||
scope.OPT_ACCESS_TOKEN_TEMPLATE = true;
|
||||
scope.OPT_ACCESS_TOKEN_PLAYER_TYPE = 'site';
|
||||
scope.AD_SIGNIFIER = 'stitched-ad';
|
||||
scope.LIVE_SIGNIFIER = ',live';
|
||||
scope.CLIENT_ID = 'kimne78kx3ncx6brgo4mv6wki5h1ko';
|
||||
// Modify options based on mode
|
||||
if (!scope.OPT_ACCESS_TOKEN_PLAYER_TYPE && scope.OPT_MODE_LOW_RES) {
|
||||
scope.OPT_ACCESS_TOKEN_PLAYER_TYPE = 'thunderdome';//480p
|
||||
//scope.OPT_ACCESS_TOKEN_PLAYER_TYPE = 'picture-by-picture';//360p
|
||||
}
|
||||
if (!scope.OPT_ACCESS_TOKEN_PLAYER_TYPE && scope.OPT_MODE_EMBED) {
|
||||
scope.OPT_ACCESS_TOKEN_PLAYER_TYPE = 'embed';
|
||||
}
|
||||
if (scope.OPT_MODE_PROXY_M3U8 && scope.OPT_MODE_PROXY_M3U8_OBFUSCATED) {
|
||||
var newStr = '';
|
||||
scope.OPT_MODE_PROXY_M3U8 = atob(scope.OPT_MODE_PROXY_M3U8);
|
||||
for (var i = 0; i < scope.OPT_MODE_PROXY_M3U8.length; i++) {
|
||||
newStr += String.fromCharCode(scope.OPT_MODE_PROXY_M3U8.charCodeAt(i) ^ scope.CLIENT_ID.charCodeAt(i % scope.CLIENT_ID.length));
|
||||
}
|
||||
scope.OPT_MODE_PROXY_M3U8 = newStr;
|
||||
}
|
||||
// These are only really for Worker scope...
|
||||
scope.StreamInfos = [];
|
||||
scope.StreamInfosByUrl = [];
|
||||
scope.CurrentChannelNameFromM3U8 = null;
|
||||
// Need this in both scopes. Window scope needs to update this to worker scope.
|
||||
scope.gql_device_id = null;
|
||||
scope.gql_device_id_rolling = '';
|
||||
// Rolling device id crap... TODO: improve this
|
||||
var charTable = []; for (var i = 97; i <= 122; i++) { charTable.push(String.fromCharCode(i)); } for (var i = 65; i <= 90; i++) { charTable.push(String.fromCharCode(i)); } for (var i = 48; i <= 57; i++) { charTable.push(String.fromCharCode(i)); }
|
||||
var bs = 'eVI6jx47kJvCFfFowK86eVI6jx47kJvC';
|
||||
var di = (new Date()).getYear() + (new Date()).getMonth() + ((new Date()).getDate() / 7) | 0;
|
||||
for (var i = 0; i < bs.length; i++) {
|
||||
scope.gql_device_id_rolling += charTable[(bs.charCodeAt(i) ^ di) % charTable.length];
|
||||
}
|
||||
}
|
||||
declareOptions(window);
|
||||
////////////////////////////////////
|
||||
// stream swap / stream mute
|
||||
////////////////////////////////////
|
||||
var tempVideo = null;// A temporary video container to hold a lower resolution stream without ads
|
||||
var disabledVideo = null;// The original video element (disabled for the duration of the ad)
|
||||
var originalVolume = 0;// The volume of the original video element
|
||||
var foundAdContainer = false;// Have ad containers been found (the clickable ad)
|
||||
var foundAdBanner = false;// Is the ad banner visible (top left of screen)
|
||||
////////////////////////////////////
|
||||
var notifyAdsWatchedReloadNextTime = 0;
|
||||
var twitchMainWorker = null;
|
||||
const oldWorker = window.Worker;
|
||||
window.Worker = class Worker extends oldWorker {
|
||||
@ -89,9 +59,6 @@
|
||||
}
|
||||
var newBlobStr = `
|
||||
${processM3U8.toString()}
|
||||
${getSegmentInfos.toString()}
|
||||
${getSegmentInfosLines.toString()}
|
||||
${getFinalSegUrl.toString()}
|
||||
${hookWorkerFetch.toString()}
|
||||
${declareOptions.toString()}
|
||||
${getAccessToken.toString()}
|
||||
@ -110,28 +77,34 @@
|
||||
`
|
||||
super(URL.createObjectURL(new Blob([newBlobStr])));
|
||||
twitchMainWorker = this;
|
||||
var adDiv = null;
|
||||
this.onmessage = function(e) {
|
||||
// NOTE: Removed adDiv caching as '.video-player' can change between streams?
|
||||
if (e.data.key == 'UboShowAdBanner') {
|
||||
if (adDiv == null) { adDiv = getAdDiv(); }
|
||||
adDiv.P.textContent = 'Waiting for' + (e.data.isMidroll ? ' midroll' : '') + ' ads to finish...';
|
||||
var adDiv = getAdDiv();
|
||||
if (adDiv != null) {
|
||||
adDiv.P.textContent = 'Blocking' + (e.data.isMidroll ? ' midroll' : '') + ' ads...';
|
||||
adDiv.style.display = 'block';
|
||||
}
|
||||
}
|
||||
else if (e.data.key == 'UboHideAdBanner') {
|
||||
if (adDiv == null) { adDiv = getAdDiv(); }
|
||||
var adDiv = getAdDiv();
|
||||
if (adDiv != null) {
|
||||
adDiv.style.display = 'none';
|
||||
}
|
||||
}
|
||||
else if (e.data.key == 'UboFoundAdSegment') {
|
||||
onFoundAd(e.data.hasLiveSeg, e.data.streamM3u8);
|
||||
onFoundAd(e.data.isMidroll, e.data.streamM3u8);
|
||||
}
|
||||
else if (e.data.key == 'UboChannelNameM3U8Changed') {
|
||||
//console.log('M3U8 channel name changed to ' + e.data.value);
|
||||
notifyAdsWatchedReloadNextTime = 0;
|
||||
}
|
||||
else if (e.data.key == 'UboReloadPlayer') {
|
||||
reloadTwitchPlayer();
|
||||
}
|
||||
else if (e.data.key == 'UboPauseResumePlayer') {
|
||||
reloadTwitchPlayer(false, true);
|
||||
}
|
||||
else if (e.data.key == 'UboSeekPlayer') {
|
||||
reloadTwitchPlayer(true);
|
||||
}
|
||||
}
|
||||
@ -159,164 +132,33 @@
|
||||
req.send();
|
||||
return req.responseText.split("'")[1];
|
||||
}
|
||||
function getSegmentInfosLines(streamInfo, lines) {
|
||||
var result = {};
|
||||
result.segs = [];
|
||||
result.targetDuration = 0;
|
||||
result.elapsedSecs = 0;
|
||||
result.totalSecs = 0;
|
||||
result.hasPrefetch = false;
|
||||
result.hasLiveBeforeAd = true;// This most likely means a midroll (live segments before ad segments)
|
||||
var hasLive = false;
|
||||
for (var i = 0; i < lines.length; i++) {
|
||||
var line = lines[i];
|
||||
if (line.startsWith('#EXT-X-TARGETDURATION')) {
|
||||
result.targetDuration = parseInt(line.split(':')[1]);
|
||||
}
|
||||
if (line.startsWith('#EXT-X-TWITCH-ELAPSED-SECS')) {
|
||||
result.elapsedSecs = line.split(':')[1];
|
||||
}
|
||||
if (line.startsWith('#EXT-X-TWITCH-TOTAL-SECS')) {
|
||||
result.totalSecs = line.split(':')[1];
|
||||
}
|
||||
if (line.startsWith('#EXT-X-DATERANGE')) {
|
||||
var attr = parseAttributes(line);
|
||||
if (attr['CLASS'] && attr['CLASS'].includes('stitched-ad')) {
|
||||
streamInfo.IsMidroll = attr['X-TV-TWITCH-AD-ROLL-TYPE'] == 'MIDROLL';
|
||||
}
|
||||
}
|
||||
if (line.startsWith('http')) {
|
||||
var segInfo = {};
|
||||
segInfo.urlLineIndex = i;
|
||||
segInfo.urlLine = lines[i];
|
||||
segInfo.url = lines[i];
|
||||
segInfo.isPrefetch = false;
|
||||
if (i >= 1 && lines[i - 1].startsWith('#EXTINF')) {
|
||||
//#EXTINF:2.002,DCM|2435256
|
||||
//#EXTINF:2.002,Amazon|8493257483
|
||||
//#EXTINF:2.000,live
|
||||
var splitted = lines[i - 1].split(':')[1].split(',');
|
||||
segInfo.extInfLineIndex = i - 1;
|
||||
segInfo.extInfLine = lines[i - 1];
|
||||
segInfo.extInfLen = splitted[0];//2.000 (can be between 2.000 -> 5.000?)
|
||||
segInfo.extInfType = splitted[1].split('|')[0];//live / Amazon / DCM
|
||||
segInfo.isAd = segInfo.extInfType != 'live';
|
||||
if (segInfo.isAd && !hasLive && result.hasLiveBeforeAd) {
|
||||
result.hasLiveBeforeAd = false;
|
||||
}
|
||||
hasLive = !segInfo.isAd;
|
||||
}
|
||||
if (i >= 2 && lines[i - 2].startsWith('#EXT-X-PROGRAM-DATE-TIME')) {
|
||||
segInfo.dateTimeLineIndex = i - 2;
|
||||
segInfo.dateTimeLine = lines[i - 2];
|
||||
segInfo.dateTime = new Date(lines[i - 2].substr(lines[i - 2].indexOf(':')));
|
||||
}
|
||||
result.segs.push(segInfo);
|
||||
}
|
||||
if (lines[i].startsWith('#EXT-X-TWITCH-PREFETCH:')) {
|
||||
var segInfo = {};
|
||||
segInfo.urlLineIndex = i;
|
||||
segInfo.urlLine = lines[i];
|
||||
segInfo.url = lines[i].substr(lines[i].indexOf(':') + 1);
|
||||
segInfo.isPrefetch = true;
|
||||
result.hasPrefetch = true;
|
||||
result.segs.push(segInfo);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
function getSegmentInfos(streamInfo, lines, backupLines) {
|
||||
var result = {};
|
||||
result.segs = [];
|
||||
result.main = getSegmentInfosLines(streamInfo, lines);
|
||||
result.backup = getSegmentInfosLines(streamInfo, backupLines);
|
||||
// Push all backup segments first
|
||||
for (var i = 0; i < result.backup.segs.length; i++) {
|
||||
var seg = {};
|
||||
seg.backup = result.backup.segs[i];
|
||||
result.segs.push(seg);
|
||||
}
|
||||
// Insert any live main segments
|
||||
// NOTE: We might want to make sure we aren't writing over previously established backup segments (make use of streamInfo.SegmentCache)
|
||||
// NOTE: Midroll ads will result in a very long backup stream. Better logic required for midrolls.
|
||||
for (var i = result.main.segs.length - 1, j = result.segs.length - 1; i >= 0 && j >= 0; i--, j--) {
|
||||
while (result.main.segs[i].isPrefetch) {
|
||||
if (result.segs[j].backup.isPrefetch) {
|
||||
result.segs[j].main = result.main.segs[i];
|
||||
j--;
|
||||
}
|
||||
i--;
|
||||
}
|
||||
if (!result.main.segs[i].isAd) {
|
||||
result.segs[j].main = result.main.segs[i];
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Set the segment cache (currently unused)
|
||||
streamInfo.SegmentCache.length = 0;
|
||||
for (var i = 0; i < result.segs.length; i++) {
|
||||
if (result.segs[i].main != null) {
|
||||
streamInfo.SegmentCache[result.segs[i].main.url] = result.segs[i];
|
||||
}
|
||||
if (result.segs[i].backup != null) {
|
||||
streamInfo.SegmentCache[result.segs[i].backup.url] = result.segs[i];
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
function getFinalSegUrl(lines) {
|
||||
for (var i = lines.length - 1; i >= 0; i--) {
|
||||
if (lines[i].startsWith("http")) {
|
||||
return lines[i];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
async function processM3U8(url, textStr, realFetch) {
|
||||
var streamInfo = StreamInfosByUrl[url];
|
||||
if (streamInfo == null) {
|
||||
console.log('Unknown stream url ' + url);
|
||||
postMessage({key:'UboHideAdBanner'});
|
||||
return textStr;
|
||||
}
|
||||
if (!OPT_MODE_STRIP_AD_SEGMENTS) {
|
||||
return textStr;
|
||||
}
|
||||
var haveAdTags = textStr.includes(AD_SIGNIFIER);
|
||||
if (OPT_MODE_STRIP_AD_SEGMENTS) {
|
||||
var si = StreamInfosByUrl[url];
|
||||
if (si != null) {
|
||||
var lines = textStr.replace('\r', '').split('\n');
|
||||
for (var i = 0; i < lines.length; i++) {
|
||||
if (lines[i].startsWith('#EXT-X-MEDIA-SEQUENCE:')) {
|
||||
var oldRealSeq = si.RealSeqNumber;
|
||||
si.RealSeqNumber = parseInt(/#EXT-X-MEDIA-SEQUENCE:([0-9]*)/.exec(lines[i])[1]);
|
||||
if (!haveAdTags && si.FakeSeqNumber > 0) {
|
||||
/*// We have some sequencing issues... for now lets pause/play and stop modifying sequence.
|
||||
// TODO: Improve sequencing (determine if the m3u8 urls have actually changed)
|
||||
si.FakeSeqNumber = 0;
|
||||
si.BackupSeqNumber = -1;
|
||||
postMessage({key:'UboPauseResumePlayer'});*/
|
||||
// We previously modified the sequence number, we need to keep doing so (alternatively pause/playing might work better
|
||||
var finalSegUrl = getFinalSegUrl(lines);
|
||||
if (finalSegUrl != si.FinalSegUrl) {
|
||||
si.FinalSegUrl = finalSegUrl;
|
||||
// TODO: Maybe only do the jump check if there was an ad recently? (within the last 5 m3u8 requests)
|
||||
var jump = Math.max(0, si.RealSeqNumber - oldRealSeq);
|
||||
if (jump <= 3) {
|
||||
si.FakeSeqNumber += Math.max(0, si.RealSeqNumber - oldRealSeq);
|
||||
} else if (jump > 0) {
|
||||
si.FakeSeqNumber++;
|
||||
}
|
||||
}
|
||||
lines[i] = '#EXT-X-MEDIA-SEQUENCE:' + si.FakeSeqNumber;
|
||||
console.log('No ad, but modifying sequence realSeq:' + si.RealSeqNumber + ' fakeSeq:' + si.FakeSeqNumber);
|
||||
}
|
||||
if (haveAdTags) {
|
||||
var currentResolution = null;
|
||||
for (const [resUrl, resName] of Object.entries(streamInfo.Urls)) {
|
||||
if (resUrl == url) {
|
||||
currentResolution = resName;
|
||||
//console.log(resName);
|
||||
break;
|
||||
}
|
||||
}
|
||||
textStr = lines.join('\n');
|
||||
}
|
||||
}
|
||||
if (haveAdTags) {
|
||||
var si = StreamInfosByUrl[url];
|
||||
if (OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST && si != null && !si.NotifyObservedNoAds) {
|
||||
// We only really know it's fully processed when we no loger see ads
|
||||
// NOTE: We probably shouldn't keep sending these requests. Possibly start sending them after expected ad duration?
|
||||
streamInfo.HadAds = true;
|
||||
streamInfo.IsMidroll = textStr.includes('"MIDROLL"') || textStr.includes('"midroll"');
|
||||
postMessage({key:'UboShowAdBanner',isMidroll:streamInfo.IsMidroll});
|
||||
// Notify ads "watched" TODO: Keep crafting these requests even after ad tags are gone as sometimes it stops too early.
|
||||
if (OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST && streamInfo != null && !streamInfo.NotifyObservedNoAds) {
|
||||
var noAds = false;
|
||||
var encodingsM3u8Response = await realFetch(si.RootM3U8Url);
|
||||
var encodingsM3u8Response = await realFetch(streamInfo.RootM3U8Url);
|
||||
if (encodingsM3u8Response.status === 200) {
|
||||
var encodingsM3u8 = await encodingsM3u8Response.text();
|
||||
var streamM3u8Url = encodingsM3u8.match(/^https:.*\.m3u8$/m)[0];
|
||||
@ -326,35 +168,76 @@
|
||||
console.log('Notify ad watched. Response has ads: ' + !noAds);
|
||||
}
|
||||
}
|
||||
if (si.NotifyFirstTime == 0) {
|
||||
si.NotifyFirstTime = Date.now();
|
||||
if (streamInfo.NotifyFirstTime == 0) {
|
||||
streamInfo.NotifyFirstTime = Date.now();
|
||||
}
|
||||
if (noAds && !si.NotifyObservedNoAds && Date.now() >= si.NotifyFirstTime + OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST_EXPECTED_DURATION) {
|
||||
si.NotifyObservedNoAds = true;
|
||||
}
|
||||
if (noAds && OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST_AND_RELOAD && Date.now() >= si.NotifyFirstTime + OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST_EXPECTED_DURATION) {
|
||||
console.log('Reload player');
|
||||
postMessage({key:'UboHideAdBanner'});
|
||||
postMessage({key:'UboReloadPlayer'});
|
||||
return '';
|
||||
if (noAds && !streamInfo.NotifyObservedNoAds && Date.now() >= streamInfo.NotifyFirstTime + OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST_EXPECTED_DURATION) {
|
||||
streamInfo.NotifyObservedNoAds = true;
|
||||
}
|
||||
}
|
||||
postMessage({
|
||||
key: 'UboFoundAdSegment',
|
||||
hasLiveSeg: textStr.includes(LIVE_SIGNIFIER),
|
||||
isMidroll: streamInfo.IsMidroll,
|
||||
streamM3u8: textStr
|
||||
});
|
||||
if (!streamInfo.IsMidroll && OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT) {
|
||||
return '';
|
||||
}
|
||||
if (!OPT_MODE_STRIP_AD_SEGMENTS) {
|
||||
return textStr;
|
||||
// NOTE: Got a bit of code duplication here, merge Backup/BackupReg in some form.
|
||||
// See if there's clean stream url to fetch (only do this after we have a regular backup, this should save time and you're unlikely to find a clean stream on first request)
|
||||
try {
|
||||
if (streamInfo.BackupRegRes != currentResolution) {
|
||||
streamInfo.BackupRegRes = null;
|
||||
streamInfo.BackupRegUrl = null;
|
||||
}
|
||||
if (haveAdTags) {
|
||||
var streamInfo = StreamInfosByUrl[url];
|
||||
if (streamInfo == null) {
|
||||
console.log('Unknown stream url ' + url);
|
||||
postMessage({key:'UboHideAdBanner'});
|
||||
return textStr;
|
||||
if (currentResolution && streamInfo.BackupRegUrl == null && (streamInfo.BackupFailed || streamInfo.BackupUrl != null)) {
|
||||
var accessTokenResponse = await getAccessToken(streamInfo.ChannelName, OPT_REGULAR_PLAYER_TYPE);
|
||||
if (accessTokenResponse.status === 200) {
|
||||
var accessToken = await accessTokenResponse.json();
|
||||
var urlInfo = new URL('https://usher.ttvnw.net/api/channel/hls/' + streamInfo.ChannelName + '.m3u8' + streamInfo.RootM3U8Params);
|
||||
urlInfo.searchParams.set('sig', accessToken.data.streamPlaybackAccessToken.signature);
|
||||
urlInfo.searchParams.set('token', accessToken.data.streamPlaybackAccessToken.value);
|
||||
var encodingsM3u8Response = await realFetch(urlInfo.href);
|
||||
if (encodingsM3u8Response.status === 200) {
|
||||
var encodingsM3u8 = await encodingsM3u8Response.text();
|
||||
var encodingsLines = encodingsM3u8.replace('\r', '').split('\n');
|
||||
for (var i = 0; i < encodingsLines.length; i++) {
|
||||
if (!encodingsLines[i].startsWith('#') && encodingsLines[i].includes('.m3u8')) {
|
||||
if (i > 0 && encodingsLines[i - 1].startsWith('#EXT-X-STREAM-INF')) {
|
||||
var res = parseAttributes(encodingsLines[i - 1])['RESOLUTION'];
|
||||
if (res && res == currentResolution) {
|
||||
streamInfo.BackupRegUrl = encodingsLines[i];
|
||||
streamInfo.BackupRegRes = currentResolution;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (streamInfo.BackupRegUrl != null) {
|
||||
var backupM3u8 = null;
|
||||
var backupM3u8Response = await realFetch(streamInfo.BackupRegUrl);
|
||||
if (backupM3u8Response.status == 200) {
|
||||
backupM3u8 = await backupM3u8Response.text();
|
||||
}
|
||||
if (backupM3u8 != null && !backupM3u8.includes(AD_SIGNIFIER)) {
|
||||
return backupM3u8;
|
||||
} else {
|
||||
//console.log('Try use regular resolution failed');
|
||||
streamInfo.BackupRegRes = null;
|
||||
streamInfo.BackupRegUrl = null;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
streamInfo.BackupRegRes = null;
|
||||
streamInfo.BackupRegUrl = null;
|
||||
console.log('Fetching backup (regular resolution) m3u8 failed');
|
||||
console.log(err);
|
||||
}
|
||||
// Fetch backup url
|
||||
try {
|
||||
if (!streamInfo.BackupFailed && streamInfo.BackupUrl == null) {
|
||||
// NOTE: We currently don't fetch the oauth_token. You wont be able to access private streams like this.
|
||||
streamInfo.BackupFailed = true;
|
||||
@ -386,87 +269,34 @@
|
||||
}
|
||||
}
|
||||
var backupM3u8 = null;
|
||||
if (streamInfo.BackupUrl != null) {
|
||||
var backupM3u8Response = await realFetch(streamInfo.BackupUrl);
|
||||
if (backupM3u8Response.status == 200) {
|
||||
backupM3u8 = await backupM3u8Response.text();
|
||||
}
|
||||
if (backupM3u8 != null && !backupM3u8.includes(AD_SIGNIFIER)) {
|
||||
return backupM3u8;
|
||||
} else {
|
||||
console.log('Backup m3u8 failed with ' + backupM3u8Response.status);
|
||||
}
|
||||
} catch (err) {
|
||||
console.log('Fetching backup m3u8 failed');
|
||||
console.log(err);
|
||||
}
|
||||
var lines = textStr.replace('\r', '').split('\n');
|
||||
var newLines = [];
|
||||
if (backupM3u8 != null) {
|
||||
var backupLines = backupM3u8.replace('\r', '').split('\n');
|
||||
var segInfos = getSegmentInfos(streamInfo, lines, backupLines);
|
||||
newLines.push('#EXTM3U');
|
||||
newLines.push('#EXT-X-VERSION:3');
|
||||
newLines.push('#EXT-X-TARGETDURATION:' + segInfos.backup.targetDuration);
|
||||
newLines.push('');//#EXT-X-MEDIA-SEQUENCE:' + streamInfo.FakeSeqNumber);
|
||||
// The following will could cause issues when we stop stripping segments
|
||||
//newLines.push('#EXT-X-TWITCH-ELAPSED-SECS:' + streamInfo.backup.elapsedSecs);
|
||||
//newLines.push('#EXT-X-TWITCH-TOTAL-SECS:' + streamInfo.backup.totalSecs);
|
||||
var pushedLiveSegs = 0;
|
||||
var pushedBackupSegs = 0;
|
||||
var pushedPrefetchSegs = 0;
|
||||
for (var i = 0; i < segInfos.segs.length; i++) {
|
||||
var seg = segInfos.segs[i];
|
||||
var segData = null;
|
||||
if (seg.main != null && !seg.main.isAd) {
|
||||
pushedLiveSegs++;
|
||||
segData = seg.main;
|
||||
} else if (seg.backup != null) {
|
||||
pushedBackupSegs++;
|
||||
segData = seg.backup;
|
||||
// Backups failed. Return nothing (this will likely result in spam or player error 2000?).
|
||||
console.log('Ad blocking failed. Stream might break.');
|
||||
return '';
|
||||
}
|
||||
if (segData != null) {
|
||||
if (segData.isPrefetch) {
|
||||
pushedPrefetchSegs++;
|
||||
newLines.push(segData.urlLine);
|
||||
} else {
|
||||
//newLines.push(segData.dateTimeLine);
|
||||
newLines.push(segData.extInfLine);
|
||||
newLines.push(segData.urlLine);
|
||||
}
|
||||
}
|
||||
}
|
||||
var finalSegUrl = getFinalSegUrl(newLines);
|
||||
if (finalSegUrl != streamInfo.FinalSegUrl) {
|
||||
streamInfo.FinalSegUrl = finalSegUrl;
|
||||
streamInfo.FakeSeqNumber++;// We might need something better than this for lager jumps in seq?
|
||||
}
|
||||
newLines[3] = '#EXT-X-MEDIA-SEQUENCE:' + streamInfo.FakeSeqNumber;
|
||||
if (pushedLiveSegs > 0 || pushedBackupSegs > 0) {
|
||||
console.log('liveSegs:' + pushedLiveSegs + ' backupSegs:' + pushedBackupSegs + ' prefetch:' + pushedPrefetchSegs + ' realSeq:' + streamInfo.RealSeqNumber + ' fakeSeq:' + streamInfo.FakeSeqNumber);
|
||||
} else {
|
||||
console.log('TODO: If theres no backup data then we need to provide our own .ts file, otherwise the player will spam m3u8 requests (denial-of-service)');
|
||||
}
|
||||
}
|
||||
textStr = newLines.length > 0 ? newLines.join('\n') : lines.join('\n');
|
||||
//console.log(textStr);
|
||||
if (streamInfo.HadAds) {
|
||||
postMessage({key:'UboSeekPlayer'});
|
||||
streamInfo.HadAds = false;
|
||||
}
|
||||
postMessage({key:'UboHideAdBanner'});
|
||||
return textStr;
|
||||
}
|
||||
function hookWorkerFetch() {
|
||||
var realFetch = fetch;
|
||||
fetch = async function(url, options) {
|
||||
if (typeof url === 'string') {
|
||||
if (OPT_MODE_STRIP_AD_SEGMENTS && url.endsWith('.ts')) {
|
||||
var shownAdBanner = false;
|
||||
for (const [channelName, streamInfo] of Object.entries(StreamInfos)) {
|
||||
var seg = streamInfo.SegmentCache[url];
|
||||
if (seg && !seg.isPrefetch) {
|
||||
if (seg.main == null && seg.backup != null) {
|
||||
shownAdBanner = true;
|
||||
postMessage({key:'UboShowAdBanner',isMidroll:streamInfo.IsMidroll});
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!shownAdBanner) {
|
||||
postMessage({key:'UboHideAdBanner'});
|
||||
}
|
||||
}
|
||||
if (url.endsWith('m3u8')) {
|
||||
return new Promise(function(resolve, reject) {
|
||||
var processAfter = async function(response) {
|
||||
@ -493,26 +323,7 @@
|
||||
});
|
||||
}
|
||||
CurrentChannelNameFromM3U8 = channelName;
|
||||
if (OPT_MODE_PROXY_M3U8) {
|
||||
if (OPT_MODE_PROXY_M3U8_FULL_URL || OPT_MODE_PROXY_M3U8_PARTIAL_URL) {
|
||||
if (OPT_MODE_PROXY_M3U8_FULL_URL) {
|
||||
url = OPT_MODE_PROXY_M3U8 + url;
|
||||
} else {
|
||||
url = OPT_MODE_PROXY_M3U8 + url.split('.m3u8')[0];
|
||||
}
|
||||
if (!OPT_MODE_PROXY_M3U8_OBFUSCATED) {
|
||||
console.log('proxy-m3u8: ' + url);
|
||||
}
|
||||
var opt2 = {};
|
||||
opt2.headers = [];
|
||||
opt2.headers['Access-Control-Allow-Origin'] = '*';// This is to appease the currently set proxy
|
||||
return realFetch(url, opt2);
|
||||
} else {
|
||||
url = OPT_MODE_PROXY_M3U8 + channelName;
|
||||
console.log('proxy-m3u8: ' + url);
|
||||
}
|
||||
}
|
||||
else if (OPT_MODE_STRIP_AD_SEGMENTS) {
|
||||
if (OPT_MODE_STRIP_AD_SEGMENTS) {
|
||||
return new Promise(async function(resolve, reject) {
|
||||
// - First m3u8 request is the m3u8 with the video encodings (360p,480p,720p,etc).
|
||||
// - Second m3u8 request is the m3u8 for the given encoding obtained in the first request. At this point we will know if there's ads.
|
||||
@ -535,13 +346,15 @@
|
||||
}
|
||||
// This might potentially backfire... maybe just add the new urls
|
||||
streamInfo.ChannelName = channelName;
|
||||
streamInfo.Urls = [];
|
||||
streamInfo.Urls = [];// xxx.m3u8 -> "284x160" (resolution)
|
||||
streamInfo.RootM3U8Url = url;
|
||||
streamInfo.RootM3U8Params = (new URL(url)).search;
|
||||
streamInfo.BackupUrl = null;
|
||||
streamInfo.BackupFailed = false;
|
||||
streamInfo.SegmentCache = [];
|
||||
streamInfo.BackupRegUrl = null;
|
||||
streamInfo.BackupRegRes = null;
|
||||
streamInfo.IsMidroll = false;
|
||||
streamInfo.HadAds = false;
|
||||
streamInfo.NotifyFirstTime = 0;
|
||||
streamInfo.NotifyObservedNoAds = false;
|
||||
streamInfo.RealSeqNumber = -1;
|
||||
@ -551,7 +364,13 @@
|
||||
var lines = encodingsM3u8.replace('\r', '').split('\n');
|
||||
for (var i = 0; i < lines.length; i++) {
|
||||
if (!lines[i].startsWith('#') && lines[i].includes('.m3u8')) {
|
||||
streamInfo.Urls.push(lines[i]);
|
||||
streamInfo.Urls[lines[i]] = -1;
|
||||
if (i > 0 && lines[i - 1].startsWith('#EXT-X-STREAM-INF')) {
|
||||
var res = parseAttributes(lines[i - 1])['RESOLUTION'];
|
||||
if (res) {
|
||||
streamInfo.Urls[lines[i]] = res;
|
||||
}
|
||||
}
|
||||
StreamInfosByUrl[lines[i]] = streamInfo;
|
||||
}
|
||||
}
|
||||
@ -592,7 +411,6 @@
|
||||
}
|
||||
function getAccessToken(channelName, playerType, realFetch) {
|
||||
var body = null;
|
||||
if (OPT_ACCESS_TOKEN_TEMPLATE) {
|
||||
var templateQuery = 'query PlaybackAccessToken_Template($login: String!, $isLive: Boolean!, $vodID: ID!, $isVod: Boolean!, $playerType: String!) { streamPlaybackAccessToken(channelName: $login, params: {platform: "web", playerBackend: "mediaplayer", playerType: $playerType}) @include(if: $isLive) { value signature __typename } videoPlaybackAccessToken(id: $vodID, params: {platform: "web", playerBackend: "mediaplayer", playerType: $playerType}) @include(if: $isVod) { value signature __typename }}';
|
||||
body = {
|
||||
operationName: 'PlaybackAccessToken_Template',
|
||||
@ -605,24 +423,6 @@
|
||||
'playerType': playerType
|
||||
}
|
||||
};
|
||||
} else {
|
||||
body = {
|
||||
operationName: 'PlaybackAccessToken',
|
||||
variables: {
|
||||
isLive: true,
|
||||
login: channelName,
|
||||
isVod: false,
|
||||
vodID: '',
|
||||
playerType: playerType
|
||||
},
|
||||
extensions: {
|
||||
persistedQuery: {
|
||||
version: 1,
|
||||
sha256Hash: '0828119ded1c13477966434e15800ff57ddacf13ba1911c129dc2200705b0712',
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
return gqlRequest(body, realFetch);
|
||||
}
|
||||
function gqlRequest(body, realFetch) {
|
||||
@ -632,7 +432,7 @@
|
||||
body: JSON.stringify(body),
|
||||
headers: {
|
||||
'client-id': CLIENT_ID,
|
||||
'X-Device-Id': gql_device_id
|
||||
'X-Device-Id': OPT_ROLLING_DEVICE_ID ? gql_device_id_rolling : gql_device_id
|
||||
}
|
||||
});
|
||||
}
|
||||
@ -702,33 +502,6 @@
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
async function tryNotifyAdsWatchedSigTok(realFetch, i, sig, token) {
|
||||
var tokInfo = JSON.parse(token);
|
||||
var channelName = tokInfo.channel;
|
||||
var urlInfo = new URL('https://usher.ttvnw.net/api/channel/hls/' + channelName + '.m3u8');
|
||||
urlInfo.searchParams.set('sig', sig);
|
||||
urlInfo.searchParams.set('token', token);
|
||||
var encodingsM3u8Response = await realFetch(urlInfo.href);
|
||||
if (encodingsM3u8Response.status === 200) {
|
||||
var encodingsM3u8 = await encodingsM3u8Response.text();
|
||||
var streamM3u8Url = encodingsM3u8.match(/^https:.*\.m3u8$/m)[0];
|
||||
var streamM3u8Response = await realFetch(streamM3u8Url);
|
||||
var streamM3u8 = await streamM3u8Response.text();
|
||||
var res = await tryNotifyAdsWatchedM3U8(streamM3u8);
|
||||
if (i >= 0) {
|
||||
if (res == 1) {
|
||||
console.log("no ad at req " + i);
|
||||
} else {
|
||||
console.log('ad at req ' + i);
|
||||
}
|
||||
}
|
||||
return res;
|
||||
} else {
|
||||
// http error
|
||||
return 2;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
function hookFetch() {
|
||||
var realFetch = window.fetch;
|
||||
window.fetch = function(url, init, ...args) {
|
||||
@ -754,32 +527,12 @@
|
||||
value: gql_device_id
|
||||
});
|
||||
}
|
||||
if (OPT_MODE_NOTIFY_ADS_WATCHED && OPT_MODE_NOTIFY_ADS_WATCHED_INITIAL_ATTEMPTS > 0) {
|
||||
var tok = null, sig = null;
|
||||
if (url.includes('gql') && init && typeof init.body === 'string' && init.body.includes('PlaybackAccessToken')) {
|
||||
return new Promise(async function(resolve, reject) {
|
||||
var response = await realFetch(url, init);
|
||||
if (response.status === 200) {
|
||||
for (var i = 0; i < OPT_MODE_NOTIFY_ADS_WATCHED_INITIAL_ATTEMPTS; i++) {
|
||||
var cloned = response.clone();
|
||||
var responseStr = await cloned.text();
|
||||
var responseData = JSON.parse(responseStr);
|
||||
if (responseData && responseData.data && responseData.data.streamPlaybackAccessToken && responseData.data.streamPlaybackAccessToken.value && responseData.data.streamPlaybackAccessToken.signature) {
|
||||
if (await tryNotifyAdsWatchedSigTok(realFetch, i, responseData.data.streamPlaybackAccessToken.signature, responseData.data.streamPlaybackAccessToken.value) == 1) {
|
||||
resolve(new Response(responseStr));
|
||||
return;
|
||||
if (OPT_ROLLING_DEVICE_ID) {
|
||||
if (typeof init.headers['X-Device-Id'] === 'string') {
|
||||
init.headers['X-Device-Id'] = gql_device_id_rolling;
|
||||
}
|
||||
} else {
|
||||
console.log('malformed');
|
||||
console.log(responseData);
|
||||
break;
|
||||
}
|
||||
}
|
||||
resolve(response);
|
||||
} else {
|
||||
resolve(response);
|
||||
}
|
||||
});
|
||||
if (typeof init.headers['Device-ID'] === 'string') {
|
||||
init.headers['Device-ID'] = gql_device_id_rolling;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -787,148 +540,16 @@
|
||||
return realFetch.apply(this, arguments);
|
||||
}
|
||||
}
|
||||
function onFoundAd(hasLiveSeg, streamM3u8) {
|
||||
if (OPT_MODE_NOTIFY_ADS_WATCHED && OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT && Date.now() >= notifyAdsWatchedReloadNextTime) {
|
||||
function onFoundAd(isMidroll, streamM3u8) {
|
||||
if (OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT && !isMidroll) {
|
||||
console.log('OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT');
|
||||
notifyAdsWatchedReloadNextTime = Date.now() + OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT_DELAY;
|
||||
if (streamM3u8) {
|
||||
tryNotifyAdsWatchedM3U8(streamM3u8);
|
||||
}
|
||||
reloadTwitchPlayer();
|
||||
return;
|
||||
}
|
||||
if (hasLiveSeg) {
|
||||
return;
|
||||
}
|
||||
if (!OPT_MODE_MUTE_BLACK && !OPT_MODE_VIDEO_SWAP) {
|
||||
return;
|
||||
}
|
||||
if (OPT_MODE_VIDEO_SWAP && typeof Hls === 'undefined') {
|
||||
return;
|
||||
}
|
||||
if (!foundAdContainer) {
|
||||
// hide ad contianers
|
||||
var adContainers = document.querySelectorAll('[data-test-selector="sad-overlay"]');
|
||||
for (var i = 0; i < adContainers.length; i++) {
|
||||
adContainers[i].style.display = "none";
|
||||
}
|
||||
foundAdContainer = adContainers.length > 0;
|
||||
}
|
||||
if (disabledVideo) {
|
||||
disabledVideo.volume = 0;
|
||||
} else {
|
||||
//get livestream video element
|
||||
var liveVid = document.getElementsByTagName("video");
|
||||
if (liveVid.length) {
|
||||
disabledVideo = liveVid = liveVid[0];
|
||||
if (!disabledVideo) {
|
||||
return;
|
||||
}
|
||||
//mute
|
||||
originalVolume = liveVid.volume;
|
||||
liveVid.volume = 0;
|
||||
//black out
|
||||
liveVid.style.filter = "brightness(0%)";
|
||||
if (OPT_MODE_VIDEO_SWAP) {
|
||||
var createTempStream = async function() {
|
||||
// Create new video stream TODO: Do this with callbacks
|
||||
var channelName = window.location.pathname.substr(1);// TODO: Better way of determining the channel name
|
||||
var tempM3u8Url = null;
|
||||
var accessTokenResponse = await getAccessToken(channelName, OPT_VIDEO_SWAP_PLAYER_TYPE);
|
||||
if (accessTokenResponse.status === 200) {
|
||||
var accessToken = await accessTokenResponse.json();
|
||||
var urlInfo = new URL('https://usher.ttvnw.net/api/channel/hls/' + channelName + '.m3u8?allow_source=true');
|
||||
urlInfo.searchParams.set('sig', accessToken.data.streamPlaybackAccessToken.signature);
|
||||
urlInfo.searchParams.set('token', accessToken.data.streamPlaybackAccessToken.value);
|
||||
var encodingsM3u8Response = await fetch(urlInfo.href);
|
||||
if (encodingsM3u8Response.status === 200) {
|
||||
// TODO: Maybe look for the most optimal m3u8
|
||||
var encodingsM3u8 = await encodingsM3u8Response.text();
|
||||
var streamM3u8Url = encodingsM3u8.match(/^https:.*\.m3u8$/m)[0];
|
||||
// Maybe this request is a bit unnecessary
|
||||
var streamM3u8Response = await fetch(streamM3u8Url);
|
||||
if (streamM3u8Response.status == 200) {
|
||||
tempM3u8Url = streamM3u8Url;
|
||||
} else {
|
||||
console.log('Backup url request (streamM3u8) failed with ' + streamM3u8Response.status);
|
||||
}
|
||||
} else {
|
||||
console.log('Backup url request (encodingsM3u8) failed with ' + encodingsM3u8Response.status);
|
||||
}
|
||||
} else {
|
||||
console.log('Backup url request (accessToken) failed with ' + accessTokenResponse.status);
|
||||
}
|
||||
if (tempM3u8Url != null) {
|
||||
tempVideo = document.createElement('video');
|
||||
tempVideo.autoplay = true;
|
||||
tempVideo.volume = originalVolume;
|
||||
console.log(disabledVideo);
|
||||
disabledVideo.parentElement.insertBefore(tempVideo, disabledVideo.nextSibling);
|
||||
if (Hls.isSupported()) {
|
||||
tempVideo.hls = new Hls();
|
||||
tempVideo.hls.loadSource(tempM3u8Url);
|
||||
tempVideo.hls.attachMedia(tempVideo);
|
||||
}
|
||||
console.log(tempVideo);
|
||||
console.log(tempM3u8Url);
|
||||
}
|
||||
};
|
||||
createTempStream();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
function pollForAds() {
|
||||
//console.log('pollForAds ' + new Date(Date.now()));
|
||||
//check ad by looking for text banner
|
||||
var adBanner = document.querySelectorAll("span.tw-c-text-overlay");
|
||||
var foundAd = false;
|
||||
for (var i = 0; i < adBanner.length; i++) {
|
||||
if (adBanner[i].attributes["data-test-selector"]) {
|
||||
foundAd = true;
|
||||
foundAdBanner = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (tempVideo && disabledVideo && tempVideo.paused != disabledVideo.paused) {
|
||||
if (disabledVideo.paused) {
|
||||
tempVideo.pause();
|
||||
} else {
|
||||
tempVideo.play();//TODO: Fix issue with Firefox
|
||||
}
|
||||
}
|
||||
if (foundAd) {
|
||||
onFoundAd(false);
|
||||
} else if (!foundAd && foundAdBanner) {
|
||||
if (disabledVideo) {
|
||||
disabledVideo.volume = originalVolume;
|
||||
disabledVideo.style.filter = "";
|
||||
disabledVideo = null;
|
||||
foundAdContainer = false;
|
||||
foundAdBanner = false;
|
||||
if (tempVideo) {
|
||||
tempVideo.hls.stopLoad();
|
||||
tempVideo.remove();
|
||||
tempVideo = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
//setTimeout(pollForAds,100);
|
||||
}
|
||||
function pollForAdsObserver() {
|
||||
pollForAds();
|
||||
var vids = document.getElementsByClassName('video-player');
|
||||
for (var i = 0; i < vids.length; i++) {
|
||||
var observer = new MutationObserver(pollForAds);
|
||||
observer.observe(vids[i], {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: false,
|
||||
characterData: false
|
||||
});
|
||||
}
|
||||
}
|
||||
function reloadTwitchPlayer(isPausePlay) {
|
||||
function reloadTwitchPlayer(isSeek, isPausePlay) {
|
||||
// Taken from ttv-tools / ffz
|
||||
// https://github.com/Nerixyz/ttv-tools/blob/master/src/context/twitch-player.ts
|
||||
// https://github.com/FrankerFaceZ/FrankerFaceZ/blob/master/src/sites/twitch-twilight/modules/player.jsx
|
||||
@ -969,6 +590,11 @@
|
||||
if (player.paused) {
|
||||
return;
|
||||
}
|
||||
if (isSeek) {
|
||||
console.log('Force seek to reset player (hopefully fixing any audio desync)');
|
||||
player.seekTo(0);
|
||||
return;
|
||||
}
|
||||
if (isPausePlay) {
|
||||
player.pause();
|
||||
player.play();
|
||||
@ -992,28 +618,5 @@
|
||||
playerState.setSrc({ isNewMediaPlayerInstance: true, refreshAccessToken: true });// ffz sets this false
|
||||
}
|
||||
window.reloadTwitchPlayer = reloadTwitchPlayer;
|
||||
function onContentLoaded() {
|
||||
// These modes use polling of the ad elements (e.g. ad banner text) to show/hide content
|
||||
if (!OPT_MODE_VIDEO_SWAP && !OPT_MODE_MUTE_BLACK) {
|
||||
return;
|
||||
}
|
||||
if (OPT_MODE_VIDEO_SWAP && typeof Hls === 'undefined') {
|
||||
var script = document.createElement('script');
|
||||
script.src = "https://cdn.jsdelivr.net/npm/hls.js@latest";
|
||||
script.onload = function() {
|
||||
pollForAdsObserver();
|
||||
}
|
||||
document.head.appendChild(script);
|
||||
} else {
|
||||
pollForAdsObserver();
|
||||
}
|
||||
}
|
||||
hookFetch();
|
||||
if (document.readyState === "complete" || document.readyState === "loaded" || document.readyState === "interactive") {
|
||||
onContentLoaded();
|
||||
} else {
|
||||
window.addEventListener("DOMContentLoaded", function() {
|
||||
onContentLoaded();
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -1,5 +0,0 @@
|
||||
OPT_MODE_STRIP_AD_SEGMENTS true
|
||||
OPT_MODE_STRIP_AD_SEGMENTS_NEWEST true
|
||||
OPT_MODE_NOTIFY_ADS_WATCHED true
|
||||
OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST true
|
||||
OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST_AND_RELOAD true
|
File diff suppressed because it is too large
Load Diff
@ -3,66 +3,36 @@ twitch-videoad.js application/javascript
|
||||
if ( /(^|\.)twitch\.tv$/.test(document.location.hostname) === false ) { return; }
|
||||
function declareOptions(scope) {
|
||||
// Options / globals
|
||||
scope.OPT_MODE_MUTE_BLACK = false;
|
||||
scope.OPT_MODE_VIDEO_SWAP = false;
|
||||
scope.OPT_MODE_LOW_RES = false;
|
||||
scope.OPT_MODE_EMBED = false;
|
||||
scope.OPT_ROLLING_DEVICE_ID = true;
|
||||
scope.OPT_MODE_STRIP_AD_SEGMENTS = true;
|
||||
scope.OPT_MODE_NOTIFY_ADS_WATCHED = true;
|
||||
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_MIN_REQUESTS = true;
|
||||
scope.OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT = false;
|
||||
scope.OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT_DELAY = 0;
|
||||
scope.OPT_MODE_PROXY_M3U8 = '';
|
||||
scope.OPT_MODE_PROXY_M3U8_OBFUSCATED = false;
|
||||
scope.OPT_MODE_PROXY_M3U8_FULL_URL = false;
|
||||
scope.OPT_MODE_PROXY_M3U8_PARTIAL_URL = false;
|
||||
scope.OPT_VIDEO_SWAP_PLAYER_TYPE = 'picture-by-picture';
|
||||
scope.OPT_BACKUP_PLAYER_TYPE = 'picture-by-picture';
|
||||
scope.OPT_BACKUP_PLAYER_TYPE = 'picture-by-picture';//'picture-by-picture';'thunderdome';
|
||||
scope.OPT_REGULAR_PLAYER_TYPE = 'site';
|
||||
scope.OPT_INITIAL_M3U8_ATTEMPTS = 1;
|
||||
scope.OPT_ACCESS_TOKEN_PLAYER_TYPE = '';
|
||||
scope.OPT_ACCESS_TOKEN_TEMPLATE = true;
|
||||
scope.OPT_ACCESS_TOKEN_PLAYER_TYPE = 'site';
|
||||
scope.AD_SIGNIFIER = 'stitched-ad';
|
||||
scope.LIVE_SIGNIFIER = ',live';
|
||||
scope.CLIENT_ID = 'kimne78kx3ncx6brgo4mv6wki5h1ko';
|
||||
// Modify options based on mode
|
||||
if (!scope.OPT_ACCESS_TOKEN_PLAYER_TYPE && scope.OPT_MODE_LOW_RES) {
|
||||
scope.OPT_ACCESS_TOKEN_PLAYER_TYPE = 'thunderdome';//480p
|
||||
//scope.OPT_ACCESS_TOKEN_PLAYER_TYPE = 'picture-by-picture';//360p
|
||||
}
|
||||
if (!scope.OPT_ACCESS_TOKEN_PLAYER_TYPE && scope.OPT_MODE_EMBED) {
|
||||
scope.OPT_ACCESS_TOKEN_PLAYER_TYPE = 'embed';
|
||||
}
|
||||
if (scope.OPT_MODE_PROXY_M3U8 && scope.OPT_MODE_PROXY_M3U8_OBFUSCATED) {
|
||||
var newStr = '';
|
||||
scope.OPT_MODE_PROXY_M3U8 = atob(scope.OPT_MODE_PROXY_M3U8);
|
||||
for (var i = 0; i < scope.OPT_MODE_PROXY_M3U8.length; i++) {
|
||||
newStr += String.fromCharCode(scope.OPT_MODE_PROXY_M3U8.charCodeAt(i) ^ scope.CLIENT_ID.charCodeAt(i % scope.CLIENT_ID.length));
|
||||
}
|
||||
scope.OPT_MODE_PROXY_M3U8 = newStr;
|
||||
}
|
||||
// These are only really for Worker scope...
|
||||
scope.StreamInfos = [];
|
||||
scope.StreamInfosByUrl = [];
|
||||
scope.CurrentChannelNameFromM3U8 = null;
|
||||
// Need this in both scopes. Window scope needs to update this to worker scope.
|
||||
scope.gql_device_id = null;
|
||||
scope.gql_device_id_rolling = '';
|
||||
// Rolling device id crap... TODO: improve this
|
||||
var charTable = []; for (var i = 97; i <= 122; i++) { charTable.push(String.fromCharCode(i)); } for (var i = 65; i <= 90; i++) { charTable.push(String.fromCharCode(i)); } for (var i = 48; i <= 57; i++) { charTable.push(String.fromCharCode(i)); }
|
||||
var bs = 'eVI6jx47kJvCFfFowK86eVI6jx47kJvC';
|
||||
var di = (new Date()).getYear() + (new Date()).getMonth() + ((new Date()).getDate() / 7) | 0;
|
||||
for (var i = 0; i < bs.length; i++) {
|
||||
scope.gql_device_id_rolling += charTable[(bs.charCodeAt(i) ^ di) % charTable.length];
|
||||
}
|
||||
}
|
||||
declareOptions(window);
|
||||
////////////////////////////////////
|
||||
// stream swap / stream mute
|
||||
////////////////////////////////////
|
||||
var tempVideo = null;// A temporary video container to hold a lower resolution stream without ads
|
||||
var disabledVideo = null;// The original video element (disabled for the duration of the ad)
|
||||
var originalVolume = 0;// The volume of the original video element
|
||||
var foundAdContainer = false;// Have ad containers been found (the clickable ad)
|
||||
var foundAdBanner = false;// Is the ad banner visible (top left of screen)
|
||||
////////////////////////////////////
|
||||
var notifyAdsWatchedReloadNextTime = 0;
|
||||
var twitchMainWorker = null;
|
||||
const oldWorker = window.Worker;
|
||||
window.Worker = class Worker extends oldWorker {
|
||||
@ -78,9 +48,6 @@ twitch-videoad.js application/javascript
|
||||
}
|
||||
var newBlobStr = `
|
||||
${processM3U8.toString()}
|
||||
${getSegmentInfos.toString()}
|
||||
${getSegmentInfosLines.toString()}
|
||||
${getFinalSegUrl.toString()}
|
||||
${hookWorkerFetch.toString()}
|
||||
${declareOptions.toString()}
|
||||
${getAccessToken.toString()}
|
||||
@ -99,28 +66,34 @@ twitch-videoad.js application/javascript
|
||||
`
|
||||
super(URL.createObjectURL(new Blob([newBlobStr])));
|
||||
twitchMainWorker = this;
|
||||
var adDiv = null;
|
||||
this.onmessage = function(e) {
|
||||
// NOTE: Removed adDiv caching as '.video-player' can change between streams?
|
||||
if (e.data.key == 'UboShowAdBanner') {
|
||||
if (adDiv == null) { adDiv = getAdDiv(); }
|
||||
adDiv.P.textContent = 'Waiting for' + (e.data.isMidroll ? ' midroll' : '') + ' ads to finish...';
|
||||
var adDiv = getAdDiv();
|
||||
if (adDiv != null) {
|
||||
adDiv.P.textContent = 'Blocking' + (e.data.isMidroll ? ' midroll' : '') + ' ads...';
|
||||
adDiv.style.display = 'block';
|
||||
}
|
||||
}
|
||||
else if (e.data.key == 'UboHideAdBanner') {
|
||||
if (adDiv == null) { adDiv = getAdDiv(); }
|
||||
var adDiv = getAdDiv();
|
||||
if (adDiv != null) {
|
||||
adDiv.style.display = 'none';
|
||||
}
|
||||
}
|
||||
else if (e.data.key == 'UboFoundAdSegment') {
|
||||
onFoundAd(e.data.hasLiveSeg, e.data.streamM3u8);
|
||||
onFoundAd(e.data.isMidroll, e.data.streamM3u8);
|
||||
}
|
||||
else if (e.data.key == 'UboChannelNameM3U8Changed') {
|
||||
//console.log('M3U8 channel name changed to ' + e.data.value);
|
||||
notifyAdsWatchedReloadNextTime = 0;
|
||||
}
|
||||
else if (e.data.key == 'UboReloadPlayer') {
|
||||
reloadTwitchPlayer();
|
||||
}
|
||||
else if (e.data.key == 'UboPauseResumePlayer') {
|
||||
reloadTwitchPlayer(false, true);
|
||||
}
|
||||
else if (e.data.key == 'UboSeekPlayer') {
|
||||
reloadTwitchPlayer(true);
|
||||
}
|
||||
}
|
||||
@ -148,164 +121,33 @@ twitch-videoad.js application/javascript
|
||||
req.send();
|
||||
return req.responseText.split("'")[1];
|
||||
}
|
||||
function getSegmentInfosLines(streamInfo, lines) {
|
||||
var result = {};
|
||||
result.segs = [];
|
||||
result.targetDuration = 0;
|
||||
result.elapsedSecs = 0;
|
||||
result.totalSecs = 0;
|
||||
result.hasPrefetch = false;
|
||||
result.hasLiveBeforeAd = true;// This most likely means a midroll (live segments before ad segments)
|
||||
var hasLive = false;
|
||||
for (var i = 0; i < lines.length; i++) {
|
||||
var line = lines[i];
|
||||
if (line.startsWith('#EXT-X-TARGETDURATION')) {
|
||||
result.targetDuration = parseInt(line.split(':')[1]);
|
||||
}
|
||||
if (line.startsWith('#EXT-X-TWITCH-ELAPSED-SECS')) {
|
||||
result.elapsedSecs = line.split(':')[1];
|
||||
}
|
||||
if (line.startsWith('#EXT-X-TWITCH-TOTAL-SECS')) {
|
||||
result.totalSecs = line.split(':')[1];
|
||||
}
|
||||
if (line.startsWith('#EXT-X-DATERANGE')) {
|
||||
var attr = parseAttributes(line);
|
||||
if (attr['CLASS'] && attr['CLASS'].includes('stitched-ad')) {
|
||||
streamInfo.IsMidroll = attr['X-TV-TWITCH-AD-ROLL-TYPE'] == 'MIDROLL';
|
||||
}
|
||||
}
|
||||
if (line.startsWith('http')) {
|
||||
var segInfo = {};
|
||||
segInfo.urlLineIndex = i;
|
||||
segInfo.urlLine = lines[i];
|
||||
segInfo.url = lines[i];
|
||||
segInfo.isPrefetch = false;
|
||||
if (i >= 1 && lines[i - 1].startsWith('#EXTINF')) {
|
||||
//#EXTINF:2.002,DCM|2435256
|
||||
//#EXTINF:2.002,Amazon|8493257483
|
||||
//#EXTINF:2.000,live
|
||||
var splitted = lines[i - 1].split(':')[1].split(',');
|
||||
segInfo.extInfLineIndex = i - 1;
|
||||
segInfo.extInfLine = lines[i - 1];
|
||||
segInfo.extInfLen = splitted[0];//2.000 (can be between 2.000 -> 5.000?)
|
||||
segInfo.extInfType = splitted[1].split('|')[0];//live / Amazon / DCM
|
||||
segInfo.isAd = segInfo.extInfType != 'live';
|
||||
if (segInfo.isAd && !hasLive && result.hasLiveBeforeAd) {
|
||||
result.hasLiveBeforeAd = false;
|
||||
}
|
||||
hasLive = !segInfo.isAd;
|
||||
}
|
||||
if (i >= 2 && lines[i - 2].startsWith('#EXT-X-PROGRAM-DATE-TIME')) {
|
||||
segInfo.dateTimeLineIndex = i - 2;
|
||||
segInfo.dateTimeLine = lines[i - 2];
|
||||
segInfo.dateTime = new Date(lines[i - 2].substr(lines[i - 2].indexOf(':')));
|
||||
}
|
||||
result.segs.push(segInfo);
|
||||
}
|
||||
if (lines[i].startsWith('#EXT-X-TWITCH-PREFETCH:')) {
|
||||
var segInfo = {};
|
||||
segInfo.urlLineIndex = i;
|
||||
segInfo.urlLine = lines[i];
|
||||
segInfo.url = lines[i].substr(lines[i].indexOf(':') + 1);
|
||||
segInfo.isPrefetch = true;
|
||||
result.hasPrefetch = true;
|
||||
result.segs.push(segInfo);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
function getSegmentInfos(streamInfo, lines, backupLines) {
|
||||
var result = {};
|
||||
result.segs = [];
|
||||
result.main = getSegmentInfosLines(streamInfo, lines);
|
||||
result.backup = getSegmentInfosLines(streamInfo, backupLines);
|
||||
// Push all backup segments first
|
||||
for (var i = 0; i < result.backup.segs.length; i++) {
|
||||
var seg = {};
|
||||
seg.backup = result.backup.segs[i];
|
||||
result.segs.push(seg);
|
||||
}
|
||||
// Insert any live main segments
|
||||
// NOTE: We might want to make sure we aren't writing over previously established backup segments (make use of streamInfo.SegmentCache)
|
||||
// NOTE: Midroll ads will result in a very long backup stream. Better logic required for midrolls.
|
||||
for (var i = result.main.segs.length - 1, j = result.segs.length - 1; i >= 0 && j >= 0; i--, j--) {
|
||||
while (result.main.segs[i].isPrefetch) {
|
||||
if (result.segs[j].backup.isPrefetch) {
|
||||
result.segs[j].main = result.main.segs[i];
|
||||
j--;
|
||||
}
|
||||
i--;
|
||||
}
|
||||
if (!result.main.segs[i].isAd) {
|
||||
result.segs[j].main = result.main.segs[i];
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Set the segment cache (currently unused)
|
||||
streamInfo.SegmentCache.length = 0;
|
||||
for (var i = 0; i < result.segs.length; i++) {
|
||||
if (result.segs[i].main != null) {
|
||||
streamInfo.SegmentCache[result.segs[i].main.url] = result.segs[i];
|
||||
}
|
||||
if (result.segs[i].backup != null) {
|
||||
streamInfo.SegmentCache[result.segs[i].backup.url] = result.segs[i];
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
function getFinalSegUrl(lines) {
|
||||
for (var i = lines.length - 1; i >= 0; i--) {
|
||||
if (lines[i].startsWith("http")) {
|
||||
return lines[i];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
async function processM3U8(url, textStr, realFetch) {
|
||||
var streamInfo = StreamInfosByUrl[url];
|
||||
if (streamInfo == null) {
|
||||
console.log('Unknown stream url ' + url);
|
||||
postMessage({key:'UboHideAdBanner'});
|
||||
return textStr;
|
||||
}
|
||||
if (!OPT_MODE_STRIP_AD_SEGMENTS) {
|
||||
return textStr;
|
||||
}
|
||||
var haveAdTags = textStr.includes(AD_SIGNIFIER);
|
||||
if (OPT_MODE_STRIP_AD_SEGMENTS) {
|
||||
var si = StreamInfosByUrl[url];
|
||||
if (si != null) {
|
||||
var lines = textStr.replace('\r', '').split('\n');
|
||||
for (var i = 0; i < lines.length; i++) {
|
||||
if (lines[i].startsWith('#EXT-X-MEDIA-SEQUENCE:')) {
|
||||
var oldRealSeq = si.RealSeqNumber;
|
||||
si.RealSeqNumber = parseInt(/#EXT-X-MEDIA-SEQUENCE:([0-9]*)/.exec(lines[i])[1]);
|
||||
if (!haveAdTags && si.FakeSeqNumber > 0) {
|
||||
/*// We have some sequencing issues... for now lets pause/play and stop modifying sequence.
|
||||
// TODO: Improve sequencing (determine if the m3u8 urls have actually changed)
|
||||
si.FakeSeqNumber = 0;
|
||||
si.BackupSeqNumber = -1;
|
||||
postMessage({key:'UboPauseResumePlayer'});*/
|
||||
// We previously modified the sequence number, we need to keep doing so (alternatively pause/playing might work better
|
||||
var finalSegUrl = getFinalSegUrl(lines);
|
||||
if (finalSegUrl != si.FinalSegUrl) {
|
||||
si.FinalSegUrl = finalSegUrl;
|
||||
// TODO: Maybe only do the jump check if there was an ad recently? (within the last 5 m3u8 requests)
|
||||
var jump = Math.max(0, si.RealSeqNumber - oldRealSeq);
|
||||
if (jump <= 3) {
|
||||
si.FakeSeqNumber += Math.max(0, si.RealSeqNumber - oldRealSeq);
|
||||
} else if (jump > 0) {
|
||||
si.FakeSeqNumber++;
|
||||
}
|
||||
}
|
||||
lines[i] = '#EXT-X-MEDIA-SEQUENCE:' + si.FakeSeqNumber;
|
||||
console.log('No ad, but modifying sequence realSeq:' + si.RealSeqNumber + ' fakeSeq:' + si.FakeSeqNumber);
|
||||
}
|
||||
if (haveAdTags) {
|
||||
var currentResolution = null;
|
||||
for (const [resUrl, resName] of Object.entries(streamInfo.Urls)) {
|
||||
if (resUrl == url) {
|
||||
currentResolution = resName;
|
||||
//console.log(resName);
|
||||
break;
|
||||
}
|
||||
}
|
||||
textStr = lines.join('\n');
|
||||
}
|
||||
}
|
||||
if (haveAdTags) {
|
||||
var si = StreamInfosByUrl[url];
|
||||
if (OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST && si != null && !si.NotifyObservedNoAds) {
|
||||
// We only really know it's fully processed when we no loger see ads
|
||||
// NOTE: We probably shouldn't keep sending these requests. Possibly start sending them after expected ad duration?
|
||||
streamInfo.HadAds = true;
|
||||
streamInfo.IsMidroll = textStr.includes('"MIDROLL"') || textStr.includes('"midroll"');
|
||||
postMessage({key:'UboShowAdBanner',isMidroll:streamInfo.IsMidroll});
|
||||
// Notify ads "watched" TODO: Keep crafting these requests even after ad tags are gone as sometimes it stops too early.
|
||||
if (OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST && streamInfo != null && !streamInfo.NotifyObservedNoAds) {
|
||||
var noAds = false;
|
||||
var encodingsM3u8Response = await realFetch(si.RootM3U8Url);
|
||||
var encodingsM3u8Response = await realFetch(streamInfo.RootM3U8Url);
|
||||
if (encodingsM3u8Response.status === 200) {
|
||||
var encodingsM3u8 = await encodingsM3u8Response.text();
|
||||
var streamM3u8Url = encodingsM3u8.match(/^https:.*\.m3u8$/m)[0];
|
||||
@ -315,35 +157,76 @@ twitch-videoad.js application/javascript
|
||||
console.log('Notify ad watched. Response has ads: ' + !noAds);
|
||||
}
|
||||
}
|
||||
if (si.NotifyFirstTime == 0) {
|
||||
si.NotifyFirstTime = Date.now();
|
||||
if (streamInfo.NotifyFirstTime == 0) {
|
||||
streamInfo.NotifyFirstTime = Date.now();
|
||||
}
|
||||
if (noAds && !si.NotifyObservedNoAds && Date.now() >= si.NotifyFirstTime + OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST_EXPECTED_DURATION) {
|
||||
si.NotifyObservedNoAds = true;
|
||||
}
|
||||
if (noAds && OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST_AND_RELOAD && Date.now() >= si.NotifyFirstTime + OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST_EXPECTED_DURATION) {
|
||||
console.log('Reload player');
|
||||
postMessage({key:'UboHideAdBanner'});
|
||||
postMessage({key:'UboReloadPlayer'});
|
||||
return '';
|
||||
if (noAds && !streamInfo.NotifyObservedNoAds && Date.now() >= streamInfo.NotifyFirstTime + OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST_EXPECTED_DURATION) {
|
||||
streamInfo.NotifyObservedNoAds = true;
|
||||
}
|
||||
}
|
||||
postMessage({
|
||||
key: 'UboFoundAdSegment',
|
||||
hasLiveSeg: textStr.includes(LIVE_SIGNIFIER),
|
||||
isMidroll: streamInfo.IsMidroll,
|
||||
streamM3u8: textStr
|
||||
});
|
||||
if (!streamInfo.IsMidroll && OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT) {
|
||||
return '';
|
||||
}
|
||||
if (!OPT_MODE_STRIP_AD_SEGMENTS) {
|
||||
return textStr;
|
||||
// NOTE: Got a bit of code duplication here, merge Backup/BackupReg in some form.
|
||||
// See if there's clean stream url to fetch (only do this after we have a regular backup, this should save time and you're unlikely to find a clean stream on first request)
|
||||
try {
|
||||
if (streamInfo.BackupRegRes != currentResolution) {
|
||||
streamInfo.BackupRegRes = null;
|
||||
streamInfo.BackupRegUrl = null;
|
||||
}
|
||||
if (haveAdTags) {
|
||||
var streamInfo = StreamInfosByUrl[url];
|
||||
if (streamInfo == null) {
|
||||
console.log('Unknown stream url ' + url);
|
||||
postMessage({key:'UboHideAdBanner'});
|
||||
return textStr;
|
||||
if (currentResolution && streamInfo.BackupRegUrl == null && (streamInfo.BackupFailed || streamInfo.BackupUrl != null)) {
|
||||
var accessTokenResponse = await getAccessToken(streamInfo.ChannelName, OPT_REGULAR_PLAYER_TYPE);
|
||||
if (accessTokenResponse.status === 200) {
|
||||
var accessToken = await accessTokenResponse.json();
|
||||
var urlInfo = new URL('https://usher.ttvnw.net/api/channel/hls/' + streamInfo.ChannelName + '.m3u8' + streamInfo.RootM3U8Params);
|
||||
urlInfo.searchParams.set('sig', accessToken.data.streamPlaybackAccessToken.signature);
|
||||
urlInfo.searchParams.set('token', accessToken.data.streamPlaybackAccessToken.value);
|
||||
var encodingsM3u8Response = await realFetch(urlInfo.href);
|
||||
if (encodingsM3u8Response.status === 200) {
|
||||
var encodingsM3u8 = await encodingsM3u8Response.text();
|
||||
var encodingsLines = encodingsM3u8.replace('\r', '').split('\n');
|
||||
for (var i = 0; i < encodingsLines.length; i++) {
|
||||
if (!encodingsLines[i].startsWith('#') && encodingsLines[i].includes('.m3u8')) {
|
||||
if (i > 0 && encodingsLines[i - 1].startsWith('#EXT-X-STREAM-INF')) {
|
||||
var res = parseAttributes(encodingsLines[i - 1])['RESOLUTION'];
|
||||
if (res && res == currentResolution) {
|
||||
streamInfo.BackupRegUrl = encodingsLines[i];
|
||||
streamInfo.BackupRegRes = currentResolution;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (streamInfo.BackupRegUrl != null) {
|
||||
var backupM3u8 = null;
|
||||
var backupM3u8Response = await realFetch(streamInfo.BackupRegUrl);
|
||||
if (backupM3u8Response.status == 200) {
|
||||
backupM3u8 = await backupM3u8Response.text();
|
||||
}
|
||||
if (backupM3u8 != null && !backupM3u8.includes(AD_SIGNIFIER)) {
|
||||
return backupM3u8;
|
||||
} else {
|
||||
//console.log('Try use regular resolution failed');
|
||||
streamInfo.BackupRegRes = null;
|
||||
streamInfo.BackupRegUrl = null;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
streamInfo.BackupRegRes = null;
|
||||
streamInfo.BackupRegUrl = null;
|
||||
console.log('Fetching backup (regular resolution) m3u8 failed');
|
||||
console.log(err);
|
||||
}
|
||||
// Fetch backup url
|
||||
try {
|
||||
if (!streamInfo.BackupFailed && streamInfo.BackupUrl == null) {
|
||||
// NOTE: We currently don't fetch the oauth_token. You wont be able to access private streams like this.
|
||||
streamInfo.BackupFailed = true;
|
||||
@ -375,87 +258,34 @@ twitch-videoad.js application/javascript
|
||||
}
|
||||
}
|
||||
var backupM3u8 = null;
|
||||
if (streamInfo.BackupUrl != null) {
|
||||
var backupM3u8Response = await realFetch(streamInfo.BackupUrl);
|
||||
if (backupM3u8Response.status == 200) {
|
||||
backupM3u8 = await backupM3u8Response.text();
|
||||
}
|
||||
if (backupM3u8 != null && !backupM3u8.includes(AD_SIGNIFIER)) {
|
||||
return backupM3u8;
|
||||
} else {
|
||||
console.log('Backup m3u8 failed with ' + backupM3u8Response.status);
|
||||
}
|
||||
} catch (err) {
|
||||
console.log('Fetching backup m3u8 failed');
|
||||
console.log(err);
|
||||
}
|
||||
var lines = textStr.replace('\r', '').split('\n');
|
||||
var newLines = [];
|
||||
if (backupM3u8 != null) {
|
||||
var backupLines = backupM3u8.replace('\r', '').split('\n');
|
||||
var segInfos = getSegmentInfos(streamInfo, lines, backupLines);
|
||||
newLines.push('#EXTM3U');
|
||||
newLines.push('#EXT-X-VERSION:3');
|
||||
newLines.push('#EXT-X-TARGETDURATION:' + segInfos.backup.targetDuration);
|
||||
newLines.push('');//#EXT-X-MEDIA-SEQUENCE:' + streamInfo.FakeSeqNumber);
|
||||
// The following will could cause issues when we stop stripping segments
|
||||
//newLines.push('#EXT-X-TWITCH-ELAPSED-SECS:' + streamInfo.backup.elapsedSecs);
|
||||
//newLines.push('#EXT-X-TWITCH-TOTAL-SECS:' + streamInfo.backup.totalSecs);
|
||||
var pushedLiveSegs = 0;
|
||||
var pushedBackupSegs = 0;
|
||||
var pushedPrefetchSegs = 0;
|
||||
for (var i = 0; i < segInfos.segs.length; i++) {
|
||||
var seg = segInfos.segs[i];
|
||||
var segData = null;
|
||||
if (seg.main != null && !seg.main.isAd) {
|
||||
pushedLiveSegs++;
|
||||
segData = seg.main;
|
||||
} else if (seg.backup != null) {
|
||||
pushedBackupSegs++;
|
||||
segData = seg.backup;
|
||||
// Backups failed. Return nothing (this will likely result in spam or player error 2000?).
|
||||
console.log('Ad blocking failed. Stream might break.');
|
||||
return '';
|
||||
}
|
||||
if (segData != null) {
|
||||
if (segData.isPrefetch) {
|
||||
pushedPrefetchSegs++;
|
||||
newLines.push(segData.urlLine);
|
||||
} else {
|
||||
//newLines.push(segData.dateTimeLine);
|
||||
newLines.push(segData.extInfLine);
|
||||
newLines.push(segData.urlLine);
|
||||
}
|
||||
}
|
||||
}
|
||||
var finalSegUrl = getFinalSegUrl(newLines);
|
||||
if (finalSegUrl != streamInfo.FinalSegUrl) {
|
||||
streamInfo.FinalSegUrl = finalSegUrl;
|
||||
streamInfo.FakeSeqNumber++;// We might need something better than this for lager jumps in seq?
|
||||
}
|
||||
newLines[3] = '#EXT-X-MEDIA-SEQUENCE:' + streamInfo.FakeSeqNumber;
|
||||
if (pushedLiveSegs > 0 || pushedBackupSegs > 0) {
|
||||
console.log('liveSegs:' + pushedLiveSegs + ' backupSegs:' + pushedBackupSegs + ' prefetch:' + pushedPrefetchSegs + ' realSeq:' + streamInfo.RealSeqNumber + ' fakeSeq:' + streamInfo.FakeSeqNumber);
|
||||
} else {
|
||||
console.log('TODO: If theres no backup data then we need to provide our own .ts file, otherwise the player will spam m3u8 requests (denial-of-service)');
|
||||
}
|
||||
}
|
||||
textStr = newLines.length > 0 ? newLines.join('\n') : lines.join('\n');
|
||||
//console.log(textStr);
|
||||
if (streamInfo.HadAds) {
|
||||
postMessage({key:'UboSeekPlayer'});
|
||||
streamInfo.HadAds = false;
|
||||
}
|
||||
postMessage({key:'UboHideAdBanner'});
|
||||
return textStr;
|
||||
}
|
||||
function hookWorkerFetch() {
|
||||
var realFetch = fetch;
|
||||
fetch = async function(url, options) {
|
||||
if (typeof url === 'string') {
|
||||
if (OPT_MODE_STRIP_AD_SEGMENTS && url.endsWith('.ts')) {
|
||||
var shownAdBanner = false;
|
||||
for (const [channelName, streamInfo] of Object.entries(StreamInfos)) {
|
||||
var seg = streamInfo.SegmentCache[url];
|
||||
if (seg && !seg.isPrefetch) {
|
||||
if (seg.main == null && seg.backup != null) {
|
||||
shownAdBanner = true;
|
||||
postMessage({key:'UboShowAdBanner',isMidroll:streamInfo.IsMidroll});
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!shownAdBanner) {
|
||||
postMessage({key:'UboHideAdBanner'});
|
||||
}
|
||||
}
|
||||
if (url.endsWith('m3u8')) {
|
||||
return new Promise(function(resolve, reject) {
|
||||
var processAfter = async function(response) {
|
||||
@ -482,26 +312,7 @@ twitch-videoad.js application/javascript
|
||||
});
|
||||
}
|
||||
CurrentChannelNameFromM3U8 = channelName;
|
||||
if (OPT_MODE_PROXY_M3U8) {
|
||||
if (OPT_MODE_PROXY_M3U8_FULL_URL || OPT_MODE_PROXY_M3U8_PARTIAL_URL) {
|
||||
if (OPT_MODE_PROXY_M3U8_FULL_URL) {
|
||||
url = OPT_MODE_PROXY_M3U8 + url;
|
||||
} else {
|
||||
url = OPT_MODE_PROXY_M3U8 + url.split('.m3u8')[0];
|
||||
}
|
||||
if (!OPT_MODE_PROXY_M3U8_OBFUSCATED) {
|
||||
console.log('proxy-m3u8: ' + url);
|
||||
}
|
||||
var opt2 = {};
|
||||
opt2.headers = [];
|
||||
opt2.headers['Access-Control-Allow-Origin'] = '*';// This is to appease the currently set proxy
|
||||
return realFetch(url, opt2);
|
||||
} else {
|
||||
url = OPT_MODE_PROXY_M3U8 + channelName;
|
||||
console.log('proxy-m3u8: ' + url);
|
||||
}
|
||||
}
|
||||
else if (OPT_MODE_STRIP_AD_SEGMENTS) {
|
||||
if (OPT_MODE_STRIP_AD_SEGMENTS) {
|
||||
return new Promise(async function(resolve, reject) {
|
||||
// - First m3u8 request is the m3u8 with the video encodings (360p,480p,720p,etc).
|
||||
// - Second m3u8 request is the m3u8 for the given encoding obtained in the first request. At this point we will know if there's ads.
|
||||
@ -524,13 +335,15 @@ twitch-videoad.js application/javascript
|
||||
}
|
||||
// This might potentially backfire... maybe just add the new urls
|
||||
streamInfo.ChannelName = channelName;
|
||||
streamInfo.Urls = [];
|
||||
streamInfo.Urls = [];// xxx.m3u8 -> "284x160" (resolution)
|
||||
streamInfo.RootM3U8Url = url;
|
||||
streamInfo.RootM3U8Params = (new URL(url)).search;
|
||||
streamInfo.BackupUrl = null;
|
||||
streamInfo.BackupFailed = false;
|
||||
streamInfo.SegmentCache = [];
|
||||
streamInfo.BackupRegUrl = null;
|
||||
streamInfo.BackupRegRes = null;
|
||||
streamInfo.IsMidroll = false;
|
||||
streamInfo.HadAds = false;
|
||||
streamInfo.NotifyFirstTime = 0;
|
||||
streamInfo.NotifyObservedNoAds = false;
|
||||
streamInfo.RealSeqNumber = -1;
|
||||
@ -540,7 +353,13 @@ twitch-videoad.js application/javascript
|
||||
var lines = encodingsM3u8.replace('\r', '').split('\n');
|
||||
for (var i = 0; i < lines.length; i++) {
|
||||
if (!lines[i].startsWith('#') && lines[i].includes('.m3u8')) {
|
||||
streamInfo.Urls.push(lines[i]);
|
||||
streamInfo.Urls[lines[i]] = -1;
|
||||
if (i > 0 && lines[i - 1].startsWith('#EXT-X-STREAM-INF')) {
|
||||
var res = parseAttributes(lines[i - 1])['RESOLUTION'];
|
||||
if (res) {
|
||||
streamInfo.Urls[lines[i]] = res;
|
||||
}
|
||||
}
|
||||
StreamInfosByUrl[lines[i]] = streamInfo;
|
||||
}
|
||||
}
|
||||
@ -581,7 +400,6 @@ twitch-videoad.js application/javascript
|
||||
}
|
||||
function getAccessToken(channelName, playerType, realFetch) {
|
||||
var body = null;
|
||||
if (OPT_ACCESS_TOKEN_TEMPLATE) {
|
||||
var templateQuery = 'query PlaybackAccessToken_Template($login: String!, $isLive: Boolean!, $vodID: ID!, $isVod: Boolean!, $playerType: String!) { streamPlaybackAccessToken(channelName: $login, params: {platform: "web", playerBackend: "mediaplayer", playerType: $playerType}) @include(if: $isLive) { value signature __typename } videoPlaybackAccessToken(id: $vodID, params: {platform: "web", playerBackend: "mediaplayer", playerType: $playerType}) @include(if: $isVod) { value signature __typename }}';
|
||||
body = {
|
||||
operationName: 'PlaybackAccessToken_Template',
|
||||
@ -594,24 +412,6 @@ twitch-videoad.js application/javascript
|
||||
'playerType': playerType
|
||||
}
|
||||
};
|
||||
} else {
|
||||
body = {
|
||||
operationName: 'PlaybackAccessToken',
|
||||
variables: {
|
||||
isLive: true,
|
||||
login: channelName,
|
||||
isVod: false,
|
||||
vodID: '',
|
||||
playerType: playerType
|
||||
},
|
||||
extensions: {
|
||||
persistedQuery: {
|
||||
version: 1,
|
||||
sha256Hash: '0828119ded1c13477966434e15800ff57ddacf13ba1911c129dc2200705b0712',
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
return gqlRequest(body, realFetch);
|
||||
}
|
||||
function gqlRequest(body, realFetch) {
|
||||
@ -621,7 +421,7 @@ twitch-videoad.js application/javascript
|
||||
body: JSON.stringify(body),
|
||||
headers: {
|
||||
'client-id': CLIENT_ID,
|
||||
'X-Device-Id': gql_device_id
|
||||
'X-Device-Id': OPT_ROLLING_DEVICE_ID ? gql_device_id_rolling : gql_device_id
|
||||
}
|
||||
});
|
||||
}
|
||||
@ -691,33 +491,6 @@ twitch-videoad.js application/javascript
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
async function tryNotifyAdsWatchedSigTok(realFetch, i, sig, token) {
|
||||
var tokInfo = JSON.parse(token);
|
||||
var channelName = tokInfo.channel;
|
||||
var urlInfo = new URL('https://usher.ttvnw.net/api/channel/hls/' + channelName + '.m3u8');
|
||||
urlInfo.searchParams.set('sig', sig);
|
||||
urlInfo.searchParams.set('token', token);
|
||||
var encodingsM3u8Response = await realFetch(urlInfo.href);
|
||||
if (encodingsM3u8Response.status === 200) {
|
||||
var encodingsM3u8 = await encodingsM3u8Response.text();
|
||||
var streamM3u8Url = encodingsM3u8.match(/^https:.*\.m3u8$/m)[0];
|
||||
var streamM3u8Response = await realFetch(streamM3u8Url);
|
||||
var streamM3u8 = await streamM3u8Response.text();
|
||||
var res = await tryNotifyAdsWatchedM3U8(streamM3u8);
|
||||
if (i >= 0) {
|
||||
if (res == 1) {
|
||||
console.log("no ad at req " + i);
|
||||
} else {
|
||||
console.log('ad at req ' + i);
|
||||
}
|
||||
}
|
||||
return res;
|
||||
} else {
|
||||
// http error
|
||||
return 2;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
function hookFetch() {
|
||||
var realFetch = window.fetch;
|
||||
window.fetch = function(url, init, ...args) {
|
||||
@ -743,32 +516,12 @@ twitch-videoad.js application/javascript
|
||||
value: gql_device_id
|
||||
});
|
||||
}
|
||||
if (OPT_MODE_NOTIFY_ADS_WATCHED && OPT_MODE_NOTIFY_ADS_WATCHED_INITIAL_ATTEMPTS > 0) {
|
||||
var tok = null, sig = null;
|
||||
if (url.includes('gql') && init && typeof init.body === 'string' && init.body.includes('PlaybackAccessToken')) {
|
||||
return new Promise(async function(resolve, reject) {
|
||||
var response = await realFetch(url, init);
|
||||
if (response.status === 200) {
|
||||
for (var i = 0; i < OPT_MODE_NOTIFY_ADS_WATCHED_INITIAL_ATTEMPTS; i++) {
|
||||
var cloned = response.clone();
|
||||
var responseStr = await cloned.text();
|
||||
var responseData = JSON.parse(responseStr);
|
||||
if (responseData && responseData.data && responseData.data.streamPlaybackAccessToken && responseData.data.streamPlaybackAccessToken.value && responseData.data.streamPlaybackAccessToken.signature) {
|
||||
if (await tryNotifyAdsWatchedSigTok(realFetch, i, responseData.data.streamPlaybackAccessToken.signature, responseData.data.streamPlaybackAccessToken.value) == 1) {
|
||||
resolve(new Response(responseStr));
|
||||
return;
|
||||
if (OPT_ROLLING_DEVICE_ID) {
|
||||
if (typeof init.headers['X-Device-Id'] === 'string') {
|
||||
init.headers['X-Device-Id'] = gql_device_id_rolling;
|
||||
}
|
||||
} else {
|
||||
console.log('malformed');
|
||||
console.log(responseData);
|
||||
break;
|
||||
}
|
||||
}
|
||||
resolve(response);
|
||||
} else {
|
||||
resolve(response);
|
||||
}
|
||||
});
|
||||
if (typeof init.headers['Device-ID'] === 'string') {
|
||||
init.headers['Device-ID'] = gql_device_id_rolling;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -776,148 +529,16 @@ twitch-videoad.js application/javascript
|
||||
return realFetch.apply(this, arguments);
|
||||
}
|
||||
}
|
||||
function onFoundAd(hasLiveSeg, streamM3u8) {
|
||||
if (OPT_MODE_NOTIFY_ADS_WATCHED && OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT && Date.now() >= notifyAdsWatchedReloadNextTime) {
|
||||
function onFoundAd(isMidroll, streamM3u8) {
|
||||
if (OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT && !isMidroll) {
|
||||
console.log('OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT');
|
||||
notifyAdsWatchedReloadNextTime = Date.now() + OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT_DELAY;
|
||||
if (streamM3u8) {
|
||||
tryNotifyAdsWatchedM3U8(streamM3u8);
|
||||
}
|
||||
reloadTwitchPlayer();
|
||||
return;
|
||||
}
|
||||
if (hasLiveSeg) {
|
||||
return;
|
||||
}
|
||||
if (!OPT_MODE_MUTE_BLACK && !OPT_MODE_VIDEO_SWAP) {
|
||||
return;
|
||||
}
|
||||
if (OPT_MODE_VIDEO_SWAP && typeof Hls === 'undefined') {
|
||||
return;
|
||||
}
|
||||
if (!foundAdContainer) {
|
||||
// hide ad contianers
|
||||
var adContainers = document.querySelectorAll('[data-test-selector="sad-overlay"]');
|
||||
for (var i = 0; i < adContainers.length; i++) {
|
||||
adContainers[i].style.display = "none";
|
||||
}
|
||||
foundAdContainer = adContainers.length > 0;
|
||||
}
|
||||
if (disabledVideo) {
|
||||
disabledVideo.volume = 0;
|
||||
} else {
|
||||
//get livestream video element
|
||||
var liveVid = document.getElementsByTagName("video");
|
||||
if (liveVid.length) {
|
||||
disabledVideo = liveVid = liveVid[0];
|
||||
if (!disabledVideo) {
|
||||
return;
|
||||
}
|
||||
//mute
|
||||
originalVolume = liveVid.volume;
|
||||
liveVid.volume = 0;
|
||||
//black out
|
||||
liveVid.style.filter = "brightness(0%)";
|
||||
if (OPT_MODE_VIDEO_SWAP) {
|
||||
var createTempStream = async function() {
|
||||
// Create new video stream TODO: Do this with callbacks
|
||||
var channelName = window.location.pathname.substr(1);// TODO: Better way of determining the channel name
|
||||
var tempM3u8Url = null;
|
||||
var accessTokenResponse = await getAccessToken(channelName, OPT_VIDEO_SWAP_PLAYER_TYPE);
|
||||
if (accessTokenResponse.status === 200) {
|
||||
var accessToken = await accessTokenResponse.json();
|
||||
var urlInfo = new URL('https://usher.ttvnw.net/api/channel/hls/' + channelName + '.m3u8?allow_source=true');
|
||||
urlInfo.searchParams.set('sig', accessToken.data.streamPlaybackAccessToken.signature);
|
||||
urlInfo.searchParams.set('token', accessToken.data.streamPlaybackAccessToken.value);
|
||||
var encodingsM3u8Response = await fetch(urlInfo.href);
|
||||
if (encodingsM3u8Response.status === 200) {
|
||||
// TODO: Maybe look for the most optimal m3u8
|
||||
var encodingsM3u8 = await encodingsM3u8Response.text();
|
||||
var streamM3u8Url = encodingsM3u8.match(/^https:.*\.m3u8$/m)[0];
|
||||
// Maybe this request is a bit unnecessary
|
||||
var streamM3u8Response = await fetch(streamM3u8Url);
|
||||
if (streamM3u8Response.status == 200) {
|
||||
tempM3u8Url = streamM3u8Url;
|
||||
} else {
|
||||
console.log('Backup url request (streamM3u8) failed with ' + streamM3u8Response.status);
|
||||
}
|
||||
} else {
|
||||
console.log('Backup url request (encodingsM3u8) failed with ' + encodingsM3u8Response.status);
|
||||
}
|
||||
} else {
|
||||
console.log('Backup url request (accessToken) failed with ' + accessTokenResponse.status);
|
||||
}
|
||||
if (tempM3u8Url != null) {
|
||||
tempVideo = document.createElement('video');
|
||||
tempVideo.autoplay = true;
|
||||
tempVideo.volume = originalVolume;
|
||||
console.log(disabledVideo);
|
||||
disabledVideo.parentElement.insertBefore(tempVideo, disabledVideo.nextSibling);
|
||||
if (Hls.isSupported()) {
|
||||
tempVideo.hls = new Hls();
|
||||
tempVideo.hls.loadSource(tempM3u8Url);
|
||||
tempVideo.hls.attachMedia(tempVideo);
|
||||
}
|
||||
console.log(tempVideo);
|
||||
console.log(tempM3u8Url);
|
||||
}
|
||||
};
|
||||
createTempStream();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
function pollForAds() {
|
||||
//console.log('pollForAds ' + new Date(Date.now()));
|
||||
//check ad by looking for text banner
|
||||
var adBanner = document.querySelectorAll("span.tw-c-text-overlay");
|
||||
var foundAd = false;
|
||||
for (var i = 0; i < adBanner.length; i++) {
|
||||
if (adBanner[i].attributes["data-test-selector"]) {
|
||||
foundAd = true;
|
||||
foundAdBanner = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (tempVideo && disabledVideo && tempVideo.paused != disabledVideo.paused) {
|
||||
if (disabledVideo.paused) {
|
||||
tempVideo.pause();
|
||||
} else {
|
||||
tempVideo.play();//TODO: Fix issue with Firefox
|
||||
}
|
||||
}
|
||||
if (foundAd) {
|
||||
onFoundAd(false);
|
||||
} else if (!foundAd && foundAdBanner) {
|
||||
if (disabledVideo) {
|
||||
disabledVideo.volume = originalVolume;
|
||||
disabledVideo.style.filter = "";
|
||||
disabledVideo = null;
|
||||
foundAdContainer = false;
|
||||
foundAdBanner = false;
|
||||
if (tempVideo) {
|
||||
tempVideo.hls.stopLoad();
|
||||
tempVideo.remove();
|
||||
tempVideo = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
//setTimeout(pollForAds,100);
|
||||
}
|
||||
function pollForAdsObserver() {
|
||||
pollForAds();
|
||||
var vids = document.getElementsByClassName('video-player');
|
||||
for (var i = 0; i < vids.length; i++) {
|
||||
var observer = new MutationObserver(pollForAds);
|
||||
observer.observe(vids[i], {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: false,
|
||||
characterData: false
|
||||
});
|
||||
}
|
||||
}
|
||||
function reloadTwitchPlayer(isPausePlay) {
|
||||
function reloadTwitchPlayer(isSeek, isPausePlay) {
|
||||
// Taken from ttv-tools / ffz
|
||||
// https://github.com/Nerixyz/ttv-tools/blob/master/src/context/twitch-player.ts
|
||||
// https://github.com/FrankerFaceZ/FrankerFaceZ/blob/master/src/sites/twitch-twilight/modules/player.jsx
|
||||
@ -958,6 +579,11 @@ twitch-videoad.js application/javascript
|
||||
if (player.paused) {
|
||||
return;
|
||||
}
|
||||
if (isSeek) {
|
||||
console.log('Force seek to reset player (hopefully fixing any audio desync)');
|
||||
player.seekTo(0);
|
||||
return;
|
||||
}
|
||||
if (isPausePlay) {
|
||||
player.pause();
|
||||
player.play();
|
||||
@ -981,28 +607,5 @@ twitch-videoad.js application/javascript
|
||||
playerState.setSrc({ isNewMediaPlayerInstance: true, refreshAccessToken: true });// ffz sets this false
|
||||
}
|
||||
window.reloadTwitchPlayer = reloadTwitchPlayer;
|
||||
function onContentLoaded() {
|
||||
// These modes use polling of the ad elements (e.g. ad banner text) to show/hide content
|
||||
if (!OPT_MODE_VIDEO_SWAP && !OPT_MODE_MUTE_BLACK) {
|
||||
return;
|
||||
}
|
||||
if (OPT_MODE_VIDEO_SWAP && typeof Hls === 'undefined') {
|
||||
var script = document.createElement('script');
|
||||
script.src = "https://cdn.jsdelivr.net/npm/hls.js@latest";
|
||||
script.onload = function() {
|
||||
pollForAdsObserver();
|
||||
}
|
||||
document.head.appendChild(script);
|
||||
} else {
|
||||
pollForAdsObserver();
|
||||
}
|
||||
}
|
||||
hookFetch();
|
||||
if (document.readyState === "complete" || document.readyState === "loaded" || document.readyState === "interactive") {
|
||||
onContentLoaded();
|
||||
} else {
|
||||
window.addEventListener("DOMContentLoaded", function() {
|
||||
onContentLoaded();
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
@ -1,4 +1,5 @@
|
||||
OPT_ROLLING_DEVICE_ID true
|
||||
OPT_MODE_STRIP_AD_SEGMENTS true
|
||||
OPT_MODE_STRIP_AD_SEGMENTS_NEWEST true
|
||||
OPT_MODE_NOTIFY_ADS_WATCHED true
|
||||
OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST true
|
||||
OPT_ACCESS_TOKEN_PLAYER_TYPE 'site'
|
@ -1,7 +1,7 @@
|
||||
// ==UserScript==
|
||||
// @name TwitchAdSolutions (notify-strip)
|
||||
// @namespace https://github.com/pixeltris/TwitchAdSolutions
|
||||
// @version 1.4
|
||||
// @version 1.5
|
||||
// @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)
|
||||
@ -14,66 +14,36 @@
|
||||
'use strict';
|
||||
function declareOptions(scope) {
|
||||
// Options / globals
|
||||
scope.OPT_MODE_MUTE_BLACK = false;
|
||||
scope.OPT_MODE_VIDEO_SWAP = false;
|
||||
scope.OPT_MODE_LOW_RES = false;
|
||||
scope.OPT_MODE_EMBED = false;
|
||||
scope.OPT_ROLLING_DEVICE_ID = true;
|
||||
scope.OPT_MODE_STRIP_AD_SEGMENTS = true;
|
||||
scope.OPT_MODE_NOTIFY_ADS_WATCHED = true;
|
||||
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_MIN_REQUESTS = true;
|
||||
scope.OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT = false;
|
||||
scope.OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT_DELAY = 0;
|
||||
scope.OPT_MODE_PROXY_M3U8 = '';
|
||||
scope.OPT_MODE_PROXY_M3U8_OBFUSCATED = false;
|
||||
scope.OPT_MODE_PROXY_M3U8_FULL_URL = false;
|
||||
scope.OPT_MODE_PROXY_M3U8_PARTIAL_URL = false;
|
||||
scope.OPT_VIDEO_SWAP_PLAYER_TYPE = 'picture-by-picture';
|
||||
scope.OPT_BACKUP_PLAYER_TYPE = 'picture-by-picture';
|
||||
scope.OPT_BACKUP_PLAYER_TYPE = 'picture-by-picture';//'picture-by-picture';'thunderdome';
|
||||
scope.OPT_REGULAR_PLAYER_TYPE = 'site';
|
||||
scope.OPT_INITIAL_M3U8_ATTEMPTS = 1;
|
||||
scope.OPT_ACCESS_TOKEN_PLAYER_TYPE = '';
|
||||
scope.OPT_ACCESS_TOKEN_TEMPLATE = true;
|
||||
scope.OPT_ACCESS_TOKEN_PLAYER_TYPE = 'site';
|
||||
scope.AD_SIGNIFIER = 'stitched-ad';
|
||||
scope.LIVE_SIGNIFIER = ',live';
|
||||
scope.CLIENT_ID = 'kimne78kx3ncx6brgo4mv6wki5h1ko';
|
||||
// Modify options based on mode
|
||||
if (!scope.OPT_ACCESS_TOKEN_PLAYER_TYPE && scope.OPT_MODE_LOW_RES) {
|
||||
scope.OPT_ACCESS_TOKEN_PLAYER_TYPE = 'thunderdome';//480p
|
||||
//scope.OPT_ACCESS_TOKEN_PLAYER_TYPE = 'picture-by-picture';//360p
|
||||
}
|
||||
if (!scope.OPT_ACCESS_TOKEN_PLAYER_TYPE && scope.OPT_MODE_EMBED) {
|
||||
scope.OPT_ACCESS_TOKEN_PLAYER_TYPE = 'embed';
|
||||
}
|
||||
if (scope.OPT_MODE_PROXY_M3U8 && scope.OPT_MODE_PROXY_M3U8_OBFUSCATED) {
|
||||
var newStr = '';
|
||||
scope.OPT_MODE_PROXY_M3U8 = atob(scope.OPT_MODE_PROXY_M3U8);
|
||||
for (var i = 0; i < scope.OPT_MODE_PROXY_M3U8.length; i++) {
|
||||
newStr += String.fromCharCode(scope.OPT_MODE_PROXY_M3U8.charCodeAt(i) ^ scope.CLIENT_ID.charCodeAt(i % scope.CLIENT_ID.length));
|
||||
}
|
||||
scope.OPT_MODE_PROXY_M3U8 = newStr;
|
||||
}
|
||||
// These are only really for Worker scope...
|
||||
scope.StreamInfos = [];
|
||||
scope.StreamInfosByUrl = [];
|
||||
scope.CurrentChannelNameFromM3U8 = null;
|
||||
// Need this in both scopes. Window scope needs to update this to worker scope.
|
||||
scope.gql_device_id = null;
|
||||
scope.gql_device_id_rolling = '';
|
||||
// Rolling device id crap... TODO: improve this
|
||||
var charTable = []; for (var i = 97; i <= 122; i++) { charTable.push(String.fromCharCode(i)); } for (var i = 65; i <= 90; i++) { charTable.push(String.fromCharCode(i)); } for (var i = 48; i <= 57; i++) { charTable.push(String.fromCharCode(i)); }
|
||||
var bs = 'eVI6jx47kJvCFfFowK86eVI6jx47kJvC';
|
||||
var di = (new Date()).getYear() + (new Date()).getMonth() + ((new Date()).getDate() / 7) | 0;
|
||||
for (var i = 0; i < bs.length; i++) {
|
||||
scope.gql_device_id_rolling += charTable[(bs.charCodeAt(i) ^ di) % charTable.length];
|
||||
}
|
||||
}
|
||||
declareOptions(window);
|
||||
////////////////////////////////////
|
||||
// stream swap / stream mute
|
||||
////////////////////////////////////
|
||||
var tempVideo = null;// A temporary video container to hold a lower resolution stream without ads
|
||||
var disabledVideo = null;// The original video element (disabled for the duration of the ad)
|
||||
var originalVolume = 0;// The volume of the original video element
|
||||
var foundAdContainer = false;// Have ad containers been found (the clickable ad)
|
||||
var foundAdBanner = false;// Is the ad banner visible (top left of screen)
|
||||
////////////////////////////////////
|
||||
var notifyAdsWatchedReloadNextTime = 0;
|
||||
var twitchMainWorker = null;
|
||||
const oldWorker = window.Worker;
|
||||
window.Worker = class Worker extends oldWorker {
|
||||
@ -89,9 +59,6 @@
|
||||
}
|
||||
var newBlobStr = `
|
||||
${processM3U8.toString()}
|
||||
${getSegmentInfos.toString()}
|
||||
${getSegmentInfosLines.toString()}
|
||||
${getFinalSegUrl.toString()}
|
||||
${hookWorkerFetch.toString()}
|
||||
${declareOptions.toString()}
|
||||
${getAccessToken.toString()}
|
||||
@ -110,28 +77,34 @@
|
||||
`
|
||||
super(URL.createObjectURL(new Blob([newBlobStr])));
|
||||
twitchMainWorker = this;
|
||||
var adDiv = null;
|
||||
this.onmessage = function(e) {
|
||||
// NOTE: Removed adDiv caching as '.video-player' can change between streams?
|
||||
if (e.data.key == 'UboShowAdBanner') {
|
||||
if (adDiv == null) { adDiv = getAdDiv(); }
|
||||
adDiv.P.textContent = 'Waiting for' + (e.data.isMidroll ? ' midroll' : '') + ' ads to finish...';
|
||||
var adDiv = getAdDiv();
|
||||
if (adDiv != null) {
|
||||
adDiv.P.textContent = 'Blocking' + (e.data.isMidroll ? ' midroll' : '') + ' ads...';
|
||||
adDiv.style.display = 'block';
|
||||
}
|
||||
}
|
||||
else if (e.data.key == 'UboHideAdBanner') {
|
||||
if (adDiv == null) { adDiv = getAdDiv(); }
|
||||
var adDiv = getAdDiv();
|
||||
if (adDiv != null) {
|
||||
adDiv.style.display = 'none';
|
||||
}
|
||||
}
|
||||
else if (e.data.key == 'UboFoundAdSegment') {
|
||||
onFoundAd(e.data.hasLiveSeg, e.data.streamM3u8);
|
||||
onFoundAd(e.data.isMidroll, e.data.streamM3u8);
|
||||
}
|
||||
else if (e.data.key == 'UboChannelNameM3U8Changed') {
|
||||
//console.log('M3U8 channel name changed to ' + e.data.value);
|
||||
notifyAdsWatchedReloadNextTime = 0;
|
||||
}
|
||||
else if (e.data.key == 'UboReloadPlayer') {
|
||||
reloadTwitchPlayer();
|
||||
}
|
||||
else if (e.data.key == 'UboPauseResumePlayer') {
|
||||
reloadTwitchPlayer(false, true);
|
||||
}
|
||||
else if (e.data.key == 'UboSeekPlayer') {
|
||||
reloadTwitchPlayer(true);
|
||||
}
|
||||
}
|
||||
@ -159,164 +132,33 @@
|
||||
req.send();
|
||||
return req.responseText.split("'")[1];
|
||||
}
|
||||
function getSegmentInfosLines(streamInfo, lines) {
|
||||
var result = {};
|
||||
result.segs = [];
|
||||
result.targetDuration = 0;
|
||||
result.elapsedSecs = 0;
|
||||
result.totalSecs = 0;
|
||||
result.hasPrefetch = false;
|
||||
result.hasLiveBeforeAd = true;// This most likely means a midroll (live segments before ad segments)
|
||||
var hasLive = false;
|
||||
for (var i = 0; i < lines.length; i++) {
|
||||
var line = lines[i];
|
||||
if (line.startsWith('#EXT-X-TARGETDURATION')) {
|
||||
result.targetDuration = parseInt(line.split(':')[1]);
|
||||
}
|
||||
if (line.startsWith('#EXT-X-TWITCH-ELAPSED-SECS')) {
|
||||
result.elapsedSecs = line.split(':')[1];
|
||||
}
|
||||
if (line.startsWith('#EXT-X-TWITCH-TOTAL-SECS')) {
|
||||
result.totalSecs = line.split(':')[1];
|
||||
}
|
||||
if (line.startsWith('#EXT-X-DATERANGE')) {
|
||||
var attr = parseAttributes(line);
|
||||
if (attr['CLASS'] && attr['CLASS'].includes('stitched-ad')) {
|
||||
streamInfo.IsMidroll = attr['X-TV-TWITCH-AD-ROLL-TYPE'] == 'MIDROLL';
|
||||
}
|
||||
}
|
||||
if (line.startsWith('http')) {
|
||||
var segInfo = {};
|
||||
segInfo.urlLineIndex = i;
|
||||
segInfo.urlLine = lines[i];
|
||||
segInfo.url = lines[i];
|
||||
segInfo.isPrefetch = false;
|
||||
if (i >= 1 && lines[i - 1].startsWith('#EXTINF')) {
|
||||
//#EXTINF:2.002,DCM|2435256
|
||||
//#EXTINF:2.002,Amazon|8493257483
|
||||
//#EXTINF:2.000,live
|
||||
var splitted = lines[i - 1].split(':')[1].split(',');
|
||||
segInfo.extInfLineIndex = i - 1;
|
||||
segInfo.extInfLine = lines[i - 1];
|
||||
segInfo.extInfLen = splitted[0];//2.000 (can be between 2.000 -> 5.000?)
|
||||
segInfo.extInfType = splitted[1].split('|')[0];//live / Amazon / DCM
|
||||
segInfo.isAd = segInfo.extInfType != 'live';
|
||||
if (segInfo.isAd && !hasLive && result.hasLiveBeforeAd) {
|
||||
result.hasLiveBeforeAd = false;
|
||||
}
|
||||
hasLive = !segInfo.isAd;
|
||||
}
|
||||
if (i >= 2 && lines[i - 2].startsWith('#EXT-X-PROGRAM-DATE-TIME')) {
|
||||
segInfo.dateTimeLineIndex = i - 2;
|
||||
segInfo.dateTimeLine = lines[i - 2];
|
||||
segInfo.dateTime = new Date(lines[i - 2].substr(lines[i - 2].indexOf(':')));
|
||||
}
|
||||
result.segs.push(segInfo);
|
||||
}
|
||||
if (lines[i].startsWith('#EXT-X-TWITCH-PREFETCH:')) {
|
||||
var segInfo = {};
|
||||
segInfo.urlLineIndex = i;
|
||||
segInfo.urlLine = lines[i];
|
||||
segInfo.url = lines[i].substr(lines[i].indexOf(':') + 1);
|
||||
segInfo.isPrefetch = true;
|
||||
result.hasPrefetch = true;
|
||||
result.segs.push(segInfo);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
function getSegmentInfos(streamInfo, lines, backupLines) {
|
||||
var result = {};
|
||||
result.segs = [];
|
||||
result.main = getSegmentInfosLines(streamInfo, lines);
|
||||
result.backup = getSegmentInfosLines(streamInfo, backupLines);
|
||||
// Push all backup segments first
|
||||
for (var i = 0; i < result.backup.segs.length; i++) {
|
||||
var seg = {};
|
||||
seg.backup = result.backup.segs[i];
|
||||
result.segs.push(seg);
|
||||
}
|
||||
// Insert any live main segments
|
||||
// NOTE: We might want to make sure we aren't writing over previously established backup segments (make use of streamInfo.SegmentCache)
|
||||
// NOTE: Midroll ads will result in a very long backup stream. Better logic required for midrolls.
|
||||
for (var i = result.main.segs.length - 1, j = result.segs.length - 1; i >= 0 && j >= 0; i--, j--) {
|
||||
while (result.main.segs[i].isPrefetch) {
|
||||
if (result.segs[j].backup.isPrefetch) {
|
||||
result.segs[j].main = result.main.segs[i];
|
||||
j--;
|
||||
}
|
||||
i--;
|
||||
}
|
||||
if (!result.main.segs[i].isAd) {
|
||||
result.segs[j].main = result.main.segs[i];
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Set the segment cache (currently unused)
|
||||
streamInfo.SegmentCache.length = 0;
|
||||
for (var i = 0; i < result.segs.length; i++) {
|
||||
if (result.segs[i].main != null) {
|
||||
streamInfo.SegmentCache[result.segs[i].main.url] = result.segs[i];
|
||||
}
|
||||
if (result.segs[i].backup != null) {
|
||||
streamInfo.SegmentCache[result.segs[i].backup.url] = result.segs[i];
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
function getFinalSegUrl(lines) {
|
||||
for (var i = lines.length - 1; i >= 0; i--) {
|
||||
if (lines[i].startsWith("http")) {
|
||||
return lines[i];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
async function processM3U8(url, textStr, realFetch) {
|
||||
var streamInfo = StreamInfosByUrl[url];
|
||||
if (streamInfo == null) {
|
||||
console.log('Unknown stream url ' + url);
|
||||
postMessage({key:'UboHideAdBanner'});
|
||||
return textStr;
|
||||
}
|
||||
if (!OPT_MODE_STRIP_AD_SEGMENTS) {
|
||||
return textStr;
|
||||
}
|
||||
var haveAdTags = textStr.includes(AD_SIGNIFIER);
|
||||
if (OPT_MODE_STRIP_AD_SEGMENTS) {
|
||||
var si = StreamInfosByUrl[url];
|
||||
if (si != null) {
|
||||
var lines = textStr.replace('\r', '').split('\n');
|
||||
for (var i = 0; i < lines.length; i++) {
|
||||
if (lines[i].startsWith('#EXT-X-MEDIA-SEQUENCE:')) {
|
||||
var oldRealSeq = si.RealSeqNumber;
|
||||
si.RealSeqNumber = parseInt(/#EXT-X-MEDIA-SEQUENCE:([0-9]*)/.exec(lines[i])[1]);
|
||||
if (!haveAdTags && si.FakeSeqNumber > 0) {
|
||||
/*// We have some sequencing issues... for now lets pause/play and stop modifying sequence.
|
||||
// TODO: Improve sequencing (determine if the m3u8 urls have actually changed)
|
||||
si.FakeSeqNumber = 0;
|
||||
si.BackupSeqNumber = -1;
|
||||
postMessage({key:'UboPauseResumePlayer'});*/
|
||||
// We previously modified the sequence number, we need to keep doing so (alternatively pause/playing might work better
|
||||
var finalSegUrl = getFinalSegUrl(lines);
|
||||
if (finalSegUrl != si.FinalSegUrl) {
|
||||
si.FinalSegUrl = finalSegUrl;
|
||||
// TODO: Maybe only do the jump check if there was an ad recently? (within the last 5 m3u8 requests)
|
||||
var jump = Math.max(0, si.RealSeqNumber - oldRealSeq);
|
||||
if (jump <= 3) {
|
||||
si.FakeSeqNumber += Math.max(0, si.RealSeqNumber - oldRealSeq);
|
||||
} else if (jump > 0) {
|
||||
si.FakeSeqNumber++;
|
||||
}
|
||||
}
|
||||
lines[i] = '#EXT-X-MEDIA-SEQUENCE:' + si.FakeSeqNumber;
|
||||
console.log('No ad, but modifying sequence realSeq:' + si.RealSeqNumber + ' fakeSeq:' + si.FakeSeqNumber);
|
||||
}
|
||||
if (haveAdTags) {
|
||||
var currentResolution = null;
|
||||
for (const [resUrl, resName] of Object.entries(streamInfo.Urls)) {
|
||||
if (resUrl == url) {
|
||||
currentResolution = resName;
|
||||
//console.log(resName);
|
||||
break;
|
||||
}
|
||||
}
|
||||
textStr = lines.join('\n');
|
||||
}
|
||||
}
|
||||
if (haveAdTags) {
|
||||
var si = StreamInfosByUrl[url];
|
||||
if (OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST && si != null && !si.NotifyObservedNoAds) {
|
||||
// We only really know it's fully processed when we no loger see ads
|
||||
// NOTE: We probably shouldn't keep sending these requests. Possibly start sending them after expected ad duration?
|
||||
streamInfo.HadAds = true;
|
||||
streamInfo.IsMidroll = textStr.includes('"MIDROLL"') || textStr.includes('"midroll"');
|
||||
postMessage({key:'UboShowAdBanner',isMidroll:streamInfo.IsMidroll});
|
||||
// Notify ads "watched" TODO: Keep crafting these requests even after ad tags are gone as sometimes it stops too early.
|
||||
if (OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST && streamInfo != null && !streamInfo.NotifyObservedNoAds) {
|
||||
var noAds = false;
|
||||
var encodingsM3u8Response = await realFetch(si.RootM3U8Url);
|
||||
var encodingsM3u8Response = await realFetch(streamInfo.RootM3U8Url);
|
||||
if (encodingsM3u8Response.status === 200) {
|
||||
var encodingsM3u8 = await encodingsM3u8Response.text();
|
||||
var streamM3u8Url = encodingsM3u8.match(/^https:.*\.m3u8$/m)[0];
|
||||
@ -326,35 +168,76 @@
|
||||
console.log('Notify ad watched. Response has ads: ' + !noAds);
|
||||
}
|
||||
}
|
||||
if (si.NotifyFirstTime == 0) {
|
||||
si.NotifyFirstTime = Date.now();
|
||||
if (streamInfo.NotifyFirstTime == 0) {
|
||||
streamInfo.NotifyFirstTime = Date.now();
|
||||
}
|
||||
if (noAds && !si.NotifyObservedNoAds && Date.now() >= si.NotifyFirstTime + OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST_EXPECTED_DURATION) {
|
||||
si.NotifyObservedNoAds = true;
|
||||
}
|
||||
if (noAds && OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST_AND_RELOAD && Date.now() >= si.NotifyFirstTime + OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST_EXPECTED_DURATION) {
|
||||
console.log('Reload player');
|
||||
postMessage({key:'UboHideAdBanner'});
|
||||
postMessage({key:'UboReloadPlayer'});
|
||||
return '';
|
||||
if (noAds && !streamInfo.NotifyObservedNoAds && Date.now() >= streamInfo.NotifyFirstTime + OPT_MODE_NOTIFY_ADS_WATCHED_PERSIST_EXPECTED_DURATION) {
|
||||
streamInfo.NotifyObservedNoAds = true;
|
||||
}
|
||||
}
|
||||
postMessage({
|
||||
key: 'UboFoundAdSegment',
|
||||
hasLiveSeg: textStr.includes(LIVE_SIGNIFIER),
|
||||
isMidroll: streamInfo.IsMidroll,
|
||||
streamM3u8: textStr
|
||||
});
|
||||
if (!streamInfo.IsMidroll && OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT) {
|
||||
return '';
|
||||
}
|
||||
if (!OPT_MODE_STRIP_AD_SEGMENTS) {
|
||||
return textStr;
|
||||
// NOTE: Got a bit of code duplication here, merge Backup/BackupReg in some form.
|
||||
// See if there's clean stream url to fetch (only do this after we have a regular backup, this should save time and you're unlikely to find a clean stream on first request)
|
||||
try {
|
||||
if (streamInfo.BackupRegRes != currentResolution) {
|
||||
streamInfo.BackupRegRes = null;
|
||||
streamInfo.BackupRegUrl = null;
|
||||
}
|
||||
if (haveAdTags) {
|
||||
var streamInfo = StreamInfosByUrl[url];
|
||||
if (streamInfo == null) {
|
||||
console.log('Unknown stream url ' + url);
|
||||
postMessage({key:'UboHideAdBanner'});
|
||||
return textStr;
|
||||
if (currentResolution && streamInfo.BackupRegUrl == null && (streamInfo.BackupFailed || streamInfo.BackupUrl != null)) {
|
||||
var accessTokenResponse = await getAccessToken(streamInfo.ChannelName, OPT_REGULAR_PLAYER_TYPE);
|
||||
if (accessTokenResponse.status === 200) {
|
||||
var accessToken = await accessTokenResponse.json();
|
||||
var urlInfo = new URL('https://usher.ttvnw.net/api/channel/hls/' + streamInfo.ChannelName + '.m3u8' + streamInfo.RootM3U8Params);
|
||||
urlInfo.searchParams.set('sig', accessToken.data.streamPlaybackAccessToken.signature);
|
||||
urlInfo.searchParams.set('token', accessToken.data.streamPlaybackAccessToken.value);
|
||||
var encodingsM3u8Response = await realFetch(urlInfo.href);
|
||||
if (encodingsM3u8Response.status === 200) {
|
||||
var encodingsM3u8 = await encodingsM3u8Response.text();
|
||||
var encodingsLines = encodingsM3u8.replace('\r', '').split('\n');
|
||||
for (var i = 0; i < encodingsLines.length; i++) {
|
||||
if (!encodingsLines[i].startsWith('#') && encodingsLines[i].includes('.m3u8')) {
|
||||
if (i > 0 && encodingsLines[i - 1].startsWith('#EXT-X-STREAM-INF')) {
|
||||
var res = parseAttributes(encodingsLines[i - 1])['RESOLUTION'];
|
||||
if (res && res == currentResolution) {
|
||||
streamInfo.BackupRegUrl = encodingsLines[i];
|
||||
streamInfo.BackupRegRes = currentResolution;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (streamInfo.BackupRegUrl != null) {
|
||||
var backupM3u8 = null;
|
||||
var backupM3u8Response = await realFetch(streamInfo.BackupRegUrl);
|
||||
if (backupM3u8Response.status == 200) {
|
||||
backupM3u8 = await backupM3u8Response.text();
|
||||
}
|
||||
if (backupM3u8 != null && !backupM3u8.includes(AD_SIGNIFIER)) {
|
||||
return backupM3u8;
|
||||
} else {
|
||||
//console.log('Try use regular resolution failed');
|
||||
streamInfo.BackupRegRes = null;
|
||||
streamInfo.BackupRegUrl = null;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
streamInfo.BackupRegRes = null;
|
||||
streamInfo.BackupRegUrl = null;
|
||||
console.log('Fetching backup (regular resolution) m3u8 failed');
|
||||
console.log(err);
|
||||
}
|
||||
// Fetch backup url
|
||||
try {
|
||||
if (!streamInfo.BackupFailed && streamInfo.BackupUrl == null) {
|
||||
// NOTE: We currently don't fetch the oauth_token. You wont be able to access private streams like this.
|
||||
streamInfo.BackupFailed = true;
|
||||
@ -386,87 +269,34 @@
|
||||
}
|
||||
}
|
||||
var backupM3u8 = null;
|
||||
if (streamInfo.BackupUrl != null) {
|
||||
var backupM3u8Response = await realFetch(streamInfo.BackupUrl);
|
||||
if (backupM3u8Response.status == 200) {
|
||||
backupM3u8 = await backupM3u8Response.text();
|
||||
}
|
||||
if (backupM3u8 != null && !backupM3u8.includes(AD_SIGNIFIER)) {
|
||||
return backupM3u8;
|
||||
} else {
|
||||
console.log('Backup m3u8 failed with ' + backupM3u8Response.status);
|
||||
}
|
||||
} catch (err) {
|
||||
console.log('Fetching backup m3u8 failed');
|
||||
console.log(err);
|
||||
}
|
||||
var lines = textStr.replace('\r', '').split('\n');
|
||||
var newLines = [];
|
||||
if (backupM3u8 != null) {
|
||||
var backupLines = backupM3u8.replace('\r', '').split('\n');
|
||||
var segInfos = getSegmentInfos(streamInfo, lines, backupLines);
|
||||
newLines.push('#EXTM3U');
|
||||
newLines.push('#EXT-X-VERSION:3');
|
||||
newLines.push('#EXT-X-TARGETDURATION:' + segInfos.backup.targetDuration);
|
||||
newLines.push('');//#EXT-X-MEDIA-SEQUENCE:' + streamInfo.FakeSeqNumber);
|
||||
// The following will could cause issues when we stop stripping segments
|
||||
//newLines.push('#EXT-X-TWITCH-ELAPSED-SECS:' + streamInfo.backup.elapsedSecs);
|
||||
//newLines.push('#EXT-X-TWITCH-TOTAL-SECS:' + streamInfo.backup.totalSecs);
|
||||
var pushedLiveSegs = 0;
|
||||
var pushedBackupSegs = 0;
|
||||
var pushedPrefetchSegs = 0;
|
||||
for (var i = 0; i < segInfos.segs.length; i++) {
|
||||
var seg = segInfos.segs[i];
|
||||
var segData = null;
|
||||
if (seg.main != null && !seg.main.isAd) {
|
||||
pushedLiveSegs++;
|
||||
segData = seg.main;
|
||||
} else if (seg.backup != null) {
|
||||
pushedBackupSegs++;
|
||||
segData = seg.backup;
|
||||
// Backups failed. Return nothing (this will likely result in spam or player error 2000?).
|
||||
console.log('Ad blocking failed. Stream might break.');
|
||||
return '';
|
||||
}
|
||||
if (segData != null) {
|
||||
if (segData.isPrefetch) {
|
||||
pushedPrefetchSegs++;
|
||||
newLines.push(segData.urlLine);
|
||||
} else {
|
||||
//newLines.push(segData.dateTimeLine);
|
||||
newLines.push(segData.extInfLine);
|
||||
newLines.push(segData.urlLine);
|
||||
}
|
||||
}
|
||||
}
|
||||
var finalSegUrl = getFinalSegUrl(newLines);
|
||||
if (finalSegUrl != streamInfo.FinalSegUrl) {
|
||||
streamInfo.FinalSegUrl = finalSegUrl;
|
||||
streamInfo.FakeSeqNumber++;// We might need something better than this for lager jumps in seq?
|
||||
}
|
||||
newLines[3] = '#EXT-X-MEDIA-SEQUENCE:' + streamInfo.FakeSeqNumber;
|
||||
if (pushedLiveSegs > 0 || pushedBackupSegs > 0) {
|
||||
console.log('liveSegs:' + pushedLiveSegs + ' backupSegs:' + pushedBackupSegs + ' prefetch:' + pushedPrefetchSegs + ' realSeq:' + streamInfo.RealSeqNumber + ' fakeSeq:' + streamInfo.FakeSeqNumber);
|
||||
} else {
|
||||
console.log('TODO: If theres no backup data then we need to provide our own .ts file, otherwise the player will spam m3u8 requests (denial-of-service)');
|
||||
}
|
||||
}
|
||||
textStr = newLines.length > 0 ? newLines.join('\n') : lines.join('\n');
|
||||
//console.log(textStr);
|
||||
if (streamInfo.HadAds) {
|
||||
postMessage({key:'UboSeekPlayer'});
|
||||
streamInfo.HadAds = false;
|
||||
}
|
||||
postMessage({key:'UboHideAdBanner'});
|
||||
return textStr;
|
||||
}
|
||||
function hookWorkerFetch() {
|
||||
var realFetch = fetch;
|
||||
fetch = async function(url, options) {
|
||||
if (typeof url === 'string') {
|
||||
if (OPT_MODE_STRIP_AD_SEGMENTS && url.endsWith('.ts')) {
|
||||
var shownAdBanner = false;
|
||||
for (const [channelName, streamInfo] of Object.entries(StreamInfos)) {
|
||||
var seg = streamInfo.SegmentCache[url];
|
||||
if (seg && !seg.isPrefetch) {
|
||||
if (seg.main == null && seg.backup != null) {
|
||||
shownAdBanner = true;
|
||||
postMessage({key:'UboShowAdBanner',isMidroll:streamInfo.IsMidroll});
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!shownAdBanner) {
|
||||
postMessage({key:'UboHideAdBanner'});
|
||||
}
|
||||
}
|
||||
if (url.endsWith('m3u8')) {
|
||||
return new Promise(function(resolve, reject) {
|
||||
var processAfter = async function(response) {
|
||||
@ -493,26 +323,7 @@
|
||||
});
|
||||
}
|
||||
CurrentChannelNameFromM3U8 = channelName;
|
||||
if (OPT_MODE_PROXY_M3U8) {
|
||||
if (OPT_MODE_PROXY_M3U8_FULL_URL || OPT_MODE_PROXY_M3U8_PARTIAL_URL) {
|
||||
if (OPT_MODE_PROXY_M3U8_FULL_URL) {
|
||||
url = OPT_MODE_PROXY_M3U8 + url;
|
||||
} else {
|
||||
url = OPT_MODE_PROXY_M3U8 + url.split('.m3u8')[0];
|
||||
}
|
||||
if (!OPT_MODE_PROXY_M3U8_OBFUSCATED) {
|
||||
console.log('proxy-m3u8: ' + url);
|
||||
}
|
||||
var opt2 = {};
|
||||
opt2.headers = [];
|
||||
opt2.headers['Access-Control-Allow-Origin'] = '*';// This is to appease the currently set proxy
|
||||
return realFetch(url, opt2);
|
||||
} else {
|
||||
url = OPT_MODE_PROXY_M3U8 + channelName;
|
||||
console.log('proxy-m3u8: ' + url);
|
||||
}
|
||||
}
|
||||
else if (OPT_MODE_STRIP_AD_SEGMENTS) {
|
||||
if (OPT_MODE_STRIP_AD_SEGMENTS) {
|
||||
return new Promise(async function(resolve, reject) {
|
||||
// - First m3u8 request is the m3u8 with the video encodings (360p,480p,720p,etc).
|
||||
// - Second m3u8 request is the m3u8 for the given encoding obtained in the first request. At this point we will know if there's ads.
|
||||
@ -535,13 +346,15 @@
|
||||
}
|
||||
// This might potentially backfire... maybe just add the new urls
|
||||
streamInfo.ChannelName = channelName;
|
||||
streamInfo.Urls = [];
|
||||
streamInfo.Urls = [];// xxx.m3u8 -> "284x160" (resolution)
|
||||
streamInfo.RootM3U8Url = url;
|
||||
streamInfo.RootM3U8Params = (new URL(url)).search;
|
||||
streamInfo.BackupUrl = null;
|
||||
streamInfo.BackupFailed = false;
|
||||
streamInfo.SegmentCache = [];
|
||||
streamInfo.BackupRegUrl = null;
|
||||
streamInfo.BackupRegRes = null;
|
||||
streamInfo.IsMidroll = false;
|
||||
streamInfo.HadAds = false;
|
||||
streamInfo.NotifyFirstTime = 0;
|
||||
streamInfo.NotifyObservedNoAds = false;
|
||||
streamInfo.RealSeqNumber = -1;
|
||||
@ -551,7 +364,13 @@
|
||||
var lines = encodingsM3u8.replace('\r', '').split('\n');
|
||||
for (var i = 0; i < lines.length; i++) {
|
||||
if (!lines[i].startsWith('#') && lines[i].includes('.m3u8')) {
|
||||
streamInfo.Urls.push(lines[i]);
|
||||
streamInfo.Urls[lines[i]] = -1;
|
||||
if (i > 0 && lines[i - 1].startsWith('#EXT-X-STREAM-INF')) {
|
||||
var res = parseAttributes(lines[i - 1])['RESOLUTION'];
|
||||
if (res) {
|
||||
streamInfo.Urls[lines[i]] = res;
|
||||
}
|
||||
}
|
||||
StreamInfosByUrl[lines[i]] = streamInfo;
|
||||
}
|
||||
}
|
||||
@ -592,7 +411,6 @@
|
||||
}
|
||||
function getAccessToken(channelName, playerType, realFetch) {
|
||||
var body = null;
|
||||
if (OPT_ACCESS_TOKEN_TEMPLATE) {
|
||||
var templateQuery = 'query PlaybackAccessToken_Template($login: String!, $isLive: Boolean!, $vodID: ID!, $isVod: Boolean!, $playerType: String!) { streamPlaybackAccessToken(channelName: $login, params: {platform: "web", playerBackend: "mediaplayer", playerType: $playerType}) @include(if: $isLive) { value signature __typename } videoPlaybackAccessToken(id: $vodID, params: {platform: "web", playerBackend: "mediaplayer", playerType: $playerType}) @include(if: $isVod) { value signature __typename }}';
|
||||
body = {
|
||||
operationName: 'PlaybackAccessToken_Template',
|
||||
@ -605,24 +423,6 @@
|
||||
'playerType': playerType
|
||||
}
|
||||
};
|
||||
} else {
|
||||
body = {
|
||||
operationName: 'PlaybackAccessToken',
|
||||
variables: {
|
||||
isLive: true,
|
||||
login: channelName,
|
||||
isVod: false,
|
||||
vodID: '',
|
||||
playerType: playerType
|
||||
},
|
||||
extensions: {
|
||||
persistedQuery: {
|
||||
version: 1,
|
||||
sha256Hash: '0828119ded1c13477966434e15800ff57ddacf13ba1911c129dc2200705b0712',
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
return gqlRequest(body, realFetch);
|
||||
}
|
||||
function gqlRequest(body, realFetch) {
|
||||
@ -632,7 +432,7 @@
|
||||
body: JSON.stringify(body),
|
||||
headers: {
|
||||
'client-id': CLIENT_ID,
|
||||
'X-Device-Id': gql_device_id
|
||||
'X-Device-Id': OPT_ROLLING_DEVICE_ID ? gql_device_id_rolling : gql_device_id
|
||||
}
|
||||
});
|
||||
}
|
||||
@ -702,33 +502,6 @@
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
async function tryNotifyAdsWatchedSigTok(realFetch, i, sig, token) {
|
||||
var tokInfo = JSON.parse(token);
|
||||
var channelName = tokInfo.channel;
|
||||
var urlInfo = new URL('https://usher.ttvnw.net/api/channel/hls/' + channelName + '.m3u8');
|
||||
urlInfo.searchParams.set('sig', sig);
|
||||
urlInfo.searchParams.set('token', token);
|
||||
var encodingsM3u8Response = await realFetch(urlInfo.href);
|
||||
if (encodingsM3u8Response.status === 200) {
|
||||
var encodingsM3u8 = await encodingsM3u8Response.text();
|
||||
var streamM3u8Url = encodingsM3u8.match(/^https:.*\.m3u8$/m)[0];
|
||||
var streamM3u8Response = await realFetch(streamM3u8Url);
|
||||
var streamM3u8 = await streamM3u8Response.text();
|
||||
var res = await tryNotifyAdsWatchedM3U8(streamM3u8);
|
||||
if (i >= 0) {
|
||||
if (res == 1) {
|
||||
console.log("no ad at req " + i);
|
||||
} else {
|
||||
console.log('ad at req ' + i);
|
||||
}
|
||||
}
|
||||
return res;
|
||||
} else {
|
||||
// http error
|
||||
return 2;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
function hookFetch() {
|
||||
var realFetch = window.fetch;
|
||||
window.fetch = function(url, init, ...args) {
|
||||
@ -754,32 +527,12 @@
|
||||
value: gql_device_id
|
||||
});
|
||||
}
|
||||
if (OPT_MODE_NOTIFY_ADS_WATCHED && OPT_MODE_NOTIFY_ADS_WATCHED_INITIAL_ATTEMPTS > 0) {
|
||||
var tok = null, sig = null;
|
||||
if (url.includes('gql') && init && typeof init.body === 'string' && init.body.includes('PlaybackAccessToken')) {
|
||||
return new Promise(async function(resolve, reject) {
|
||||
var response = await realFetch(url, init);
|
||||
if (response.status === 200) {
|
||||
for (var i = 0; i < OPT_MODE_NOTIFY_ADS_WATCHED_INITIAL_ATTEMPTS; i++) {
|
||||
var cloned = response.clone();
|
||||
var responseStr = await cloned.text();
|
||||
var responseData = JSON.parse(responseStr);
|
||||
if (responseData && responseData.data && responseData.data.streamPlaybackAccessToken && responseData.data.streamPlaybackAccessToken.value && responseData.data.streamPlaybackAccessToken.signature) {
|
||||
if (await tryNotifyAdsWatchedSigTok(realFetch, i, responseData.data.streamPlaybackAccessToken.signature, responseData.data.streamPlaybackAccessToken.value) == 1) {
|
||||
resolve(new Response(responseStr));
|
||||
return;
|
||||
if (OPT_ROLLING_DEVICE_ID) {
|
||||
if (typeof init.headers['X-Device-Id'] === 'string') {
|
||||
init.headers['X-Device-Id'] = gql_device_id_rolling;
|
||||
}
|
||||
} else {
|
||||
console.log('malformed');
|
||||
console.log(responseData);
|
||||
break;
|
||||
}
|
||||
}
|
||||
resolve(response);
|
||||
} else {
|
||||
resolve(response);
|
||||
}
|
||||
});
|
||||
if (typeof init.headers['Device-ID'] === 'string') {
|
||||
init.headers['Device-ID'] = gql_device_id_rolling;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -787,148 +540,16 @@
|
||||
return realFetch.apply(this, arguments);
|
||||
}
|
||||
}
|
||||
function onFoundAd(hasLiveSeg, streamM3u8) {
|
||||
if (OPT_MODE_NOTIFY_ADS_WATCHED && OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT && Date.now() >= notifyAdsWatchedReloadNextTime) {
|
||||
function onFoundAd(isMidroll, streamM3u8) {
|
||||
if (OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT && !isMidroll) {
|
||||
console.log('OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT');
|
||||
notifyAdsWatchedReloadNextTime = Date.now() + OPT_MODE_NOTIFY_ADS_WATCHED_RELOAD_PLAYER_ON_AD_SEGMENT_DELAY;
|
||||
if (streamM3u8) {
|
||||
tryNotifyAdsWatchedM3U8(streamM3u8);
|
||||
}
|
||||
reloadTwitchPlayer();
|
||||
return;
|
||||
}
|
||||
if (hasLiveSeg) {
|
||||
return;
|
||||
}
|
||||
if (!OPT_MODE_MUTE_BLACK && !OPT_MODE_VIDEO_SWAP) {
|
||||
return;
|
||||
}
|
||||
if (OPT_MODE_VIDEO_SWAP && typeof Hls === 'undefined') {
|
||||
return;
|
||||
}
|
||||
if (!foundAdContainer) {
|
||||
// hide ad contianers
|
||||
var adContainers = document.querySelectorAll('[data-test-selector="sad-overlay"]');
|
||||
for (var i = 0; i < adContainers.length; i++) {
|
||||
adContainers[i].style.display = "none";
|
||||
}
|
||||
foundAdContainer = adContainers.length > 0;
|
||||
}
|
||||
if (disabledVideo) {
|
||||
disabledVideo.volume = 0;
|
||||
} else {
|
||||
//get livestream video element
|
||||
var liveVid = document.getElementsByTagName("video");
|
||||
if (liveVid.length) {
|
||||
disabledVideo = liveVid = liveVid[0];
|
||||
if (!disabledVideo) {
|
||||
return;
|
||||
}
|
||||
//mute
|
||||
originalVolume = liveVid.volume;
|
||||
liveVid.volume = 0;
|
||||
//black out
|
||||
liveVid.style.filter = "brightness(0%)";
|
||||
if (OPT_MODE_VIDEO_SWAP) {
|
||||
var createTempStream = async function() {
|
||||
// Create new video stream TODO: Do this with callbacks
|
||||
var channelName = window.location.pathname.substr(1);// TODO: Better way of determining the channel name
|
||||
var tempM3u8Url = null;
|
||||
var accessTokenResponse = await getAccessToken(channelName, OPT_VIDEO_SWAP_PLAYER_TYPE);
|
||||
if (accessTokenResponse.status === 200) {
|
||||
var accessToken = await accessTokenResponse.json();
|
||||
var urlInfo = new URL('https://usher.ttvnw.net/api/channel/hls/' + channelName + '.m3u8?allow_source=true');
|
||||
urlInfo.searchParams.set('sig', accessToken.data.streamPlaybackAccessToken.signature);
|
||||
urlInfo.searchParams.set('token', accessToken.data.streamPlaybackAccessToken.value);
|
||||
var encodingsM3u8Response = await fetch(urlInfo.href);
|
||||
if (encodingsM3u8Response.status === 200) {
|
||||
// TODO: Maybe look for the most optimal m3u8
|
||||
var encodingsM3u8 = await encodingsM3u8Response.text();
|
||||
var streamM3u8Url = encodingsM3u8.match(/^https:.*\.m3u8$/m)[0];
|
||||
// Maybe this request is a bit unnecessary
|
||||
var streamM3u8Response = await fetch(streamM3u8Url);
|
||||
if (streamM3u8Response.status == 200) {
|
||||
tempM3u8Url = streamM3u8Url;
|
||||
} else {
|
||||
console.log('Backup url request (streamM3u8) failed with ' + streamM3u8Response.status);
|
||||
}
|
||||
} else {
|
||||
console.log('Backup url request (encodingsM3u8) failed with ' + encodingsM3u8Response.status);
|
||||
}
|
||||
} else {
|
||||
console.log('Backup url request (accessToken) failed with ' + accessTokenResponse.status);
|
||||
}
|
||||
if (tempM3u8Url != null) {
|
||||
tempVideo = document.createElement('video');
|
||||
tempVideo.autoplay = true;
|
||||
tempVideo.volume = originalVolume;
|
||||
console.log(disabledVideo);
|
||||
disabledVideo.parentElement.insertBefore(tempVideo, disabledVideo.nextSibling);
|
||||
if (Hls.isSupported()) {
|
||||
tempVideo.hls = new Hls();
|
||||
tempVideo.hls.loadSource(tempM3u8Url);
|
||||
tempVideo.hls.attachMedia(tempVideo);
|
||||
}
|
||||
console.log(tempVideo);
|
||||
console.log(tempM3u8Url);
|
||||
}
|
||||
};
|
||||
createTempStream();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
function pollForAds() {
|
||||
//console.log('pollForAds ' + new Date(Date.now()));
|
||||
//check ad by looking for text banner
|
||||
var adBanner = document.querySelectorAll("span.tw-c-text-overlay");
|
||||
var foundAd = false;
|
||||
for (var i = 0; i < adBanner.length; i++) {
|
||||
if (adBanner[i].attributes["data-test-selector"]) {
|
||||
foundAd = true;
|
||||
foundAdBanner = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (tempVideo && disabledVideo && tempVideo.paused != disabledVideo.paused) {
|
||||
if (disabledVideo.paused) {
|
||||
tempVideo.pause();
|
||||
} else {
|
||||
tempVideo.play();//TODO: Fix issue with Firefox
|
||||
}
|
||||
}
|
||||
if (foundAd) {
|
||||
onFoundAd(false);
|
||||
} else if (!foundAd && foundAdBanner) {
|
||||
if (disabledVideo) {
|
||||
disabledVideo.volume = originalVolume;
|
||||
disabledVideo.style.filter = "";
|
||||
disabledVideo = null;
|
||||
foundAdContainer = false;
|
||||
foundAdBanner = false;
|
||||
if (tempVideo) {
|
||||
tempVideo.hls.stopLoad();
|
||||
tempVideo.remove();
|
||||
tempVideo = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
//setTimeout(pollForAds,100);
|
||||
}
|
||||
function pollForAdsObserver() {
|
||||
pollForAds();
|
||||
var vids = document.getElementsByClassName('video-player');
|
||||
for (var i = 0; i < vids.length; i++) {
|
||||
var observer = new MutationObserver(pollForAds);
|
||||
observer.observe(vids[i], {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: false,
|
||||
characterData: false
|
||||
});
|
||||
}
|
||||
}
|
||||
function reloadTwitchPlayer(isPausePlay) {
|
||||
function reloadTwitchPlayer(isSeek, isPausePlay) {
|
||||
// Taken from ttv-tools / ffz
|
||||
// https://github.com/Nerixyz/ttv-tools/blob/master/src/context/twitch-player.ts
|
||||
// https://github.com/FrankerFaceZ/FrankerFaceZ/blob/master/src/sites/twitch-twilight/modules/player.jsx
|
||||
@ -969,6 +590,11 @@
|
||||
if (player.paused) {
|
||||
return;
|
||||
}
|
||||
if (isSeek) {
|
||||
console.log('Force seek to reset player (hopefully fixing any audio desync)');
|
||||
player.seekTo(0);
|
||||
return;
|
||||
}
|
||||
if (isPausePlay) {
|
||||
player.pause();
|
||||
player.play();
|
||||
@ -992,28 +618,5 @@
|
||||
playerState.setSrc({ isNewMediaPlayerInstance: true, refreshAccessToken: true });// ffz sets this false
|
||||
}
|
||||
window.reloadTwitchPlayer = reloadTwitchPlayer;
|
||||
function onContentLoaded() {
|
||||
// These modes use polling of the ad elements (e.g. ad banner text) to show/hide content
|
||||
if (!OPT_MODE_VIDEO_SWAP && !OPT_MODE_MUTE_BLACK) {
|
||||
return;
|
||||
}
|
||||
if (OPT_MODE_VIDEO_SWAP && typeof Hls === 'undefined') {
|
||||
var script = document.createElement('script');
|
||||
script.src = "https://cdn.jsdelivr.net/npm/hls.js@latest";
|
||||
script.onload = function() {
|
||||
pollForAdsObserver();
|
||||
}
|
||||
document.head.appendChild(script);
|
||||
} else {
|
||||
pollForAdsObserver();
|
||||
}
|
||||
}
|
||||
hookFetch();
|
||||
if (document.readyState === "complete" || document.readyState === "loaded" || document.readyState === "interactive") {
|
||||
onContentLoaded();
|
||||
} else {
|
||||
window.addEventListener("DOMContentLoaded", function() {
|
||||
onContentLoaded();
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
@ -1,32 +0,0 @@
|
||||
Web browser extensions / scripts:
|
||||
|
||||
- https://github.com/odensc/ttv-ublock
|
||||
- Simulates `embed` Twitch player at a network level (doesn't modify DOM).
|
||||
- Twitch detects this and may display a purple screen every 10-15 mins asking the user to remove ad blockers.
|
||||
- https://github.com/Nerixyz/ttv-tools
|
||||
- Removes ad segments which cannot be skipped (loading wheel until ad-free stream).
|
||||
- Can keep stream at lowest latency (speeds up stream if too far behind).
|
||||
- https://github.com/LeonHeidelbach/ttv_adEraser
|
||||
- Modifies DOM to switch between the `embed` player when there's ads. May display purple screen if both ads and purple screen show at the same time?
|
||||
- https://github.com/instance01/Twitch-HLS-AdBlock
|
||||
- Removes ad segments. May result in m3u8 being requested quickly in succession if all segments are removed.
|
||||
- https://github.com/Wilkolicious/twitchAdSkip
|
||||
- https://gist.github.com/simple-hacker/ddd81964b3e8bca47e0aead5ad19a707/
|
||||
- https://greasyfork.org/en/scripts/415412-twitch-refresh-on-advert/code
|
||||
- Reloads the player (or page) when it detects the ad banner in DOM.
|
||||
- [Alternate Player for Twitch.tv](https://chrome.google.com/webstore/detail/bhplkbgoehhhddaoolmakpocnenplmhf) - [code](https://robwu.nl/crxviewer/?crx=bhplkbgoehhhddaoolmakpocnenplmhf&qf=player.js)
|
||||
- Removes ad segments which cannot be skipped. The player will freeze on the last live frame until no more ads.
|
||||
- https://github.com/TTV-LOL/extensions
|
||||
- Uses a proxy on the main m3u8 file to get a stream without ads (no prerolls / midrolls).
|
||||
- [Video Ad-Block, for Twitch](https://gist.github.com/saucettv/0f85e9051c7d25aee67fdc033609fe1d) - [chrome](https://chrome.google.com/webstore/detail/video-ad-block-for-twitch/kgeglempfkhalebjlogemlmeakondflc) / [firefox](https://addons.mozilla.org/en-US/firefox/addon/video-ad-block-for-twitch)
|
||||
- Sets the stream to an ad-free variant which is limited to 480p for the duration of the ad. Switches back to the regular stream after the ad.
|
||||
|
||||
Applications / third party websites:
|
||||
- https://github.com/streamlink/streamlink
|
||||
- Removes ad segments (I assume this will freeze on the last live frame until no more ads).
|
||||
- [multiChat for Twitch](https://play.google.com/store/apps/details?id=org.mchatty)
|
||||
- Unsure how this one blocks ads, but it claims that it does.
|
||||
- https://twitchls.com/
|
||||
- Uses the `embed` player. Purple screen may display every 10-15 mins.
|
||||
- https://reddit.com/r/Twitch/comments/kisdsy/i_did_a_little_test_regarding_ads_on_twitch_and/
|
||||
- Some countries don't get ads. A simple VPN/VPS could be used to block ads by proxying the m3u8 without having to proxy all your traffic (just the initial m3u8).
|
File diff suppressed because it is too large
Load Diff
@ -1 +0,0 @@
|
||||
OPT_MODE_PROXY_M3U8 'http://127.0.0.1/twitch-m3u8/'
|
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
@ -1 +0,0 @@
|
||||
OPT_MODE_STRIP_AD_SEGMENTS true
|
1019
strip/strip.user.js
1019
strip/strip.user.js
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -1 +0,0 @@
|
||||
OPT_MODE_VIDEO_SWAP true
|
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user