Merge pull request #340 from younesaassila/v2.3.8

Release version 2.3.8
This commit is contained in:
Younes Aassila 2024-08-23 10:38:28 +02:00 committed by GitHub
commit 4cf96c311c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 476 additions and 1438 deletions

View File

@ -1,8 +1,17 @@
name: Build and Test
on: [push, pull_request]
on:
push:
branches:
- "**"
tags-ignore:
- "**"
pull_request:
branches:
- "**"
jobs:
build:
concurrency: ci-${{ github.ref }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3

View File

@ -32,7 +32,7 @@
</div>
<div align="center">
<a href="https://discord.gg/AmtFTPwsyH">
<a href="https://discord.ttvlolpro.com/">
<img
alt="Discord server"
src="https://dcbadge.vercel.app/api/server/AmtFTPwsyH"
@ -78,7 +78,7 @@ TTV LOL PRO is a fork of TTV LOL that:
TTV LOL PRO does not remove banner ads, nor does it remove ads from VODs. For the best experience, we recommend using [uBlock Origin](https://ublockorigin.com/) alongside TTV LOL PRO.
Any questions? Please read the [wiki](https://wiki.cdn-perfprod.com/) first.
Any questions? Please read the [wiki](https://wiki.ttvlolpro.com/) first.
## Screenshots

1665
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{
"name": "ttv-lol-pro",
"version": "2.3.7",
"version": "2.3.8",
"description": "TTV LOL PRO removes most livestream ads from Twitch.",
"@parcel/bundler-default": {
"minBundles": 10000000,
@ -43,24 +43,25 @@
"license": "GPL-3.0",
"dependencies": {
"bowser": "^2.11.0",
"ip": "^2.0.1",
"ip-address": "^9.0.5",
"m3u8-parser": "^7.1.0"
},
"devDependencies": {
"@parcel/config-webextension": "^2.12.0",
"@types/chrome": "^0.0.267",
"@types/ip": "^1.1.3",
"@types/webextension-polyfill": "^0.10.7",
"@types/chrome": "^0.0.270",
"@types/jsbn": "^1.2.33",
"@types/node": "^20.16.1",
"@types/webextension-polyfill": "^0.12.0",
"buffer": "^6.0.3",
"os-browserify": "^0.3.0",
"parcel": "^2.12.0",
"postcss": "^8.4.38",
"postcss": "^8.4.41",
"prettier": "2.8.8",
"prettier-plugin-css-order": "^1.3.1",
"prettier-plugin-organize-imports": "^3.2.4",
"shx": "^0.3.4",
"typescript": "^5.4.5",
"webextension-polyfill": "^0.11.0"
"typescript": "^5.5.4",
"webextension-polyfill": "^0.12.0"
},
"private": true
}

View File

@ -6,7 +6,7 @@ const pendingRequests: string[] = [];
export default function onAuthRequired(
details: WebRequest.OnAuthRequiredDetailsType
): void | WebRequest.BlockingResponseOrPromise {
): WebRequest.BlockingResponseOrPromise | undefined {
if (!details.isProxy) return;
if (pendingRequests.includes(details.requestId)) {

View File

@ -8,7 +8,7 @@ import { twitchTvHostRegex } from "../../common/ts/regexes";
export default function onBeforeTwitchTvSendHeaders(
details: WebRequest.OnBeforeSendHeadersDetailsType
): void | WebRequest.BlockingResponseOrPromise {
): WebRequest.BlockingResponseOrPromise | undefined {
const host = getHostFromUrl(details.url);
if (!host || !twitchTvHostRegex.test(host)) return;

View File

@ -11,7 +11,7 @@ export default function onBeforeVideoWeaverRequest(
details: WebRequest.OnBeforeRequestDetailsType & {
proxyInfo?: ProxyInfo;
}
): void | WebRequest.BlockingResponseOrPromise {
): WebRequest.BlockingResponseOrPromise | undefined {
// Filter to video-weaver responses.
const host = getHostFromUrl(details.url);
if (!host || !videoWeaverHostRegex.test(host)) return;
@ -34,9 +34,7 @@ export default function onBeforeVideoWeaverRequest(
if (isDuplicate) return text;
const channelName = findChannelFromVideoWeaverUrl(details.url);
const isPurpleScreen = textLower.includes(
"https://help.twitch.tv/s/article/ad-experience-on-twitch"
);
const isPurpleScreen = textLower.includes("https://help.twitch.tv/");
const proxy =
details.proxyInfo && details.proxyInfo.type !== "direct"
? getUrlFromProxyInfo(details.proxyInfo)

View File

@ -14,8 +14,8 @@ const fetchTimeoutMsOverride: Map<ProxyRequestType, number> = new Map([
export default function onContentScriptMessage(
message: any,
sender: Runtime.MessageSender,
sendResponse: () => void
): true | void | Promise<any> {
sendResponse: (message: any) => void
): Promise<any> | true | undefined {
if (message.type === MessageType.EnableFullMode) {
if (!sender.tab?.id) return;

View File

@ -27,7 +27,13 @@ export default async function onResponseStarted(
const host = getHostFromUrl(details.url);
if (!host) return;
const proxy = getProxyFromDetails(details);
let proxy: string | null = null;
let errorMessage: string | null = null;
try {
proxy = getProxyFromDetails(details);
} catch (error) {
errorMessage = error instanceof Error ? error.message : `${error}`;
}
const requestParams = {
isChromium: isChromium,
@ -79,13 +85,26 @@ export default async function onResponseStarted(
findChannelFromTwitchTvUrl(tabUrl);
const streamStatus = getStreamStatus(channelName);
const stats = streamStatus?.stats ?? { proxied: 0, notProxied: 0 };
if (!proxy) {
stats.notProxied++;
let reason = errorMessage ?? streamStatus?.reason ?? "";
try {
const proxySettings = await browser.proxy.settings.get({});
switch (proxySettings.levelOfControl) {
case "controlled_by_other_extensions":
reason = "Proxy settings controlled by other extension";
break;
case "not_controllable":
reason = "Proxy settings not controllable";
break;
}
} catch {}
setStreamStatus(channelName, {
proxied: false,
proxyHost: streamStatus?.proxyHost ? streamStatus.proxyHost : undefined,
proxyCountry: streamStatus?.proxyCountry,
reason: streamStatus?.reason ?? "",
reason,
stats,
});
console.log(
@ -93,6 +112,7 @@ export default async function onResponseStarted(
);
return;
}
stats.proxied++;
setStreamStatus(channelName, {
proxied: true,
@ -126,24 +146,29 @@ function getProxyFromDetails(
}
): string | null {
if (isChromium) {
const proxies = [
...store.state.optimizedProxies,
...store.state.normalProxies,
];
const isDnsError =
proxies.length !== 0 && store.state.dnsResponses.length === 0;
if (isDnsError) {
throw new Error(
"Cannot detect if requests are being proxied due to a DNS error"
);
}
const ip = details.ip;
if (!ip) return null;
const dnsResponse = store.state.dnsResponses.find(
dnsResponse => dnsResponse.ips.indexOf(ip) !== -1
);
if (!dnsResponse) return null;
const proxies = [
...store.state.optimizedProxies,
...store.state.normalProxies,
];
const proxyInfoArray = proxies.map(getProxyInfoFromUrl);
const possibleProxies = proxyInfoArray.filter(
proxy => proxy.host === dnsResponse.host
);
if (possibleProxies.length === 1)
return getUrlFromProxyInfo(possibleProxies[0]);
// TODO: Set reason to some error message about DNS.
return dnsResponse.host;
if (possibleProxies.length === 0) return dnsResponse.host;
return getUrlFromProxyInfo(possibleProxies[0]);
} else {
const proxyInfo = details.proxyInfo; // Firefox only.
if (!proxyInfo || proxyInfo.type === "direct") return null;

View File

@ -1,4 +1,5 @@
import ip from "ip";
import { Address4, Address6 } from "ip-address";
import isPrivateIp from "./isPrivateIp";
import { getProxyInfoFromUrl } from "./proxyInfo";
/**
@ -12,16 +13,26 @@ export function anonymizeIpAddress(url: string): string {
let proxyHost = proxyInfo.host;
const isIPv4 = ip.isV4Format(proxyHost);
const isIPv6 = ip.isV6Format(proxyHost);
const isIPv4 = Address4.isValid(proxyHost);
const isIPv6 = Address6.isValid(proxyHost);
const isIP = isIPv4 || isIPv6;
const isPublicIP = isIP && !ip.isPrivate(proxyHost);
const isPublicIP = isIP && !isPrivateIp(proxyHost);
if (isPublicIP) {
if (isIPv4) {
proxyHost = ip.mask(proxyHost, "255.255.0.0").replace(/\.0\.0$/, ".*.*");
proxyHost = new Address4(proxyHost)
.correctForm()
.split(".")
.map((byte, index) => (index < 2 ? byte : "xxx"))
.join(".");
} else if (isIPv6) {
proxyHost = ip.mask(proxyHost, "ffff:ffff:ffff:ffff:0000:0000:0000:0000");
const bytes = new Address6(proxyHost).toByteArray();
const anonymizedBytes = bytes.map((byte, index) =>
index < 6 ? byte : 0x0
);
proxyHost = Address6.fromByteArray(anonymizedBytes)
.correctForm()
.replace(/::$/, "::xxxx");
}
}

View File

@ -0,0 +1,27 @@
import { Address4, Address6 } from "ip-address";
const ip4LinkLocalSubnet = new Address4("169.254.0.0/16");
const ip4LoopbackSubnet = new Address4("127.0.0.0/8");
const ip4PrivateASubnet = new Address4("10.0.0.0/8");
const ip4PrivateBSubnet = new Address4("172.16.0.0/12");
const ip4PrivateCSubnet = new Address4("192.168.0.0/16");
export default function isPrivateIp(address: string): boolean {
try {
const ip4 = new Address4(address);
return (
ip4.isInSubnet(ip4LinkLocalSubnet) ||
ip4.isInSubnet(ip4LoopbackSubnet) ||
ip4.isInSubnet(ip4PrivateASubnet) ||
ip4.isInSubnet(ip4PrivateBSubnet) ||
ip4.isInSubnet(ip4PrivateCSubnet)
);
} catch (error) {}
try {
const ip6 = new Address6(address);
return ip6.isLinkLocal() || ip6.isLoopback();
} catch (error) {}
return false;
}

View File

@ -1,4 +1,4 @@
import ip from "ip";
import { Address6 } from "ip-address";
import type { ProxyInfo } from "../../types";
export function getProxyInfoFromUrl(
@ -71,14 +71,14 @@ export function getUrlFromProxyInfo(proxyInfo: ProxyInfo): string {
} else if (username) {
url = `${username}@`;
}
const isIPv4 = ip.isV4Format(host);
const isIPv6 = ip.isV6Format(host);
// isV6Format() returns true for IPv4 addresses, so we need to exclude those.
if (isIPv6 && !isIPv4) {
const isIPv6 = Address6.isValid(host);
if (isIPv6) {
url += `[${host}]`;
} else {
url += host;
}
if (port) url += `:${port}`;
if (port) {
url += `:${port}`;
}
return url;
}

View File

@ -1,12 +1,13 @@
import ip from "ip";
import { Address4, Address6 } from "ip-address";
import store from "../../store";
import type { DnsResponse, DnsResponseJson } from "../../types";
import { getProxyInfoFromUrl } from "./proxyInfo";
export default async function updateDnsResponses() {
const proxies = store.state.optimizedProxiesEnabled
? store.state.optimizedProxies
: store.state.normalProxies;
const proxies = [
...store.state.optimizedProxies,
...store.state.normalProxies,
];
const proxyInfoArray = proxies.map(getProxyInfoFromUrl);
for (const proxyInfo of proxyInfoArray) {
@ -25,18 +26,19 @@ export default async function updateDnsResponses() {
}
// If the host is an IP address, we don't need to make a DNS request.
const isIp = ip.isV4Format(host) || ip.isV6Format(host);
const isIp = Address4.isValid(host) || Address6.isValid(host);
if (isIp) {
if (dnsResponseIndex !== -1) {
store.state.dnsResponses.splice(dnsResponseIndex, 1);
}
const dnsResponse: DnsResponse = {
host,
ips: [host],
timestamp: Date.now(),
ttl: Infinity,
};
store.state.dnsResponses.push(dnsResponse);
if (dnsResponseIndex !== -1) {
store.state.dnsResponses.splice(dnsResponseIndex, 1, dnsResponse);
} else {
store.state.dnsResponses.push(dnsResponse);
}
continue;
}
@ -59,16 +61,17 @@ export default async function updateDnsResponses() {
}
const { Answer } = data;
if (dnsResponseIndex !== -1) {
store.state.dnsResponses.splice(dnsResponseIndex, 1);
}
const dnsResponse: DnsResponse = {
host,
ips: Answer.map(answer => answer.data),
timestamp: Date.now(),
ttl: Math.max(Math.max(...Answer.map(answer => answer.TTL)), 300),
};
store.state.dnsResponses.push(dnsResponse);
if (dnsResponseIndex !== -1) {
store.state.dnsResponses.splice(dnsResponseIndex, 1, dnsResponse);
} else {
store.state.dnsResponses.push(dnsResponse);
}
} catch (error) {
console.error(error);
}

View File

@ -3,6 +3,7 @@ import workerScriptURL from "url:../page/worker.ts";
import browser, { Storage } from "webextension-polyfill";
import findChannelFromTwitchTvUrl from "../common/ts/findChannelFromTwitchTvUrl";
import isChromium from "../common/ts/isChromium";
import { getStreamStatus, setStreamStatus } from "../common/ts/streamStatus";
import store from "../store";
import type { State } from "../store/types";
import { MessageType } from "../types";
@ -80,7 +81,7 @@ function onStoreChange(changes: Record<string, Storage.StorageChange>) {
});
}
function onBackgroundMessage(message: any) {
function onBackgroundMessage(message: any): undefined {
switch (message.type) {
case MessageType.EnableFullModeResponse:
window.postMessage({
@ -145,6 +146,15 @@ function onPageMessage(event: MessageEvent) {
);
}
break;
case MessageType.MultipleAdBlockersInUse:
const channelName = findChannelFromTwitchTvUrl(location.href);
if (!channelName) break;
const streamStatus = getStreamStatus(channelName);
setStreamStatus(channelName, {
...(streamStatus ?? { proxied: false }),
reason: "Another Twitch ad blocker is in use",
});
break;
case MessageType.ClearStats:
clearStats(message.channelName);
break;

View File

@ -3,7 +3,7 @@
"name": "TTV LOL PRO",
"description": "TTV LOL PRO removes most livestream ads from Twitch.",
"homepage_url": "https://github.com/younesaassila/ttv-lol-pro",
"version": "2.3.7",
"version": "2.3.8",
"background": {
"service_worker": "background/background.ts",
"type": "module"

View File

@ -3,7 +3,7 @@
"name": "TTV LOL PRO",
"description": "TTV LOL PRO removes most livestream ads from Twitch.",
"homepage_url": "https://github.com/younesaassila/ttv-lol-pro",
"version": "2.3.7",
"version": "2.3.8",
"background": {
"scripts": ["background/background.ts"],
"persistent": false

View File

@ -113,9 +113,14 @@
<label for="anonymous-mode-checkbox">Anonymous mode</label>
<br />
<small>
Watch streams as if you were logged out. This option might help
Watch streams as if you were logged out. This option may help
reduce the number of "Commercial break in progress" ads.
</small>
<br />
<small>
This option might prevent Drops from working. Only disable it
<b>if you are having issues</b>.
</small>
</li>
</ul>
</section>

View File

@ -41,7 +41,8 @@ const pageState: PageState = {
window.fetch = getFetch(pageState);
window.Worker = class Worker extends window.Worker {
const NATIVE_WORKER = window.Worker;
window.Worker = class Worker extends NATIVE_WORKER {
constructor(scriptURL: string | URL, options?: WorkerOptions) {
const fullUrl = toAbsoluteUrl(scriptURL.toString());
const isTwitchWorker = fullUrl.includes(".twitch.tv");
@ -49,6 +50,16 @@ window.Worker = class Worker extends window.Worker {
super(scriptURL, options);
return;
}
// Required for VAFT (>=12.0.0) compatibility.
const isAlreadyHooked = NATIVE_WORKER.toString().includes("twitch");
if (isAlreadyHooked) {
console.info("[TTV LOL PRO] Another Twitch ad blocker is in use.");
sendMessageToContentScript({
type: MessageType.MultipleAdBlockersInUse,
});
super(scriptURL, options);
return;
}
let script = "";
// Fetch Twitch's script, since Firefox Nightly errors out when trying to
// import a blob URL directly.
@ -80,7 +91,7 @@ window.Worker = class Worker extends window.Worker {
const newScriptURL = URL.createObjectURL(
new Blob([newScript], { type: "text/javascript" })
);
// Required for VAFT compatibility.
// Required for VAFT (<9.0.0) compatibility.
const wrapperScript = `
try {
importScripts('${newScriptURL}');

View File

@ -173,7 +173,7 @@
</li>
<li>
<a
href="https://discord.gg/AmtFTPwsyH"
href="https://discord.ttvlolpro.com/"
target="_blank"
class="list-item"
>
@ -239,7 +239,7 @@
<small class="question-info">
Any questions? Please read the
<a href="https://wiki.cdn-perfprod.com/" target="_blank">wiki</a>
<a href="https://wiki.ttvlolpro.com/" target="_blank">wiki</a>
first.
</small>
</main>

View File

@ -8,7 +8,6 @@ import {
import { alpha2 } from "../common/ts/countryCodes";
import findChannelFromTwitchTvUrl from "../common/ts/findChannelFromTwitchTvUrl";
import isChannelWhitelisted from "../common/ts/isChannelWhitelisted";
import isChromium from "../common/ts/isChromium";
import store from "../store";
import type { StreamStatus } from "../types";
@ -116,7 +115,7 @@ function setProxyStatus(
reasonElement.textContent = status.reason;
reasonElement.style.display = "";
} else if (status.stats) {
reasonElement.textContent = `Proxied: ${status.stats.proxied} | Not proxied: ${status.stats.notProxied}`;
reasonElement.textContent = getProxyStatusStatsMessage(status.stats);
reasonElement.style.display = "";
} else {
reasonElement.style.display = "none";
@ -133,8 +132,20 @@ function setProxyStatus(
}
}
function getProxyStatusStatsMessage(
stats: NonNullable<StreamStatus["stats"]>
): string {
const formatter = new Intl.NumberFormat("en-US");
return `Proxied: ${formatter.format(
stats.proxied
)} | Not proxied: ${formatter.format(stats.notProxied)}`;
}
function getProxyStatusMessages(status: StreamStatus): string[] {
const messages = [];
if (status.reason && status.stats) {
messages.push(getProxyStatusStatsMessage(status.stats));
}
if (status.proxyHost) {
messages.push(`Proxy: ${anonymizeIpAddress(status.proxyHost)}`);
}
@ -219,6 +230,7 @@ copyDebugInfoButtonElement.addEventListener("click", async e => {
`Stream status:\n`,
status != null
? [
`- Reason: ${status.reason ?? "N/A"}\n`,
`- Proxied: ${status.stats?.proxied ?? "N/A"}, Not proxied: ${
status.stats?.notProxied ?? "N/A"
}\n`,
@ -230,9 +242,7 @@ copyDebugInfoButtonElement.addEventListener("click", async e => {
`- Country: ${status.proxyCountry ?? "N/A"}\n`,
].join("")
: "",
isChromium
? `Proxy level of control: ${proxySettings.levelOfControl}\n`
: "",
`Proxy level of control: ${proxySettings.levelOfControl}\n`,
].join("")
: "",
store.state.adLog.length > 0

View File

@ -33,7 +33,7 @@ class Store<T extends Record<string | symbol, any>> {
if (area !== this._areaName) return;
for (const [key, { newValue }] of Object.entries(changes)) {
if (newValue === undefined) continue; // Ignore deletions.
this._state[key as keyof T] = newValue;
this._state[key as keyof T] = newValue as T[keyof T];
}
this.dispatchEvent("change", changes);
});
@ -46,7 +46,7 @@ class Store<T extends Record<string | symbol, any>> {
this._state = this._getDefaultState();
for (const [key, value] of Object.entries(storage)) {
this._state[key as keyof T] = value;
this._state[key as keyof T] = value as T[keyof T];
}
const stateHandler = getStateHandler(this._areaName, this._state);
const stateProxy = new Proxy(this._state, stateHandler);

View File

@ -82,6 +82,7 @@ export const enum MessageType {
UsherResponse = "TLP_UsherResponse",
NewPlaybackAccessToken = "TLP_NewPlaybackAccessToken",
NewPlaybackAccessTokenResponse = "TLP_NewPlaybackAccessTokenResponse",
MultipleAdBlockersInUse = "TLP_MultipleAdBlockersInUse",
ClearStats = "TLP_ClearStats",
}