diff --git a/README.md b/README.md index 96a844a..7fa54bd 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,8 @@ This repo aims to provide multiple solutions for blocking Twitch ads. - dyn-skip - When ads play this instantly notifies Twitch that ads were watched. It then refreshes the stream (either full reload, or using FZZ extension). - May potentially result in multiple refreshes if ads are being served aggressively. +- dyn-skip-min + - dyn-skip variant which doesn't require a reload (WIP/experimental) - dyn - Ad segments are replaced by a low resolution stream segments (on a m3u8 level). - Skips 2-3 seconds when switching to the live stream. diff --git a/dyn-skip-min/dyn-skip-min-ublock-origin.js b/dyn-skip-min/dyn-skip-min-ublock-origin.js new file mode 100644 index 0000000..d8afce7 --- /dev/null +++ b/dyn-skip-min/dyn-skip-min-ublock-origin.js @@ -0,0 +1,197 @@ +twitch-videoad.js application/javascript +(function() { + if ( /(^|\.)twitch\.tv$/.test(document.location.hostname) === false ) { return; } + function declareOptions(scope) { + // Options / globals + scope.OPT_INITIAL_M3U8_ATTEMPTS = 10; + scope.AD_SIGNIFIER = 'stitched-ad'; + scope.CLIENT_ID = 'kimne78kx3ncx6brgo4mv6wki5h1ko'; + } + var gql_device_id = null; + const oldWorker = window.Worker; + window.Worker = class Worker extends oldWorker { + constructor(twitchBlobUrl) { + var jsURL = getWasmWorkerUrl(twitchBlobUrl); + var newBlobStr = ` + ${hookWorkerFetch.toString()} + hookWorkerFetch(); + importScripts('${jsURL}'); + ` + super(URL.createObjectURL(new Blob([newBlobStr]))); + } + } + function getWasmWorkerUrl(twitchBlobUrl) { + var req = new XMLHttpRequest(); + req.open('GET', twitchBlobUrl, false); + req.send(); + return req.responseText.split("'")[1]; + } + function hookWorkerFetch() { + var realFetch = fetch; + fetch = async function(url, options) { + if (typeof url === 'string') { + if (url.includes('/api/channel/hls/')) { + var rawUrl = url.split(/[?#]/)[0]; + var urlInfo = new URL(rawUrl); + urlInfo.searchParams.set('sig', (new URL(url)).searchParams.get('sig')); + urlInfo.searchParams.set('token', (new URL(url)).searchParams.get('token')); + //console.log('modify url ' + url + ' ------------------ ' + urlInfo.href); + url = urlInfo.href; + } + } + return realFetch.apply(this, arguments); + } + } + function makeGraphQlPacket(event, radToken, payload) { + return [{ + operationName: 'ClientSideAdEventHandling_RecordAdEvent', + variables: { + input: { + eventName: event, + eventPayload: JSON.stringify(payload), + radToken, + }, + }, + extensions: { + persistedQuery: { + version: 1, + sha256Hash: '7e6c69e6eb59f8ccb97ab73686f3d8b7d85a72a0298745ccd8bfc68e4054ca5b', + }, + }, + }]; + } + function gqlRequest(body) { + return fetch('https://gql.twitch.tv/gql', { + method: 'POST', + body: JSON.stringify(body), + headers: { + 'client-id': CLIENT_ID, + 'X-Device-Id': gql_device_id + } + }); + } + function parseAttributes(str) { + return Object.fromEntries( + str.split(/(?:^|,)((?:[^=]*)=(?:"[^"]*"|[^,]*))/) + .filter(Boolean) + .map(x => { + const idx = x.indexOf('='); + const key = x.substring(0, idx); + const value = x.substring(idx +1); + const num = Number(value); + return [key, Number.isNaN(num) ? value.startsWith('"') ? JSON.parse(value) : value : num] + })); + } + declareOptions(window); + function hookFetch() { + var realFetch = window.fetch; + window.fetch = function(url, init, ...args) { + if (typeof url === 'string') { + var deviceId = init.headers['X-Device-Id']; + if (typeof deviceId !== 'string') { + deviceId = init.headers['Device-ID']; + } + if (typeof deviceId === 'string') { + gql_device_id = deviceId; + } + var tok = null, sig = null; + if (url.includes('/access_token')) { + return new Promise(async function(resolve, reject) { + var response = await realFetch(url, init); + if (response.status === 200) { + // TODO + } else { + resolve(response); + } + }); + } + else 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_INITIAL_M3U8_ATTEMPTS; i++) { + var cloned = response.clone(); + var responseData = await cloned.json(); + if (responseData && responseData.data && responseData.data.streamPlaybackAccessToken && responseData.data.streamPlaybackAccessToken.value && responseData.data.streamPlaybackAccessToken.signature) { + var tokInfo = JSON.parse(responseData.data.streamPlaybackAccessToken.value); + var channelName = tokInfo.channel; + var urlInfo = new URL('https://usher.ttvnw.net/api/channel/hls/' + channelName + '.m3u8'); + urlInfo.searchParams.set('sig', responseData.data.streamPlaybackAccessToken.signature); + urlInfo.searchParams.set('token', responseData.data.streamPlaybackAccessToken.value); + 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(); + //console.log(streamM3u8); + if (streamM3u8.includes(AD_SIGNIFIER)) { + console.log('ad at req ' + i); + var matches = streamM3u8.match(/#EXT-X-DATERANGE:(ID="stitched-ad-[^\n]+)\n/); + if (matches.length > 1) { + const attrString = matches[1]; + const attr = parseAttributes(attrString); + var podLength = parseInt(attr['X-TV-TWITCH-AD-POD-LENGTH'] ? attr['X-TV-TWITCH-AD-POD-LENGTH'] : '1'); + var podPosition = parseInt(attr['X-TV-TWITCH-AD-POD-POSITION'] ? attr['X-TV-TWITCH-AD-POD-POSITION'] : '0'); + var radToken = attr['X-TV-TWITCH-AD-RADS-TOKEN']; + var lineItemId = attr['X-TV-TWITCH-AD-LINE-ITEM-ID']; + var orderId = attr['X-TV-TWITCH-AD-ORDER-ID']; + var creativeId = attr['X-TV-TWITCH-AD-CREATIVE-ID']; + var adId = attr['X-TV-TWITCH-AD-ADVERTISER-ID']; + var rollType = attr['X-TV-TWITCH-AD-ROLL-TYPE'].toLowerCase(); + const baseData = { + stitched: true, + roll_type: rollType, + player_mute: false, + player_volume: 0.5, + visible: true, + }; + for (let podPosition = 0; podPosition < podLength; podPosition++) { + const extendedData = { + ...baseData, + ad_id: adId, + ad_position: podPosition, + duration: 30, + creative_id: creativeId, + total_ads: podLength, + order_id: orderId, + line_item_id: lineItemId, + }; + await gqlRequest(makeGraphQlPacket('video_ad_impression', radToken, extendedData)); + for (let quartile = 0; quartile < 4; quartile++) { + await gqlRequest( + makeGraphQlPacket('video_ad_quartile_complete', radToken, { + ...extendedData, + quartile: quartile + 1, + }) + ); + } + await gqlRequest(makeGraphQlPacket('video_ad_pod_complete', radToken, baseData)); + } + } + } else { + console.log("no ad at req " + i); + break; + } + } else { + break; + } + } else { + console.log('malformed'); + console.log(responseData); + break; + } + } + console.log(responseData); + resolve(response); + } else { + resolve(response); + } + }); + } + } + return realFetch.apply(this, arguments); + } + } + hookFetch(); +})(); \ No newline at end of file diff --git a/dyn-skip-min/dyn-skip-min-userscript.js b/dyn-skip-min/dyn-skip-min-userscript.js new file mode 100644 index 0000000..d75298e --- /dev/null +++ b/dyn-skip-min/dyn-skip-min-userscript.js @@ -0,0 +1,208 @@ +// ==UserScript== +// @name TwitchAdSolutions (dyn-skip) +// @namespace https://github.com/pixeltris/TwitchAdSolutions +// @version 1.0 +// @description Skips twitch ads, and reloads the stream +// @author pixeltris +// @match *://*.twitch.tv/* +// @downloadURL https://github.com/pixeltris/TwitchAdSolutions/raw/master/dyn-skip/dyn-skip-userscript.js +// @run-at document-start +// @grant none +// ==/UserScript== +// ad-skip from https://github.com/Nerixyz/ttv-tools/blob/master/src/context/context-script.ts +(function() { + 'use strict'; + function declareOptions(scope) { + // Options / globals + scope.OPT_INITIAL_M3U8_ATTEMPTS = 10; + scope.AD_SIGNIFIER = 'stitched-ad'; + scope.CLIENT_ID = 'kimne78kx3ncx6brgo4mv6wki5h1ko'; + } + var gql_device_id = null; + const oldWorker = window.Worker; + window.Worker = class Worker extends oldWorker { + constructor(twitchBlobUrl) { + var jsURL = getWasmWorkerUrl(twitchBlobUrl); + var newBlobStr = ` + ${hookWorkerFetch.toString()} + hookWorkerFetch(); + importScripts('${jsURL}'); + ` + super(URL.createObjectURL(new Blob([newBlobStr]))); + } + } + function getWasmWorkerUrl(twitchBlobUrl) { + var req = new XMLHttpRequest(); + req.open('GET', twitchBlobUrl, false); + req.send(); + return req.responseText.split("'")[1]; + } + function hookWorkerFetch() { + var realFetch = fetch; + fetch = async function(url, options) { + if (typeof url === 'string') { + if (url.includes('/api/channel/hls/')) { + var rawUrl = url.split(/[?#]/)[0]; + var urlInfo = new URL(rawUrl); + urlInfo.searchParams.set('sig', (new URL(url)).searchParams.get('sig')); + urlInfo.searchParams.set('token', (new URL(url)).searchParams.get('token')); + //console.log('modify url ' + url + ' ------------------ ' + urlInfo.href); + url = urlInfo.href; + } + } + return realFetch.apply(this, arguments); + } + } + function makeGraphQlPacket(event, radToken, payload) { + return [{ + operationName: 'ClientSideAdEventHandling_RecordAdEvent', + variables: { + input: { + eventName: event, + eventPayload: JSON.stringify(payload), + radToken, + }, + }, + extensions: { + persistedQuery: { + version: 1, + sha256Hash: '7e6c69e6eb59f8ccb97ab73686f3d8b7d85a72a0298745ccd8bfc68e4054ca5b', + }, + }, + }]; + } + function gqlRequest(body) { + return fetch('https://gql.twitch.tv/gql', { + method: 'POST', + body: JSON.stringify(body), + headers: { + 'client-id': CLIENT_ID, + 'X-Device-Id': gql_device_id + } + }); + } + function parseAttributes(str) { + return Object.fromEntries( + str.split(/(?:^|,)((?:[^=]*)=(?:"[^"]*"|[^,]*))/) + .filter(Boolean) + .map(x => { + const idx = x.indexOf('='); + const key = x.substring(0, idx); + const value = x.substring(idx +1); + const num = Number(value); + return [key, Number.isNaN(num) ? value.startsWith('"') ? JSON.parse(value) : value : num] + })); + } + declareOptions(window); + function hookFetch() { + var realFetch = window.fetch; + window.fetch = function(url, init, ...args) { + if (typeof url === 'string') { + var deviceId = init.headers['X-Device-Id']; + if (typeof deviceId !== 'string') { + deviceId = init.headers['Device-ID']; + } + if (typeof deviceId === 'string') { + gql_device_id = deviceId; + } + var tok = null, sig = null; + if (url.includes('/access_token')) { + return new Promise(async function(resolve, reject) { + var response = await realFetch(url, init); + if (response.status === 200) { + // TODO + } else { + resolve(response); + } + }); + } + else 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_INITIAL_M3U8_ATTEMPTS; i++) { + var cloned = response.clone(); + var responseData = await cloned.json(); + if (responseData && responseData.data && responseData.data.streamPlaybackAccessToken && responseData.data.streamPlaybackAccessToken.value && responseData.data.streamPlaybackAccessToken.signature) { + var tokInfo = JSON.parse(responseData.data.streamPlaybackAccessToken.value); + var channelName = tokInfo.channel; + var urlInfo = new URL('https://usher.ttvnw.net/api/channel/hls/' + channelName + '.m3u8'); + urlInfo.searchParams.set('sig', responseData.data.streamPlaybackAccessToken.signature); + urlInfo.searchParams.set('token', responseData.data.streamPlaybackAccessToken.value); + 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(); + //console.log(streamM3u8); + if (streamM3u8.includes(AD_SIGNIFIER)) { + console.log('ad at req ' + i); + var matches = streamM3u8.match(/#EXT-X-DATERANGE:(ID="stitched-ad-[^\n]+)\n/); + if (matches.length > 1) { + const attrString = matches[1]; + const attr = parseAttributes(attrString); + var podLength = parseInt(attr['X-TV-TWITCH-AD-POD-LENGTH'] ? attr['X-TV-TWITCH-AD-POD-LENGTH'] : '1'); + var podPosition = parseInt(attr['X-TV-TWITCH-AD-POD-POSITION'] ? attr['X-TV-TWITCH-AD-POD-POSITION'] : '0'); + var radToken = attr['X-TV-TWITCH-AD-RADS-TOKEN']; + var lineItemId = attr['X-TV-TWITCH-AD-LINE-ITEM-ID']; + var orderId = attr['X-TV-TWITCH-AD-ORDER-ID']; + var creativeId = attr['X-TV-TWITCH-AD-CREATIVE-ID']; + var adId = attr['X-TV-TWITCH-AD-ADVERTISER-ID']; + var rollType = attr['X-TV-TWITCH-AD-ROLL-TYPE'].toLowerCase(); + const baseData = { + stitched: true, + roll_type: rollType, + player_mute: false, + player_volume: 0.5, + visible: true, + }; + for (let podPosition = 0; podPosition < podLength; podPosition++) { + const extendedData = { + ...baseData, + ad_id: adId, + ad_position: podPosition, + duration: 30, + creative_id: creativeId, + total_ads: podLength, + order_id: orderId, + line_item_id: lineItemId, + }; + await gqlRequest(makeGraphQlPacket('video_ad_impression', radToken, extendedData)); + for (let quartile = 0; quartile < 4; quartile++) { + await gqlRequest( + makeGraphQlPacket('video_ad_quartile_complete', radToken, { + ...extendedData, + quartile: quartile + 1, + }) + ); + } + await gqlRequest(makeGraphQlPacket('video_ad_pod_complete', radToken, baseData)); + } + } + } else { + console.log("no ad at req " + i); + break; + } + } else { + break; + } + } else { + console.log('malformed'); + console.log(responseData); + break; + } + } + console.log(responseData); + resolve(response); + } else { + resolve(response); + } + }); + } + } + return realFetch.apply(this, arguments); + } + } + hookFetch(); +})(); \ No newline at end of file