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...{" "} + (•᷄∩•᷅ ) +

+
+ +
+
+
+ ) + ) : ( + + )} +
+
+ + ); +}