diff --git a/src/backend/providers/fetchers.ts b/src/backend/providers/fetchers.ts
index cd619829..9eaea425 100644
--- a/src/backend/providers/fetchers.ts
+++ b/src/backend/providers/fetchers.ts
@@ -30,8 +30,27 @@ function makeLoadbalancedList(getter: () => string[]) {
export const getLoadbalancedProxyUrl = makeLoadbalancedList(getProxyUrls);
export const getLoadbalancedProviderApiUrl =
makeLoadbalancedList(getProviderApiUrls);
-export const getLoadbalancedM3U8ProxyUrl =
- makeLoadbalancedList(getM3U8ProxyUrls);
+function getEnabledM3U8ProxyUrls() {
+ const allM3U8ProxyUrls = getM3U8ProxyUrls();
+ const enabledProxies = localStorage.getItem("m3u8-proxy-enabled");
+
+ if (!enabledProxies) {
+ return allM3U8ProxyUrls;
+ }
+
+ try {
+ const enabled = JSON.parse(enabledProxies);
+ return allM3U8ProxyUrls.filter(
+ (_url, index) => enabled[index.toString()] !== false,
+ );
+ } catch {
+ return allM3U8ProxyUrls;
+ }
+}
+
+export const getLoadbalancedM3U8ProxyUrl = makeLoadbalancedList(
+ getEnabledM3U8ProxyUrls,
+);
async function fetchButWithApiTokens(
input: RequestInfo | URL,
diff --git a/src/pages/admin/AdminPage.tsx b/src/pages/admin/AdminPage.tsx
index 98a9f560..eeb3b3dd 100644
--- a/src/pages/admin/AdminPage.tsx
+++ b/src/pages/admin/AdminPage.tsx
@@ -3,6 +3,7 @@ import { Heading1, Paragraph } from "@/components/utils/Text";
import { SubPageLayout } from "@/pages/layouts/SubPageLayout";
import { ConfigValuesPart } from "@/pages/parts/admin/ConfigValuesPart";
import { ExtensionOverridePart } from "@/pages/parts/admin/ExtensionOverridePart";
+import { M3U8TestPart } from "@/pages/parts/admin/M3U8TestPart";
import { RegionSelectorPart } from "@/pages/parts/admin/RegionSelectorPart";
import { TMDBTestPart } from "@/pages/parts/admin/TMDBTestPart";
import { WorkerTestPart } from "@/pages/parts/admin/WorkerTestPart";
@@ -20,6 +21,7 @@ export function AdminPage() {
+
diff --git a/src/pages/parts/admin/M3U8TestPart.tsx b/src/pages/parts/admin/M3U8TestPart.tsx
new file mode 100644
index 00000000..dca5d73f
--- /dev/null
+++ b/src/pages/parts/admin/M3U8TestPart.tsx
@@ -0,0 +1,314 @@
+import classNames from "classnames";
+import { useEffect, useMemo, useState } from "react";
+import { useAsyncFn } from "react-use";
+
+import { Button } from "@/components/buttons/Button";
+import { Toggle } from "@/components/buttons/Toggle";
+import { Icon, Icons } from "@/components/Icon";
+import { Box } from "@/components/layout/Box";
+import { Divider } from "@/components/utils/Divider";
+import { Heading2 } from "@/components/utils/Text";
+import { getM3U8ProxyUrls } from "@/utils/proxyUrls";
+
+export function M3U8ProxyItem(props: {
+ name: string;
+ errored?: boolean;
+ success?: boolean;
+ questionable?: boolean;
+ errorText?: string;
+ url?: string;
+ enabled?: boolean;
+ onToggle?: (enabled: boolean) => void;
+}) {
+ const urlWithoutProtocol = props.url ? new URL(props.url).host : null;
+
+ return (
+
+
props.onToggle?.(!props.enabled)}
+ />
+
+
+
{props.name}
+ {props.errorText ?
{props.errorText}
: null}
+ {urlWithoutProtocol ?
{urlWithoutProtocol}
: null}
+
+
+ );
+}
+
+export function M3U8TestPart() {
+ const m3u8ProxyList = useMemo(() => {
+ return getM3U8ProxyUrls().map((v, ind) => ({
+ id: ind.toString(),
+ url: v,
+ }));
+ }, []);
+
+ // Load enabled proxies from localStorage
+ const [enabledProxies, setEnabledProxies] = useState>(
+ () => {
+ const saved = localStorage.getItem("m3u8-proxy-enabled");
+ if (saved) {
+ try {
+ return JSON.parse(saved);
+ } catch {
+ return {};
+ }
+ }
+ // Default: all enabled
+ return Object.fromEntries(m3u8ProxyList.map((proxy) => [proxy.id, true]));
+ },
+ );
+
+ // Save enabled proxies to localStorage
+ useEffect(() => {
+ localStorage.setItem("m3u8-proxy-enabled", JSON.stringify(enabledProxies));
+ }, [enabledProxies]);
+
+ const [proxyState, setProxyState] = useState<
+ {
+ id: string;
+ status: "error" | "success" | "questionable";
+ error?: Error;
+ }[]
+ >([]);
+
+ const [buttonClicked, setButtonClicked] = useState(false);
+ const [buttonDisabled, setButtonDisabled] = useState(false);
+
+ const [testState, runTests] = useAsyncFn(async () => {
+ setButtonDisabled(true);
+ function updateProxy(id: string, data: (typeof proxyState)[number]) {
+ setProxyState((s) => {
+ return [...s.filter((v) => v.id !== id), data];
+ });
+ }
+ setProxyState([]);
+
+ const activeProxies = m3u8ProxyList.filter(
+ (proxy) => enabledProxies[proxy.id],
+ );
+ const proxyPromises = activeProxies.map(async (proxy) => {
+ try {
+ if (proxy.url.endsWith("/")) {
+ updateProxy(proxy.id, {
+ id: proxy.id,
+ status: "error",
+ error: new Error("URL ends with slash"),
+ });
+ return;
+ }
+
+ // Test if it can do the same destination fetch as CORS proxy
+ const testUrl = `${proxy.url}/?destination=${encodeURIComponent(
+ "https://postman-echo.com/get",
+ )}`;
+ const response = await fetch(testUrl);
+
+ if (response.ok) {
+ updateProxy(proxy.id, {
+ id: proxy.id,
+ status: "success",
+ });
+ }
+ } catch (err) {
+ const error = err as Error;
+ error.message = error.message.replace(proxy.url, "M3U8_PROXY_URL");
+ updateProxy(proxy.id, {
+ id: proxy.id,
+ status: "questionable",
+ error,
+ });
+ }
+ });
+
+ await Promise.all(proxyPromises);
+ setTimeout(() => setButtonDisabled(false), 5000);
+ }, [m3u8ProxyList, enabledProxies]);
+
+ const handleToggleProxy = (proxyId: string, enabled: boolean) => {
+ setEnabledProxies((prev) => ({
+ ...prev,
+ [proxyId]: enabled,
+ }));
+ };
+
+ const allEnabled = m3u8ProxyList.every((proxy) => enabledProxies[proxy.id]);
+ const noneEnabled = m3u8ProxyList.every((proxy) => !enabledProxies[proxy.id]);
+
+ const handleToggleAll = () => {
+ if (allEnabled) {
+ // Disable all
+ setEnabledProxies(
+ Object.fromEntries(m3u8ProxyList.map((proxy) => [proxy.id, false])),
+ );
+ } else {
+ // Enable all
+ setEnabledProxies(
+ Object.fromEntries(m3u8ProxyList.map((proxy) => [proxy.id, true])),
+ );
+ }
+ };
+
+ const enabledCount = m3u8ProxyList.filter(
+ (proxy) => enabledProxies[proxy.id],
+ ).length;
+
+ return (
+ <>
+ M3U8 Proxy tests
+
+
+ {m3u8ProxyList.length} M3U8 proxy(s) registered ({enabledCount}{" "}
+ enabled)
+
+
+
+
+ {m3u8ProxyList.map((v, i) => {
+ const s = proxyState.find((segment) => segment.id === v.id);
+ const name = `M3U8 Proxy ${i + 1}`;
+ const enabled = enabledProxies[v.id];
+
+ if (!s) {
+ return (
+ handleToggleProxy(v.id, isEnabled)}
+ />
+ );
+ }
+
+ if (s.status === "error") {
+ return (
+ handleToggleProxy(v.id, isEnabled)}
+ />
+ );
+ }
+
+ if (s.status === "success") {
+ return (
+ handleToggleProxy(v.id, isEnabled)}
+ />
+ );
+ }
+
+ if (s.status === "questionable") {
+ return (
+ handleToggleProxy(v.id, isEnabled)}
+ />
+ );
+ }
+
+ return (
+ handleToggleProxy(v.id, isEnabled)}
+ />
+ );
+ })}
+
+
+ {buttonClicked ? (
+ proxyState
+ .filter((p) => enabledProxies[p.id])
+ .every((proxy) => proxy.status === "success") ? (
+
+ All enabled M3U8 proxies have passed the test!{" "}
+ ٩(ˊᗜˋ*)و♡
+
+ ) : (
+
+
+
+ Some M3U8 proxies have failed the test...{" "}
+ (•᷄∩•᷅ )
+
+
+
+
+
+
+ )
+ ) : (
+
+ )}
+
+
+ >
+ );
+}