mirror of
https://github.com/UrloMythus/UnHided.git
synced 2026-06-10 09:10:23 +00:00
New version
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,4 +1,4 @@
|
|||||||
from typing import Dict, Optional, Union
|
from typing import Dict, Literal, Optional, Union
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
@@ -59,6 +59,10 @@ class Settings(BaseSettings):
|
|||||||
disable_home_page: bool = False # Whether to disable the home page UI.
|
disable_home_page: bool = False # Whether to disable the home page UI.
|
||||||
disable_docs: bool = False # Whether to disable the API documentation (Swagger UI).
|
disable_docs: bool = False # Whether to disable the API documentation (Swagger UI).
|
||||||
disable_speedtest: bool = False # Whether to disable the speedtest UI.
|
disable_speedtest: bool = False # Whether to disable the speedtest UI.
|
||||||
|
stremio_proxy_url: str | None = None # The Stremio server URL for alternative content proxying.
|
||||||
|
m3u8_content_routing: Literal["mediaflow", "stremio", "direct"] = (
|
||||||
|
"mediaflow" # Routing strategy for M3U8 content URLs: "mediaflow", "stremio", or "direct"
|
||||||
|
)
|
||||||
|
|
||||||
user_agent: str = (
|
user_agent: str = (
|
||||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36" # The user agent to use for HTTP requests.
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36" # The user agent to use for HTTP requests.
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -43,7 +43,7 @@ class DLHDExtractor(BaseExtractor):
|
|||||||
channel_headers = {
|
channel_headers = {
|
||||||
"referer": player_origin + "/",
|
"referer": player_origin + "/",
|
||||||
"origin": player_origin,
|
"origin": player_origin,
|
||||||
"user-agent": self.base_headers["user-agent"]
|
"user-agent": self.base_headers["user-agent"],
|
||||||
}
|
}
|
||||||
|
|
||||||
channel_response = await self._make_request(channel_url, headers=channel_headers)
|
channel_response = await self._make_request(channel_url, headers=channel_headers)
|
||||||
@@ -52,15 +52,21 @@ class DLHDExtractor(BaseExtractor):
|
|||||||
if not player_url:
|
if not player_url:
|
||||||
raise ExtractorError("Could not extract player URL from channel page")
|
raise ExtractorError("Could not extract player URL from channel page")
|
||||||
|
|
||||||
# Check if this is a vecloud URL
|
if not re.search(r"/stream/([a-zA-Z0-9-]+)", player_url):
|
||||||
if "vecloud" in player_url:
|
iframe_player_url = await self._handle_playnow(player_url, player_origin)
|
||||||
|
player_origin = self._get_origin(player_url)
|
||||||
|
player_url = iframe_player_url
|
||||||
|
|
||||||
|
try:
|
||||||
return await self._handle_vecloud(player_url, player_origin + "/")
|
return await self._handle_vecloud(player_url, player_origin + "/")
|
||||||
|
except Exception as e:
|
||||||
|
pass
|
||||||
|
|
||||||
# Get player page to extract authentication information
|
# Get player page to extract authentication information
|
||||||
player_headers = {
|
player_headers = {
|
||||||
"referer": player_origin + "/",
|
"referer": player_origin + "/",
|
||||||
"origin": player_origin,
|
"origin": player_origin,
|
||||||
"user-agent": self.base_headers["user-agent"]
|
"user-agent": self.base_headers["user-agent"],
|
||||||
}
|
}
|
||||||
|
|
||||||
player_response = await self._make_request(player_url, headers=player_headers)
|
player_response = await self._make_request(player_url, headers=player_headers)
|
||||||
@@ -89,16 +95,18 @@ class DLHDExtractor(BaseExtractor):
|
|||||||
raise ExtractorError("Could not determine auth URL base")
|
raise ExtractorError("Could not determine auth URL base")
|
||||||
|
|
||||||
# Construct auth URL
|
# Construct auth URL
|
||||||
auth_url = (f"{auth_url_base}/auth.php?channel_id={auth_data['channel_key']}"
|
auth_url = (
|
||||||
f"&ts={auth_data['auth_ts']}&rnd={auth_data['auth_rnd']}"
|
f"{auth_url_base}/auth.php?channel_id={auth_data['channel_key']}"
|
||||||
f"&sig={quote(auth_data['auth_sig'])}")
|
f"&ts={auth_data['auth_ts']}&rnd={auth_data['auth_rnd']}"
|
||||||
|
f"&sig={quote(auth_data['auth_sig'])}"
|
||||||
|
)
|
||||||
|
|
||||||
# Make auth request
|
# Make auth request
|
||||||
player_origin = self._get_origin(player_url)
|
player_origin = self._get_origin(player_url)
|
||||||
auth_headers = {
|
auth_headers = {
|
||||||
"referer": player_origin + "/",
|
"referer": player_origin + "/",
|
||||||
"origin": player_origin,
|
"origin": player_origin,
|
||||||
"user-agent": self.base_headers["user-agent"]
|
"user-agent": self.base_headers["user-agent"],
|
||||||
}
|
}
|
||||||
|
|
||||||
auth_response = await self._make_request(auth_url, headers=auth_headers)
|
auth_response = await self._make_request(auth_url, headers=auth_headers)
|
||||||
@@ -113,14 +121,14 @@ class DLHDExtractor(BaseExtractor):
|
|||||||
lookup_url_base=player_origin,
|
lookup_url_base=player_origin,
|
||||||
auth_url_base=auth_url_base,
|
auth_url_base=auth_url_base,
|
||||||
auth_data=auth_data,
|
auth_data=auth_data,
|
||||||
headers=auth_headers
|
headers=auth_headers,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Set up the final stream headers
|
# Set up the final stream headers
|
||||||
stream_headers = {
|
stream_headers = {
|
||||||
"referer": player_url,
|
"referer": player_url,
|
||||||
"origin": player_origin,
|
"origin": player_origin,
|
||||||
"user-agent": self.base_headers["user-agent"]
|
"user-agent": self.base_headers["user-agent"],
|
||||||
}
|
}
|
||||||
|
|
||||||
# Return the stream URL with headers
|
# Return the stream URL with headers
|
||||||
@@ -144,12 +152,17 @@ class DLHDExtractor(BaseExtractor):
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Extract stream ID from vecloud URL
|
# Extract stream ID from vecloud URL
|
||||||
stream_id_match = re.search(r'/stream/([a-zA-Z0-9-]+)', player_url)
|
stream_id_match = re.search(r"/stream/([a-zA-Z0-9-]+)", player_url)
|
||||||
if not stream_id_match:
|
if not stream_id_match:
|
||||||
raise ExtractorError("Could not extract stream ID from vecloud URL")
|
raise ExtractorError("Could not extract stream ID from vecloud URL")
|
||||||
|
|
||||||
stream_id = stream_id_match.group(1)
|
stream_id = stream_id_match.group(1)
|
||||||
|
|
||||||
|
response = await self._make_request(
|
||||||
|
player_url, headers={"referer": channel_referer, "user-agent": self.base_headers["user-agent"]}
|
||||||
|
)
|
||||||
|
player_url = str(response.url)
|
||||||
|
|
||||||
# Construct API URL
|
# Construct API URL
|
||||||
player_parsed = urlparse(player_url)
|
player_parsed = urlparse(player_url)
|
||||||
player_domain = player_parsed.netloc
|
player_domain = player_parsed.netloc
|
||||||
@@ -161,13 +174,10 @@ class DLHDExtractor(BaseExtractor):
|
|||||||
"referer": player_url,
|
"referer": player_url,
|
||||||
"origin": player_origin,
|
"origin": player_origin,
|
||||||
"user-agent": self.base_headers["user-agent"],
|
"user-agent": self.base_headers["user-agent"],
|
||||||
"content-type": "application/json"
|
"content-type": "application/json",
|
||||||
}
|
}
|
||||||
|
|
||||||
api_data = {
|
api_data = {"r": channel_referer, "d": player_domain}
|
||||||
"r": channel_referer,
|
|
||||||
"d": player_domain
|
|
||||||
}
|
|
||||||
|
|
||||||
# Make API request
|
# Make API request
|
||||||
api_response = await self._make_request(api_url, method="POST", headers=api_headers, json=api_data)
|
api_response = await self._make_request(api_url, method="POST", headers=api_headers, json=api_data)
|
||||||
@@ -187,7 +197,7 @@ class DLHDExtractor(BaseExtractor):
|
|||||||
stream_headers = {
|
stream_headers = {
|
||||||
"referer": player_origin + "/",
|
"referer": player_origin + "/",
|
||||||
"origin": player_origin,
|
"origin": player_origin,
|
||||||
"user-agent": self.base_headers["user-agent"]
|
"user-agent": self.base_headers["user-agent"],
|
||||||
}
|
}
|
||||||
|
|
||||||
# Return the stream URL with headers
|
# Return the stream URL with headers
|
||||||
@@ -200,14 +210,24 @@ class DLHDExtractor(BaseExtractor):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise ExtractorError(f"Vecloud extraction failed: {str(e)}")
|
raise ExtractorError(f"Vecloud extraction failed: {str(e)}")
|
||||||
|
|
||||||
|
async def _handle_playnow(self, player_iframe: str, channel_origin: str) -> str:
|
||||||
|
"""Handle playnow URLs."""
|
||||||
|
# Set up headers for the playnow request
|
||||||
|
playnow_headers = {"referer": channel_origin + "/", "user-agent": self.base_headers["user-agent"]}
|
||||||
|
|
||||||
|
# Make the playnow request
|
||||||
|
playnow_response = await self._make_request(player_iframe, headers=playnow_headers)
|
||||||
|
player_url = self._extract_player_url(playnow_response.text)
|
||||||
|
if not player_url:
|
||||||
|
raise ExtractorError("Could not extract player URL from playnow response")
|
||||||
|
return player_url
|
||||||
|
|
||||||
def _extract_player_url(self, html_content: str) -> Optional[str]:
|
def _extract_player_url(self, html_content: str) -> Optional[str]:
|
||||||
"""Extract player iframe URL from channel page HTML."""
|
"""Extract player iframe URL from channel page HTML."""
|
||||||
try:
|
try:
|
||||||
# Look for iframe with allowfullscreen attribute
|
# Look for iframe with allowfullscreen attribute
|
||||||
iframe_match = re.search(
|
iframe_match = re.search(
|
||||||
r'<iframe[^>]*src=["\']([^"\']+)["\'][^>]*allowfullscreen',
|
r'<iframe[^>]*src=["\']([^"\']+)["\'][^>]*allowfullscreen', html_content, re.IGNORECASE
|
||||||
html_content,
|
|
||||||
re.IGNORECASE
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if not iframe_match:
|
if not iframe_match:
|
||||||
@@ -215,7 +235,7 @@ class DLHDExtractor(BaseExtractor):
|
|||||||
iframe_match = re.search(
|
iframe_match = re.search(
|
||||||
r'<iframe[^>]*src=["\']([^"\']+(?:premiumtv|daddylivehd|vecloud)[^"\']*)["\']',
|
r'<iframe[^>]*src=["\']([^"\']+(?:premiumtv|daddylivehd|vecloud)[^"\']*)["\']',
|
||||||
html_content,
|
html_content,
|
||||||
re.IGNORECASE
|
re.IGNORECASE,
|
||||||
)
|
)
|
||||||
|
|
||||||
if iframe_match:
|
if iframe_match:
|
||||||
@@ -225,17 +245,16 @@ class DLHDExtractor(BaseExtractor):
|
|||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def _lookup_server(self, lookup_url_base: str, auth_url_base: str, auth_data: Dict[str, str], headers: Dict[str, str]) -> str:
|
async def _lookup_server(
|
||||||
|
self, lookup_url_base: str, auth_url_base: str, auth_data: Dict[str, str], headers: Dict[str, str]
|
||||||
|
) -> str:
|
||||||
"""Lookup server information and generate stream URL."""
|
"""Lookup server information and generate stream URL."""
|
||||||
try:
|
try:
|
||||||
# Construct server lookup URL
|
# Construct server lookup URL
|
||||||
server_lookup_url = f"{lookup_url_base}/server_lookup.php?channel_id={quote(auth_data['channel_key'])}"
|
server_lookup_url = f"{lookup_url_base}/server_lookup.php?channel_id={quote(auth_data['channel_key'])}"
|
||||||
|
|
||||||
# Make server lookup request
|
# Make server lookup request
|
||||||
server_response = await self._make_request(
|
server_response = await self._make_request(server_lookup_url, headers=headers)
|
||||||
server_lookup_url,
|
|
||||||
headers=headers
|
|
||||||
)
|
|
||||||
|
|
||||||
server_data = server_response.json()
|
server_data = server_response.json()
|
||||||
server_key = server_data.get("server_key")
|
server_key = server_data.get("server_key")
|
||||||
@@ -244,13 +263,13 @@ class DLHDExtractor(BaseExtractor):
|
|||||||
raise ExtractorError("Failed to get server key")
|
raise ExtractorError("Failed to get server key")
|
||||||
|
|
||||||
# Extract domain parts from auth URL for constructing stream URL
|
# Extract domain parts from auth URL for constructing stream URL
|
||||||
auth_domain_parts = urlparse(auth_url_base).netloc.split('.')
|
auth_domain_parts = urlparse(auth_url_base).netloc.split(".")
|
||||||
domain_suffix = '.'.join(auth_domain_parts[1:]) if len(auth_domain_parts) > 1 else auth_domain_parts[0]
|
domain_suffix = ".".join(auth_domain_parts[1:]) if len(auth_domain_parts) > 1 else auth_domain_parts[0]
|
||||||
|
|
||||||
# Generate the m3u8 URL based on server response pattern
|
# Generate the m3u8 URL based on server response pattern
|
||||||
if '/' in server_key:
|
if "/" in server_key:
|
||||||
# Handle special case like "top1/cdn"
|
# Handle special case like "top1/cdn"
|
||||||
parts = server_key.split('/')
|
parts = server_key.split("/")
|
||||||
return f"https://{parts[0]}.{domain_suffix}/{server_key}/{auth_data['channel_key']}/mono.m3u8"
|
return f"https://{parts[0]}.{domain_suffix}/{server_key}/{auth_data['channel_key']}/mono.m3u8"
|
||||||
else:
|
else:
|
||||||
# Handle normal case
|
# Handle normal case
|
||||||
@@ -278,7 +297,7 @@ class DLHDExtractor(BaseExtractor):
|
|||||||
"channel_key": channel_key_match.group(1),
|
"channel_key": channel_key_match.group(1),
|
||||||
"auth_ts": auth_ts_match.group(1),
|
"auth_ts": auth_ts_match.group(1),
|
||||||
"auth_rnd": auth_rnd_match.group(1),
|
"auth_rnd": auth_rnd_match.group(1),
|
||||||
"auth_sig": auth_sig_match.group(1)
|
"auth_sig": auth_sig_match.group(1),
|
||||||
}
|
}
|
||||||
except Exception:
|
except Exception:
|
||||||
return {}
|
return {}
|
||||||
@@ -287,21 +306,15 @@ class DLHDExtractor(BaseExtractor):
|
|||||||
"""Extract auth URL base from player page script content."""
|
"""Extract auth URL base from player page script content."""
|
||||||
try:
|
try:
|
||||||
# Look for auth URL or domain in fetchWithRetry call or similar patterns
|
# Look for auth URL or domain in fetchWithRetry call or similar patterns
|
||||||
auth_url_match = re.search(
|
auth_url_match = re.search(r'fetchWithRetry\([\'"]([^\'"]*/auth\.php)', html_content)
|
||||||
r'fetchWithRetry\([\'"]([^\'"]*/auth\.php)',
|
|
||||||
html_content
|
|
||||||
)
|
|
||||||
|
|
||||||
if auth_url_match:
|
if auth_url_match:
|
||||||
auth_url = auth_url_match.group(1)
|
auth_url = auth_url_match.group(1)
|
||||||
# Extract base URL up to the auth.php part
|
# Extract base URL up to the auth.php part
|
||||||
return auth_url.split('/auth.php')[0]
|
return auth_url.split("/auth.php")[0]
|
||||||
|
|
||||||
# Try finding domain directly
|
# Try finding domain directly
|
||||||
domain_match = re.search(
|
domain_match = re.search(r'[\'"]https://([^/\'\"]+)(?:/[^\'\"]*)?/auth\.php', html_content)
|
||||||
r'[\'"]https://([^/\'\"]+)(?:/[^\'\"]*)?/auth\.php',
|
|
||||||
html_content
|
|
||||||
)
|
|
||||||
|
|
||||||
if domain_match:
|
if domain_match:
|
||||||
return f"https://{domain_match.group(1)}"
|
return f"https://{domain_match.group(1)}"
|
||||||
@@ -320,13 +333,13 @@ class DLHDExtractor(BaseExtractor):
|
|||||||
try:
|
try:
|
||||||
# Typical pattern is to use a subdomain for auth domain
|
# Typical pattern is to use a subdomain for auth domain
|
||||||
parsed = urlparse(player_domain)
|
parsed = urlparse(player_domain)
|
||||||
domain_parts = parsed.netloc.split('.')
|
domain_parts = parsed.netloc.split(".")
|
||||||
|
|
||||||
# Get the top-level domain and second-level domain
|
# Get the top-level domain and second-level domain
|
||||||
if len(domain_parts) >= 2:
|
if len(domain_parts) >= 2:
|
||||||
base_domain = '.'.join(domain_parts[-2:])
|
base_domain = ".".join(domain_parts[-2:])
|
||||||
# Try common subdomains for auth
|
# Try common subdomains for auth
|
||||||
for prefix in ['auth', 'api', 'cdn']:
|
for prefix in ["auth", "api", "cdn"]:
|
||||||
potential_auth_domain = f"https://{prefix}.{base_domain}"
|
potential_auth_domain = f"https://{prefix}.{base_domain}"
|
||||||
return potential_auth_domain
|
return potential_auth_domain
|
||||||
|
|
||||||
|
|||||||
@@ -39,15 +39,16 @@ class VixCloudExtractor(BaseExtractor):
|
|||||||
|
|
||||||
async def extract(self, url: str, **kwargs) -> Dict[str, Any]:
|
async def extract(self, url: str, **kwargs) -> Dict[str, Any]:
|
||||||
"""Extract Vixcloud URL."""
|
"""Extract Vixcloud URL."""
|
||||||
site_url = url.split("/iframe")[0]
|
if "iframe" in url:
|
||||||
version = await self.version(site_url)
|
site_url = url.split("/iframe")[0]
|
||||||
response = await self._make_request(url, headers={"x-inertia": "true", "x-inertia-version": version})
|
version = await self.version(site_url)
|
||||||
soup = BeautifulSoup(response.text, "lxml", parse_only=SoupStrainer("iframe"))
|
response = await self._make_request(url, headers={"x-inertia": "true", "x-inertia-version": version})
|
||||||
iframe = soup.find("iframe").get("src")
|
soup = BeautifulSoup(response.text, "lxml", parse_only=SoupStrainer("iframe"))
|
||||||
parsed_url = urlparse(iframe)
|
iframe = soup.find("iframe").get("src")
|
||||||
query_params = parse_qs(parsed_url.query)
|
response = await self._make_request(iframe, headers={"x-inertia": "true", "x-inertia-version": version})
|
||||||
response = await self._make_request(iframe, headers={"x-inertia": "true", "x-inertia-version": version})
|
elif "movie" in url or "tv" in url:
|
||||||
|
response = await self._make_request(url)
|
||||||
|
|
||||||
if response.status_code != 200:
|
if response.status_code != 200:
|
||||||
raise ExtractorError("Failed to extract URL components, Invalid Request")
|
raise ExtractorError("Failed to extract URL components, Invalid Request")
|
||||||
soup = BeautifulSoup(response.text, "lxml", parse_only=SoupStrainer("body"))
|
soup = BeautifulSoup(response.text, "lxml", parse_only=SoupStrainer("body"))
|
||||||
@@ -55,15 +56,15 @@ class VixCloudExtractor(BaseExtractor):
|
|||||||
script = soup.find("body").find("script").text
|
script = soup.find("body").find("script").text
|
||||||
token = re.search(r"'token':\s*'(\w+)'", script).group(1)
|
token = re.search(r"'token':\s*'(\w+)'", script).group(1)
|
||||||
expires = re.search(r"'expires':\s*'(\d+)'", script).group(1)
|
expires = re.search(r"'expires':\s*'(\d+)'", script).group(1)
|
||||||
vixid = iframe.split("/embed/")[1].split("?")[0]
|
canPlayFHD = re.search(r"window\.canPlayFHD\s*=\s*(\w+)", script).group(1)
|
||||||
base_url = iframe.split("://")[1].split("/")[0]
|
print(script,"A")
|
||||||
final_url = f"https://{base_url}/playlist/{vixid}.m3u8?token={token}&expires={expires}"
|
server_url = re.search(r"url:\s*'([^']+)'", script).group(1)
|
||||||
if "canPlayFHD" in query_params:
|
if "?b=1" in server_url:
|
||||||
# canPlayFHD = "h=1"
|
final_url = f'{server_url}&token={token}&expires={expires}'
|
||||||
|
else:
|
||||||
|
final_url = f"{server_url}?token={token}&expires={expires}"
|
||||||
|
if "window.canPlayFHD = true" in script:
|
||||||
final_url += "&h=1"
|
final_url += "&h=1"
|
||||||
if "b" in query_params:
|
|
||||||
# b = "b=1"
|
|
||||||
final_url += "&b=1"
|
|
||||||
self.base_headers["referer"] = url
|
self.base_headers["referer"] = url
|
||||||
return {
|
return {
|
||||||
"destination_url": final_url,
|
"destination_url": final_url,
|
||||||
|
|||||||
@@ -23,4 +23,3 @@ class UIAccessControlMiddleware(BaseHTTPMiddleware):
|
|||||||
return Response(status_code=403, content="Forbidden")
|
return Response(status_code=403, content="Forbidden")
|
||||||
|
|
||||||
return await call_next(request)
|
return await call_next(request)
|
||||||
|
|
||||||
|
|||||||
@@ -172,9 +172,29 @@ def build_hls_playlist(mpd_dict: dict, profiles: list[dict], request: Request) -
|
|||||||
|
|
||||||
# Add headers for only the first profile
|
# Add headers for only the first profile
|
||||||
if index == 0:
|
if index == 0:
|
||||||
sequence = segments[0]["number"]
|
first_segment = segments[0]
|
||||||
extinf_values = [f["extinf"] for f in segments if "extinf" in f]
|
extinf_values = [f["extinf"] for f in segments if "extinf" in f]
|
||||||
target_duration = math.ceil(max(extinf_values)) if extinf_values else 3
|
target_duration = math.ceil(max(extinf_values)) if extinf_values else 3
|
||||||
|
|
||||||
|
# Calculate media sequence using adaptive logic for different MPD types
|
||||||
|
mpd_start_number = profile.get("segment_template_start_number")
|
||||||
|
if mpd_start_number and mpd_start_number >= 1000:
|
||||||
|
# Amazon-style: Use absolute segment numbering
|
||||||
|
sequence = first_segment.get("number", mpd_start_number)
|
||||||
|
else:
|
||||||
|
# Sky-style: Use time-based calculation if available
|
||||||
|
time_val = first_segment.get("time")
|
||||||
|
duration_val = first_segment.get("duration_mpd_timescale")
|
||||||
|
if time_val is not None and duration_val and duration_val > 0:
|
||||||
|
calculated_sequence = math.floor(time_val / duration_val)
|
||||||
|
# For live streams with very large sequence numbers, use modulo to keep reasonable range
|
||||||
|
if mpd_dict.get("isLive", False) and calculated_sequence > 100000:
|
||||||
|
sequence = calculated_sequence % 100000
|
||||||
|
else:
|
||||||
|
sequence = calculated_sequence
|
||||||
|
else:
|
||||||
|
sequence = first_segment.get("number", 1)
|
||||||
|
|
||||||
hls.extend(
|
hls.extend(
|
||||||
[
|
[
|
||||||
f"#EXT-X-TARGETDURATION:{target_duration}",
|
f"#EXT-X-TARGETDURATION:{target_duration}",
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,9 +1,11 @@
|
|||||||
import uuid
|
from fastapi import APIRouter, HTTPException
|
||||||
|
|
||||||
from fastapi import APIRouter, BackgroundTasks, HTTPException, Request
|
|
||||||
from fastapi.responses import RedirectResponse
|
from fastapi.responses import RedirectResponse
|
||||||
|
|
||||||
from mediaflow_proxy.speedtest.service import SpeedTestService, SpeedTestProvider
|
from mediaflow_proxy.speedtest.models import (
|
||||||
|
BrowserSpeedTestConfig,
|
||||||
|
BrowserSpeedTestRequest,
|
||||||
|
)
|
||||||
|
from mediaflow_proxy.speedtest.service import SpeedTestService
|
||||||
|
|
||||||
speedtest_router = APIRouter()
|
speedtest_router = APIRouter()
|
||||||
|
|
||||||
@@ -11,33 +13,29 @@ speedtest_router = APIRouter()
|
|||||||
speedtest_service = SpeedTestService()
|
speedtest_service = SpeedTestService()
|
||||||
|
|
||||||
|
|
||||||
@speedtest_router.get("/", summary="Show speed test interface")
|
@speedtest_router.get("/", summary="Show browser speed test interface")
|
||||||
async def show_speedtest_page():
|
async def show_speedtest_page():
|
||||||
"""Return the speed test HTML interface."""
|
"""Return the browser-based speed test HTML interface."""
|
||||||
return RedirectResponse(url="/speedtest.html")
|
return RedirectResponse(url="/speedtest.html")
|
||||||
|
|
||||||
|
|
||||||
@speedtest_router.post("/start", summary="Start a new speed test", response_model=dict)
|
@speedtest_router.post("/config", summary="Get browser speed test configuration")
|
||||||
async def start_speedtest(background_tasks: BackgroundTasks, provider: SpeedTestProvider, request: Request):
|
async def get_browser_speedtest_config(
|
||||||
"""Start a new speed test for the specified provider."""
|
test_request: BrowserSpeedTestRequest,
|
||||||
task_id = str(uuid.uuid4())
|
) -> BrowserSpeedTestConfig:
|
||||||
api_key = request.headers.get("api_key")
|
"""Get configuration for browser-based speed test."""
|
||||||
|
try:
|
||||||
|
provider_impl = speedtest_service.get_provider(test_request.provider, test_request.api_key)
|
||||||
|
|
||||||
# Create and initialize the task
|
# Get test URLs and user info
|
||||||
await speedtest_service.create_test(task_id, provider, api_key)
|
test_urls, user_info = await provider_impl.get_test_urls()
|
||||||
|
config = await provider_impl.get_config()
|
||||||
|
|
||||||
# Schedule the speed test
|
return BrowserSpeedTestConfig(
|
||||||
background_tasks.add_task(speedtest_service.run_speedtest, task_id, provider, api_key)
|
provider=test_request.provider,
|
||||||
|
test_urls=test_urls,
|
||||||
return {"task_id": task_id}
|
test_duration=config.test_duration,
|
||||||
|
user_info=user_info,
|
||||||
|
)
|
||||||
@speedtest_router.get("/results/{task_id}", summary="Get speed test results")
|
except Exception as e:
|
||||||
async def get_speedtest_results(task_id: str):
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
"""Get the results or current status of a speed test."""
|
|
||||||
task = await speedtest_service.get_test_results(task_id)
|
|
||||||
|
|
||||||
if not task:
|
|
||||||
raise HTTPException(status_code=404, detail="Speed test task not found or expired")
|
|
||||||
|
|
||||||
return task.dict()
|
|
||||||
|
|||||||
@@ -98,7 +98,6 @@ class ExtractorURLParams(GenericParams):
|
|||||||
description="Additional parameters required for specific extractors (e.g., stream_title for LiveTV)",
|
description="Additional parameters required for specific extractors (e.g., stream_title for LiveTV)",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@field_validator("extra_params", mode="before")
|
@field_validator("extra_params", mode="before")
|
||||||
def validate_extra_params(cls, value: Any):
|
def validate_extra_params(cls, value: Any):
|
||||||
if isinstance(value, str):
|
if isinstance(value, str):
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,8 +1,7 @@
|
|||||||
from datetime import datetime
|
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import Dict, Optional
|
from typing import Dict, Optional
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, HttpUrl
|
||||||
|
|
||||||
|
|
||||||
class SpeedTestProvider(str, Enum):
|
class SpeedTestProvider(str, Enum):
|
||||||
@@ -21,26 +20,20 @@ class UserInfo(BaseModel):
|
|||||||
country: Optional[str] = None
|
country: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class SpeedTestResult(BaseModel):
|
class MediaFlowServer(BaseModel):
|
||||||
speed_mbps: float = Field(..., description="Speed in Mbps")
|
url: HttpUrl
|
||||||
duration: float = Field(..., description="Test duration in seconds")
|
api_password: Optional[str] = None
|
||||||
data_transferred: int = Field(..., description="Data transferred in bytes")
|
name: Optional[str] = None
|
||||||
timestamp: datetime = Field(default_factory=datetime.utcnow)
|
|
||||||
|
|
||||||
|
|
||||||
class LocationResult(BaseModel):
|
class BrowserSpeedTestConfig(BaseModel):
|
||||||
result: Optional[SpeedTestResult] = None
|
|
||||||
error: Optional[str] = None
|
|
||||||
server_name: str
|
|
||||||
server_url: str
|
|
||||||
|
|
||||||
|
|
||||||
class SpeedTestTask(BaseModel):
|
|
||||||
task_id: str
|
|
||||||
provider: SpeedTestProvider
|
provider: SpeedTestProvider
|
||||||
results: Dict[str, LocationResult] = {}
|
test_urls: Dict[str, str]
|
||||||
started_at: datetime
|
test_duration: int = 10
|
||||||
completed_at: Optional[datetime] = None
|
|
||||||
status: str = "running"
|
|
||||||
user_info: Optional[UserInfo] = None
|
user_info: Optional[UserInfo] = None
|
||||||
current_location: Optional[str] = None
|
|
||||||
|
|
||||||
|
class BrowserSpeedTestRequest(BaseModel):
|
||||||
|
provider: SpeedTestProvider
|
||||||
|
api_key: Optional[str] = None
|
||||||
|
current_api_password: Optional[str] = None
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,20 +1,13 @@
|
|||||||
import logging
|
|
||||||
import time
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from typing import Dict, Optional, Type
|
from typing import Dict, Optional, Type
|
||||||
|
|
||||||
from mediaflow_proxy.utils.cache_utils import get_cached_speedtest, set_cache_speedtest
|
from .models import SpeedTestProvider
|
||||||
from mediaflow_proxy.utils.http_utils import Streamer, create_httpx_client
|
|
||||||
from .models import SpeedTestTask, LocationResult, SpeedTestResult, SpeedTestProvider
|
|
||||||
from .providers.all_debrid import AllDebridSpeedTest
|
from .providers.all_debrid import AllDebridSpeedTest
|
||||||
from .providers.base import BaseSpeedTestProvider
|
from .providers.base import BaseSpeedTestProvider
|
||||||
from .providers.real_debrid import RealDebridSpeedTest
|
from .providers.real_debrid import RealDebridSpeedTest
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class SpeedTestService:
|
class SpeedTestService:
|
||||||
"""Service for managing speed tests across different providers."""
|
"""Service for managing speed test provider configurations."""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
# Provider mapping
|
# Provider mapping
|
||||||
@@ -23,7 +16,7 @@ class SpeedTestService:
|
|||||||
SpeedTestProvider.ALL_DEBRID: AllDebridSpeedTest,
|
SpeedTestProvider.ALL_DEBRID: AllDebridSpeedTest,
|
||||||
}
|
}
|
||||||
|
|
||||||
def _get_provider(self, provider: SpeedTestProvider, api_key: Optional[str] = None) -> BaseSpeedTestProvider:
|
def get_provider(self, provider: SpeedTestProvider, api_key: Optional[str] = None) -> BaseSpeedTestProvider:
|
||||||
"""Get the appropriate provider implementation."""
|
"""Get the appropriate provider implementation."""
|
||||||
provider_class = self._providers.get(provider)
|
provider_class = self._providers.get(provider)
|
||||||
if not provider_class:
|
if not provider_class:
|
||||||
@@ -33,97 +26,3 @@ class SpeedTestService:
|
|||||||
raise ValueError("API key required for AllDebrid")
|
raise ValueError("API key required for AllDebrid")
|
||||||
|
|
||||||
return provider_class(api_key) if provider == SpeedTestProvider.ALL_DEBRID else provider_class()
|
return provider_class(api_key) if provider == SpeedTestProvider.ALL_DEBRID else provider_class()
|
||||||
|
|
||||||
async def create_test(
|
|
||||||
self, task_id: str, provider: SpeedTestProvider, api_key: Optional[str] = None
|
|
||||||
) -> SpeedTestTask:
|
|
||||||
"""Create a new speed test task."""
|
|
||||||
provider_impl = self._get_provider(provider, api_key)
|
|
||||||
|
|
||||||
# Get initial URLs and user info
|
|
||||||
urls, user_info = await provider_impl.get_test_urls()
|
|
||||||
|
|
||||||
task = SpeedTestTask(
|
|
||||||
task_id=task_id, provider=provider, started_at=datetime.now(tz=timezone.utc), user_info=user_info
|
|
||||||
)
|
|
||||||
|
|
||||||
await set_cache_speedtest(task_id, task)
|
|
||||||
return task
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
async def get_test_results(task_id: str) -> Optional[SpeedTestTask]:
|
|
||||||
"""Get results for a specific task."""
|
|
||||||
return await get_cached_speedtest(task_id)
|
|
||||||
|
|
||||||
async def run_speedtest(self, task_id: str, provider: SpeedTestProvider, api_key: Optional[str] = None):
|
|
||||||
"""Run the speed test with real-time updates."""
|
|
||||||
try:
|
|
||||||
task = await get_cached_speedtest(task_id)
|
|
||||||
if not task:
|
|
||||||
raise ValueError(f"Task {task_id} not found")
|
|
||||||
|
|
||||||
provider_impl = self._get_provider(provider, api_key)
|
|
||||||
config = await provider_impl.get_config()
|
|
||||||
|
|
||||||
async with create_httpx_client() as client:
|
|
||||||
streamer = Streamer(client)
|
|
||||||
|
|
||||||
for location, url in config.test_urls.items():
|
|
||||||
try:
|
|
||||||
task.current_location = location
|
|
||||||
await set_cache_speedtest(task_id, task)
|
|
||||||
result = await self._test_location(location, url, streamer, config.test_duration, provider_impl)
|
|
||||||
task.results[location] = result
|
|
||||||
await set_cache_speedtest(task_id, task)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error testing {location}: {str(e)}")
|
|
||||||
task.results[location] = LocationResult(
|
|
||||||
error=str(e), server_name=location, server_url=config.test_urls[location]
|
|
||||||
)
|
|
||||||
await set_cache_speedtest(task_id, task)
|
|
||||||
|
|
||||||
# Mark task as completed
|
|
||||||
task.completed_at = datetime.now(tz=timezone.utc)
|
|
||||||
task.status = "completed"
|
|
||||||
task.current_location = None
|
|
||||||
await set_cache_speedtest(task_id, task)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error in speed test task {task_id}: {str(e)}")
|
|
||||||
if task := await get_cached_speedtest(task_id):
|
|
||||||
task.status = "failed"
|
|
||||||
await set_cache_speedtest(task_id, task)
|
|
||||||
|
|
||||||
async def _test_location(
|
|
||||||
self, location: str, url: str, streamer: Streamer, test_duration: int, provider: BaseSpeedTestProvider
|
|
||||||
) -> LocationResult:
|
|
||||||
"""Test speed for a specific location."""
|
|
||||||
try:
|
|
||||||
start_time = time.time()
|
|
||||||
total_bytes = 0
|
|
||||||
|
|
||||||
await streamer.create_streaming_response(url, headers={})
|
|
||||||
|
|
||||||
async for chunk in streamer.stream_content():
|
|
||||||
if time.time() - start_time >= test_duration:
|
|
||||||
break
|
|
||||||
total_bytes += len(chunk)
|
|
||||||
|
|
||||||
duration = time.time() - start_time
|
|
||||||
speed_mbps = (total_bytes * 8) / (duration * 1_000_000)
|
|
||||||
|
|
||||||
# Get server info if available (for AllDebrid)
|
|
||||||
server_info = getattr(provider, "servers", {}).get(location)
|
|
||||||
server_url = server_info.url if server_info else url
|
|
||||||
|
|
||||||
return LocationResult(
|
|
||||||
result=SpeedTestResult(
|
|
||||||
speed_mbps=round(speed_mbps, 2), duration=round(duration, 2), data_transferred=total_bytes
|
|
||||||
),
|
|
||||||
server_name=location,
|
|
||||||
server_url=server_url,
|
|
||||||
)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error testing {location}: {str(e)}")
|
|
||||||
raise # Re-raise to be handled by run_speedtest
|
|
||||||
|
|||||||
@@ -43,6 +43,44 @@
|
|||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.speed-test-section {
|
||||||
|
background-color: #e8f4fd;
|
||||||
|
border-left: 4px solid #2196f3;
|
||||||
|
padding: 15px;
|
||||||
|
margin: 20px 0;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.speed-test-links {
|
||||||
|
display: flex;
|
||||||
|
gap: 15px;
|
||||||
|
margin-top: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.speed-test-link {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 10px 20px;
|
||||||
|
background-color: #2196f3;
|
||||||
|
color: white;
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: 5px;
|
||||||
|
transition: background-color 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.speed-test-link:hover {
|
||||||
|
background-color: #1976d2;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.speed-test-link.browser {
|
||||||
|
background-color: #4caf50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.speed-test-link.browser:hover {
|
||||||
|
background-color: #388e3c;
|
||||||
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
color: #3498db;
|
color: #3498db;
|
||||||
}
|
}
|
||||||
@@ -63,6 +101,17 @@
|
|||||||
<div class="feature">Proxy and modify HLS (M3U8) streams in real-time with custom headers and key URL modifications for bypassing some sneaky restrictions.</div>
|
<div class="feature">Proxy and modify HLS (M3U8) streams in real-time with custom headers and key URL modifications for bypassing some sneaky restrictions.</div>
|
||||||
<div class="feature">Protect against unauthorized access and network bandwidth abuses</div>
|
<div class="feature">Protect against unauthorized access and network bandwidth abuses</div>
|
||||||
|
|
||||||
|
<div class="speed-test-section">
|
||||||
|
<h3>🚀 Speed Test Tool</h3>
|
||||||
|
<p>Test your connection speed with debrid services to optimize your streaming experience:</p>
|
||||||
|
<div class="speed-test-links">
|
||||||
|
<a href="/speedtest" class="speed-test-link browser">Browser Speed Test</a>
|
||||||
|
</div>
|
||||||
|
<p style="margin-top: 10px; font-size: 14px; color: #666;">
|
||||||
|
<strong>Browser Speed Test:</strong> Tests your actual connection speed through MediaFlow proxy vs direct connection with support for multiple servers, interactive charts, and comprehensive analytics.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<h2>Getting Started</h2>
|
<h2>Getting Started</h2>
|
||||||
<p>Visit the <a href="https://github.com/mhdzumair/mediaflow-proxy">GitHub repository</a> for installation instructions and documentation.</p>
|
<p>Visit the <a href="https://github.com/mhdzumair/mediaflow-proxy">GitHub repository</a> for installation instructions and documentation.</p>
|
||||||
|
|
||||||
|
|||||||
@@ -3,38 +3,39 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Debrid Speed Test</title>
|
<title>MediaFlow Speed Test</title>
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||||
|
<script src="/speedtest.js"></script>
|
||||||
<script>
|
<script>
|
||||||
tailwind.config = {
|
tailwind.config = {
|
||||||
darkMode: 'class',
|
darkMode: 'class',
|
||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
animation: {
|
animation: {
|
||||||
'progress': 'progress 180s linear forwards',
|
'pulse-slow': 'pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite',
|
||||||
},
|
'bounce-slow': 'bounce 2s infinite',
|
||||||
keyframes: {
|
|
||||||
progress: {
|
|
||||||
'0%': {width: '0%'},
|
|
||||||
'100%': {width: '100%'}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
<style>
|
<style>
|
||||||
.provider-card {
|
.result-card {
|
||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.provider-card:hover {
|
.result-card:hover {
|
||||||
transform: translateY(-5px);
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-input {
|
||||||
|
animation: slideIn 0.3s ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes slideIn {
|
@keyframes slideIn {
|
||||||
from {
|
from {
|
||||||
transform: translateY(20px);
|
transform: translateY(-10px);
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
to {
|
to {
|
||||||
@@ -43,8 +44,21 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.slide-in {
|
.metric-card {
|
||||||
animation: slideIn 0.3s ease-out forwards;
|
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
|
||||||
|
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-card.success {
|
||||||
|
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-card.warning {
|
||||||
|
background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-card.error {
|
||||||
|
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
@@ -63,634 +77,301 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<main class="container mx-auto px-4 py-8">
|
<main class="container mx-auto px-4 py-8">
|
||||||
<!-- Views Container -->
|
<!-- Header -->
|
||||||
<div id="views-container">
|
<div class="text-center mb-8">
|
||||||
<!-- API Password View -->
|
<h1 class="text-4xl font-bold text-gray-800 dark:text-white mb-2">
|
||||||
<div id="passwordView" class="space-y-8">
|
🚀 MediaFlow Speed Test
|
||||||
<h1 class="text-3xl font-bold text-center text-gray-800 dark:text-white mb-8">
|
</h1>
|
||||||
Enter API Password
|
<p class="text-gray-600 dark:text-gray-300">
|
||||||
</h1>
|
Compare your connection speed through MediaFlow proxy vs direct connection
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="max-w-md mx-auto">
|
<!-- Configuration View -->
|
||||||
<form id="passwordForm" class="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6 space-y-4">
|
<div id="configView" class="max-w-4xl mx-auto space-y-6">
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6">
|
||||||
|
<h2 class="text-2xl font-semibold text-gray-800 dark:text-white mb-6">Test Configuration</h2>
|
||||||
|
|
||||||
|
<form id="configForm" class="space-y-6">
|
||||||
|
<!-- Provider Selection -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<label for="apiPassword" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
API Password
|
Debrid Provider
|
||||||
</label>
|
</label>
|
||||||
<input
|
<select id="provider"
|
||||||
type="password"
|
class="w-full px-4 py-2 rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||||
id="apiPassword"
|
<option value="real_debrid">Real-Debrid</option>
|
||||||
class="w-full px-4 py-2 rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
<option value="all_debrid">AllDebrid</option>
|
||||||
required
|
</select>
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center space-x-2">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
id="rememberPassword"
|
|
||||||
class="rounded border-gray-300 dark:border-gray-600"
|
|
||||||
>
|
|
||||||
<label for="rememberPassword" class="text-sm text-gray-600 dark:text-gray-400">
|
|
||||||
Remember password
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
class="w-full px-4 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-colors"
|
|
||||||
>
|
|
||||||
Continue
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Provider Selection View -->
|
<!-- API Key (for AllDebrid) -->
|
||||||
<div id="selectionView" class="space-y-8 hidden">
|
<div id="apiKeySection" class="space-y-2 hidden">
|
||||||
<h1 class="text-3xl font-bold text-center text-gray-800 dark:text-white mb-8">
|
<label for="apiKey" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
Select Debrid Service for Speed Test
|
|
||||||
</h1>
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 max-w-4xl mx-auto">
|
|
||||||
<!-- Real-Debrid Card -->
|
|
||||||
<button onclick="startTest('real_debrid')" class="provider-card bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6 text-left hover:shadow-xl transition-shadow">
|
|
||||||
<h2 class="text-xl font-semibold text-gray-800 dark:text-white mb-2">Real-Debrid</h2>
|
|
||||||
<p class="text-gray-600 dark:text-gray-300">Test speeds across multiple Real-Debrid servers worldwide</p>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- AllDebrid Card -->
|
|
||||||
<button onclick="showAllDebridSetup()" class="provider-card bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6 text-left hover:shadow-xl transition-shadow">
|
|
||||||
<h2 class="text-xl font-semibold text-gray-800 dark:text-white mb-2">AllDebrid</h2>
|
|
||||||
<p class="text-gray-600 dark:text-gray-300">Measure download speeds from AllDebrid servers</p>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- AllDebrid Setup View -->
|
|
||||||
<div id="allDebridSetupView" class="max-w-md mx-auto space-y-6 hidden">
|
|
||||||
<h2 class="text-2xl font-bold text-center text-gray-800 dark:text-white mb-8">
|
|
||||||
AllDebrid Setup
|
|
||||||
</h2>
|
|
||||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6">
|
|
||||||
<form id="allDebridForm" class="space-y-4">
|
|
||||||
<div class="space-y-2">
|
|
||||||
<label for="adApiKey" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
|
||||||
AllDebrid API Key
|
AllDebrid API Key
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
id="adApiKey"
|
id="apiKey"
|
||||||
class="w-full px-4 py-2 rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
class="w-full px-4 py-2 rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
required
|
placeholder="Enter your AllDebrid API key"
|
||||||
>
|
>
|
||||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
|
||||||
You can find your API key in the AllDebrid dashboard
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center space-x-2">
|
</div>
|
||||||
<input
|
|
||||||
type="checkbox"
|
<!-- Current Instance API Password -->
|
||||||
id="rememberAdKey"
|
<div class="space-y-2">
|
||||||
class="rounded border-gray-300 dark:border-gray-600"
|
<label for="currentApiPassword" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
>
|
Current MediaFlow API Password (if required)
|
||||||
<label for="rememberAdKey" class="text-sm text-gray-600 dark:text-gray-400">
|
</label>
|
||||||
Remember API key
|
<input
|
||||||
|
type="password"
|
||||||
|
id="currentApiPassword"
|
||||||
|
class="w-full px-4 py-2 rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
placeholder="Enter API password for current instance"
|
||||||
|
>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Required to fetch test configuration if this instance has API password protection
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- MediaFlow Servers -->
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
MediaFlow Servers to Test
|
||||||
</label>
|
</label>
|
||||||
</div>
|
|
||||||
<div class="flex space-x-3">
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick="showView('selectionView')"
|
id="addServerBtn"
|
||||||
class="flex-1 px-4 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-md hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors"
|
class="px-3 py-1 bg-blue-500 text-white rounded-md hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm"
|
||||||
>
|
>
|
||||||
Back
|
+ Add Server
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
class="flex-1 px-4 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-colors"
|
|
||||||
>
|
|
||||||
Start Test
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Testing View -->
|
<div id="serversContainer" class="space-y-3">
|
||||||
<div id="testingView" class="max-w-4xl mx-auto space-y-6 hidden">
|
<!-- Current server (auto-added) -->
|
||||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6">
|
<div class="server-input grid grid-cols-1 md:grid-cols-3 gap-3 p-3 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||||
<!-- User Info Section -->
|
<input
|
||||||
<div id="userInfo" class="mb-6 hidden">
|
type="url"
|
||||||
<!-- User info will be populated dynamically -->
|
placeholder="MediaFlow URL"
|
||||||
|
class="server-url w-full px-3 py-2 rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Server Name (optional)"
|
||||||
|
class="server-name w-full px-3 py-2 rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm"
|
||||||
|
>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
placeholder="API Password (optional)"
|
||||||
|
class="server-password flex-1 px-3 py-2 rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="remove-server px-2 py-2 bg-red-500 text-white rounded-md hover:bg-red-600 focus:outline-none text-sm hidden"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Progress Section -->
|
<!-- CDN Location Selection -->
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div class="text-center text-gray-600 dark:text-gray-300" id="currentLocation">
|
<h3 class="text-lg font-medium text-gray-800 dark:text-white">CDN Locations</h3>
|
||||||
Initializing test...
|
|
||||||
|
<!-- CDN Status and Selection Container -->
|
||||||
|
<div id="cdnStatusContainer">
|
||||||
|
<!-- Status message will be populated here -->
|
||||||
</div>
|
</div>
|
||||||
<div class="h-2 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
|
|
||||||
<div class="h-full bg-blue-500 animate-progress" id="progressBar"></div>
|
<div id="cdnSelection" class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">
|
||||||
|
<!-- CDN checkboxes will be populated here -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<button type="button" id="refreshCdnBtn"
|
||||||
|
class="px-4 py-2 bg-gradient-to-r from-green-500 to-emerald-600 text-white rounded-lg hover:from-green-600 hover:to-emerald-700 focus:outline-none focus:ring-2 focus:ring-green-500 transition-all duration-200 font-medium shadow-md">
|
||||||
|
🔄 Refresh CDNs
|
||||||
|
</button>
|
||||||
|
<button type="button" id="selectAllCdn"
|
||||||
|
class="px-3 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors text-sm font-medium">Select
|
||||||
|
All
|
||||||
|
</button>
|
||||||
|
<button type="button" id="selectNoneCdn"
|
||||||
|
class="px-3 py-2 bg-gray-500 text-white rounded-lg hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-gray-500 transition-colors text-sm font-medium">Select
|
||||||
|
None
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Results Container -->
|
<!-- Test Options -->
|
||||||
<div id="resultsContainer" class="mt-8">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
<!-- Results will be populated dynamically -->
|
<div class="space-y-4">
|
||||||
</div>
|
<h3 class="text-lg font-medium text-gray-800 dark:text-white">Test Options</h3>
|
||||||
</div>
|
<div class="space-y-2">
|
||||||
</div>
|
<div class="flex items-center space-x-2">
|
||||||
|
<input type="checkbox" id="testProxy" checked class="rounded border-gray-300 dark:border-gray-600">
|
||||||
<!-- Results View -->
|
<label for="testProxy" class="text-sm text-gray-700 dark:text-gray-300">
|
||||||
<div id="resultsView" class="max-w-4xl mx-auto space-y-6 hidden">
|
Test through MediaFlow proxy
|
||||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6">
|
</label>
|
||||||
<div class="space-y-6">
|
|
||||||
<!-- Summary Section -->
|
|
||||||
<div class="border-b border-gray-200 dark:border-gray-700 pb-4">
|
|
||||||
<h3 class="text-lg font-semibold text-gray-800 dark:text-white mb-4">Test Summary</h3>
|
|
||||||
<div class="grid grid-cols-2 md:grid-cols-3 gap-4">
|
|
||||||
<div class="space-y-1">
|
|
||||||
<div class="text-sm text-gray-500 dark:text-gray-400">Fastest Server</div>
|
|
||||||
<div id="fastestServer" class="font-medium text-gray-900 dark:text-white"></div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-1">
|
<div class="flex items-center space-x-2">
|
||||||
<div class="text-sm text-gray-500 dark:text-gray-400">Top Speed</div>
|
<input type="checkbox" id="testDirect" checked class="rounded border-gray-300 dark:border-gray-600">
|
||||||
<div id="topSpeed" class="font-medium text-green-500"></div>
|
<label for="testDirect" class="text-sm text-gray-700 dark:text-gray-300">
|
||||||
</div>
|
Test direct connection (for comparison)
|
||||||
<div class="space-y-1">
|
</label>
|
||||||
<div class="text-sm text-gray-500 dark:text-gray-400">Average Speed</div>
|
|
||||||
<div id="avgSpeed" class="font-medium text-blue-500"></div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Detailed Results -->
|
<div class="space-y-4">
|
||||||
<div id="finalResults" class="space-y-4">
|
<h3 class="text-lg font-medium text-gray-800 dark:text-white">Advanced Settings</h3>
|
||||||
<!-- Results will be populated here -->
|
<div class="space-y-2">
|
||||||
|
<label for="testDuration" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
Test Duration (seconds)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id="testDuration"
|
||||||
|
value="10"
|
||||||
|
min="5"
|
||||||
|
max="30"
|
||||||
|
class="w-full px-3 py-2 rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="text-center mt-6">
|
<button
|
||||||
<button onclick="resetTest()" class="px-6 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors">
|
type="submit"
|
||||||
Test Another Provider
|
class="w-full px-6 py-3 bg-gradient-to-r from-blue-500 to-purple-600 text-white rounded-md hover:from-blue-600 hover:to-purple-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-all duration-200 font-medium"
|
||||||
|
>
|
||||||
|
🚀 Start Speed Test
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Testing View -->
|
||||||
|
<div id="testingView" class="max-w-6xl mx-auto space-y-6 hidden">
|
||||||
|
<!-- Progress Section -->
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6">
|
||||||
|
<div class="text-center space-y-4">
|
||||||
|
<h2 class="text-2xl font-semibold text-gray-800 dark:text-white">Running Speed Tests</h2>
|
||||||
|
<div id="currentTest" class="text-gray-600 dark:text-gray-300">
|
||||||
|
Initializing...
|
||||||
|
</div>
|
||||||
|
<div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-3">
|
||||||
|
<div id="progressBar" class="bg-gradient-to-r from-blue-500 to-purple-600 h-3 rounded-full transition-all duration-300" style="width: 0%"></div>
|
||||||
|
</div>
|
||||||
|
<div id="progressText" class="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
0% complete
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
id="cancelTestBtn"
|
||||||
|
class="mt-4 px-6 py-2 bg-red-500 text-white rounded-md hover:bg-red-600 focus:outline-none focus:ring-2 focus:ring-red-500 transition-colors"
|
||||||
|
>
|
||||||
|
⏹️ Cancel Test
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Error View -->
|
<!-- Live Results -->
|
||||||
<div id="errorView" class="max-w-4xl mx-auto space-y-6 hidden">
|
<div id="liveResults" class="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||||
<div class="bg-red-50 dark:bg-red-900/50 border-l-4 border-red-500 p-4 rounded">
|
<!-- Results will be populated here -->
|
||||||
<div class="flex">
|
</div>
|
||||||
<div class="flex-shrink-0">
|
</div>
|
||||||
<svg class="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
|
|
||||||
<path fill-rule="evenodd"
|
|
||||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
|
|
||||||
clip-rule="evenodd"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div class="ml-3">
|
|
||||||
<p class="text-sm text-red-700 dark:text-red-200" id="errorMessage"></p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="text-center">
|
<!-- Results View -->
|
||||||
<button onclick="resetTest()"
|
<div id="resultsView" class="max-w-7xl mx-auto space-y-6 hidden">
|
||||||
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 dark:hover:bg-blue-500 transition-colors duration-200">
|
<!-- Key Metrics -->
|
||||||
Try Again
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||||
</button>
|
<div id="bestProxyMetric" class="metric-card text-white p-4 rounded-lg text-center">
|
||||||
|
<div class="text-2xl font-bold" id="bestProxySpeed">-- Mbps</div>
|
||||||
|
<div class="text-sm opacity-90">Best Proxy Speed</div>
|
||||||
|
<div class="text-xs opacity-75 mt-1" id="bestProxyServer">--</div>
|
||||||
|
</div>
|
||||||
|
<div id="bestDirectMetric" class="metric-card text-white p-4 rounded-lg text-center">
|
||||||
|
<div class="text-2xl font-bold" id="bestDirectSpeed">-- Mbps</div>
|
||||||
|
<div class="text-sm opacity-90">Best Direct Speed</div>
|
||||||
|
<div class="text-xs opacity-75 mt-1" id="bestDirectLocation">--</div>
|
||||||
|
</div>
|
||||||
|
<div id="avgProxyMetric" class="metric-card text-white p-4 rounded-lg text-center">
|
||||||
|
<div class="text-2xl font-bold" id="avgProxySpeed">-- Mbps</div>
|
||||||
|
<div class="text-sm opacity-90">Avg Proxy Speed</div>
|
||||||
|
<div class="text-xs opacity-75 mt-1" id="proxyTestCount">-- tests</div>
|
||||||
|
</div>
|
||||||
|
<div id="speedDiffMetric" class="metric-card text-white p-4 rounded-lg text-center">
|
||||||
|
<div class="text-2xl font-bold" id="speedDifference">--%</div>
|
||||||
|
<div class="text-sm opacity-90">Speed Difference</div>
|
||||||
|
<div class="text-xs opacity-75 mt-1">Proxy vs Direct</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Server Comparison Metrics -->
|
||||||
|
<div id="serverMetrics" class="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6 mb-6">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-800 dark:text-white mb-4">MediaFlow Server Performance</h3>
|
||||||
|
<div id="serverComparisonGrid" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
<!-- Server metrics will be populated here -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Charts Section -->
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-800 dark:text-white mb-4">Speed Comparison by Location</h3>
|
||||||
|
<div class="relative h-64 w-full">
|
||||||
|
<canvas id="speedChart"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-800 dark:text-white mb-4">Server Performance Overview</h3>
|
||||||
|
<div class="relative h-64 w-full">
|
||||||
|
<canvas id="serverChart"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Detailed Results -->
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6">
|
||||||
|
<h2 class="text-2xl font-semibold text-gray-800 dark:text-white mb-4">Detailed Results</h2>
|
||||||
|
<div id="detailedResults" class="space-y-4">
|
||||||
|
<!-- Detailed results will be populated here -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="text-center">
|
||||||
|
<button
|
||||||
|
id="runAgainBtn"
|
||||||
|
class="px-6 py-3 bg-gradient-to-r from-green-500 to-blue-600 text-white rounded-md hover:from-green-600 hover:to-blue-700 focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2 transition-all duration-200 font-medium"
|
||||||
|
>
|
||||||
|
🔄 Run Another Test
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// Config and State
|
// Theme management
|
||||||
const STATE = {
|
const themeToggle = document.getElementById('themeToggle');
|
||||||
apiPassword: localStorage.getItem('speedtest_api_password'),
|
const html = document.documentElement;
|
||||||
adApiKey: localStorage.getItem('ad_api_key'),
|
|
||||||
currentTaskId: null,
|
|
||||||
resultsCount: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Theme handling
|
// Check for saved theme preference or default to light mode
|
||||||
function setTheme(theme) {
|
const savedTheme = localStorage.getItem('theme') || 'light';
|
||||||
document.documentElement.classList.toggle('dark', theme === 'dark');
|
html.classList.toggle('dark', savedTheme === 'dark');
|
||||||
localStorage.theme = theme;
|
|
||||||
}
|
|
||||||
|
|
||||||
function initTheme() {
|
themeToggle.addEventListener('click', () => {
|
||||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
html.classList.toggle('dark');
|
||||||
setTheme(localStorage.theme || (prefersDark ? 'dark' : 'light'));
|
const newTheme = html.classList.contains('dark') ? 'dark' : 'light';
|
||||||
}
|
localStorage.setItem('theme', newTheme);
|
||||||
|
|
||||||
// View management
|
|
||||||
function showView(viewId) {
|
|
||||||
document.querySelectorAll('#views-container > div').forEach(view => {
|
|
||||||
view.classList.toggle('hidden', view.id !== viewId);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
function createErrorResult(location, data) {
|
|
||||||
return `
|
|
||||||
<div class="py-4">
|
|
||||||
<div class="flex justify-between items-center">
|
|
||||||
<div>
|
|
||||||
<span class="font-medium text-gray-800 dark:text-white">${location}</span>
|
|
||||||
<span class="ml-2 text-sm text-gray-500 dark:text-gray-400">${data.server_name || ''}</span>
|
|
||||||
</div>
|
|
||||||
<span class="text-sm text-red-500 dark:text-red-400">
|
|
||||||
Failed
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="mt-1 text-sm text-red-400 dark:text-red-300">
|
|
||||||
${data.error || 'Test failed'}
|
|
||||||
</div>
|
|
||||||
<div class="mt-1 text-xs text-gray-400 dark:text-gray-500">
|
|
||||||
Server: ${data.server_url}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
function formatBytes(bytes) {
|
|
||||||
const units = ['B', 'KB', 'MB', 'GB'];
|
|
||||||
let value = bytes;
|
|
||||||
let unitIndex = 0;
|
|
||||||
|
|
||||||
while (value >= 1024 && unitIndex < units.length - 1) {
|
|
||||||
value /= 1024;
|
|
||||||
unitIndex++;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${value.toFixed(2)} ${units[unitIndex]}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleAuthError() {
|
|
||||||
localStorage.removeItem('speedtest_api_password');
|
|
||||||
STATE.apiPassword = null;
|
|
||||||
showError('Authentication failed. Please check your API password.');
|
|
||||||
}
|
|
||||||
|
|
||||||
function showError(message) {
|
|
||||||
document.getElementById('errorMessage').textContent = message;
|
|
||||||
showView('errorView');
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetTest() {
|
|
||||||
window.location.reload();
|
|
||||||
}
|
|
||||||
|
|
||||||
function showAllDebridSetup() {
|
|
||||||
showView('allDebridSetupView');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function startTest(provider) {
|
|
||||||
if (provider === 'all_debrid' && !STATE.adApiKey) {
|
|
||||||
showAllDebridSetup();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
showView('testingView');
|
|
||||||
initializeResultsContainer();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const params = new URLSearchParams({provider});
|
|
||||||
const headers = {'api_password': STATE.apiPassword};
|
|
||||||
|
|
||||||
if (provider === 'all_debrid' && STATE.adApiKey) {
|
|
||||||
headers['api_key'] = STATE.adApiKey;
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(`/speedtest/start?${params}`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
if (response.status === 403) {
|
|
||||||
handleAuthError();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
throw new Error('Failed to start speed test');
|
|
||||||
}
|
|
||||||
|
|
||||||
const {task_id} = await response.json();
|
|
||||||
STATE.currentTaskId = task_id;
|
|
||||||
await pollResults(task_id);
|
|
||||||
} catch (error) {
|
|
||||||
showError(error.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function initializeResultsContainer() {
|
|
||||||
const container = document.getElementById('resultsContainer');
|
|
||||||
container.innerHTML = `
|
|
||||||
<div class="space-y-4">
|
|
||||||
<div id="locationResults" class="divide-y divide-gray-200 dark:divide-gray-700">
|
|
||||||
<!-- Results will be populated here -->
|
|
||||||
</div>
|
|
||||||
<div id="summaryStats" class="hidden pt-4">
|
|
||||||
<!-- Summary stats will be populated here -->
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function pollResults(taskId) {
|
|
||||||
let retryCount = 0;
|
|
||||||
const maxRetries = 10;
|
|
||||||
try {
|
|
||||||
while (true) {
|
|
||||||
const response = await fetch(`/speedtest/results/${taskId}`, {
|
|
||||||
headers: {'api_password': STATE.apiPassword}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
if (response.status === 403) {
|
|
||||||
handleAuthError();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (retryCount < maxRetries) {
|
|
||||||
retryCount++
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
throw new Error('Failed to fetch results after multiple attempts');
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
retryCount = 0; //reset the retry count
|
|
||||||
|
|
||||||
if (data.status === 'failed') {
|
|
||||||
throw new Error('Speed test failed');
|
|
||||||
}
|
|
||||||
|
|
||||||
updateUI(data);
|
|
||||||
|
|
||||||
if (data.status === 'completed') {
|
|
||||||
showFinalResults(data);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
showError(error.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateUI(data) {
|
|
||||||
if (data.user_info) {
|
|
||||||
updateUserInfo(data.user_info);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.current_location) {
|
|
||||||
document.getElementById('currentLocation').textContent =
|
|
||||||
`Testing server ${data.current_location}...`;
|
|
||||||
}
|
|
||||||
|
|
||||||
updateResults(data.results);
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateUserInfo(userInfo) {
|
|
||||||
const userInfoDiv = document.getElementById('userInfo');
|
|
||||||
userInfoDiv.innerHTML = `
|
|
||||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
|
||||||
<div class="space-y-1">
|
|
||||||
<div class="text-sm text-gray-500 dark:text-gray-400">IP Address</div>
|
|
||||||
<div class="font-medium text-gray-900 dark:text-white">${userInfo.ip}</div>
|
|
||||||
</div>
|
|
||||||
<div class="space-y-1">
|
|
||||||
<div class="text-sm text-gray-500 dark:text-gray-400">ISP</div>
|
|
||||||
<div class="font-medium text-gray-900 dark:text-white">${userInfo.isp}</div>
|
|
||||||
</div>
|
|
||||||
<div class="space-y-1">
|
|
||||||
<div class="text-sm text-gray-500 dark:text-gray-400">Country</div>
|
|
||||||
<div class="font-medium text-gray-900 dark:text-white">${userInfo.country?.toUpperCase()}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
userInfoDiv.classList.remove('hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateResults(results) {
|
|
||||||
const container = document.getElementById('resultsContainer');
|
|
||||||
const validResults = Object.entries(results)
|
|
||||||
.filter(([, data]) => data.result !== null && !data.error)
|
|
||||||
.sort(([, a], [, b]) => (b.result.speed_mbps) - (a.result.speed_mbps));
|
|
||||||
|
|
||||||
const failedResults = Object.entries(results)
|
|
||||||
.filter(([, data]) => data.error || data.result === null);
|
|
||||||
|
|
||||||
// Generate HTML for results
|
|
||||||
const resultsHTML = [
|
|
||||||
// Successful results
|
|
||||||
...validResults.map(([location, data]) => createSuccessResult(location, data)),
|
|
||||||
// Failed results
|
|
||||||
...failedResults.map(([location, data]) => createErrorResult(location, data))
|
|
||||||
].join('');
|
|
||||||
|
|
||||||
container.innerHTML = `
|
|
||||||
<div class="space-y-4">
|
|
||||||
<!-- Summary Stats -->
|
|
||||||
${createSummaryStats(validResults)}
|
|
||||||
<!-- Individual Results -->
|
|
||||||
<div class="mt-6 divide-y divide-gray-200 dark:divide-gray-700">
|
|
||||||
${resultsHTML}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
function createSummaryStats(validResults) {
|
|
||||||
if (validResults.length === 0) return '';
|
|
||||||
|
|
||||||
const speeds = validResults.map(([, data]) => data.result.speed_mbps);
|
|
||||||
const maxSpeed = Math.max(...speeds);
|
|
||||||
const avgSpeed = speeds.reduce((a, b) => a + b, 0) / speeds.length;
|
|
||||||
const fastestServer = validResults[0][0]; // First server after sorting
|
|
||||||
|
|
||||||
return `
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 bg-gray-50 dark:bg-gray-800 p-4 rounded-lg">
|
|
||||||
<div class="text-center">
|
|
||||||
<div class="text-sm text-gray-500 dark:text-gray-400">Fastest Server</div>
|
|
||||||
<div class="font-medium text-gray-900 dark:text-white">${fastestServer}</div>
|
|
||||||
</div>
|
|
||||||
<div class="text-center">
|
|
||||||
<div class="text-sm text-gray-500 dark:text-gray-400">Top Speed</div>
|
|
||||||
<div class="font-medium text-green-500">${maxSpeed.toFixed(2)} Mbps</div>
|
|
||||||
</div>
|
|
||||||
<div class="text-center">
|
|
||||||
<div class="text-sm text-gray-500 dark:text-gray-400">Average Speed</div>
|
|
||||||
<div class="font-medium text-blue-500">${avgSpeed.toFixed(2)} Mbps</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function createSuccessResult(location, data) {
|
|
||||||
const speedClass = getSpeedClass(data.result.speed_mbps);
|
|
||||||
return `
|
|
||||||
<div class="py-4">
|
|
||||||
<div class="flex justify-between items-center">
|
|
||||||
<div>
|
|
||||||
<span class="font-medium text-gray-800 dark:text-white">${location}</span>
|
|
||||||
<span class="ml-2 text-sm text-gray-500 dark:text-gray-400">${data.server_name || ''}</span>
|
|
||||||
</div>
|
|
||||||
<span class="text-lg font-semibold ${speedClass}">${data.result.speed_mbps.toFixed(2)} Mbps</span>
|
|
||||||
</div>
|
|
||||||
<div class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
|
||||||
Duration: ${data.result.duration.toFixed(2)}s •
|
|
||||||
Data: ${formatBytes(data.result.data_transferred)}
|
|
||||||
</div>
|
|
||||||
<div class="mt-1 text-xs text-gray-400 dark:text-gray-500">
|
|
||||||
Server: ${data.server_url}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getSpeedClass(speed) {
|
|
||||||
if (speed >= 10) return 'text-green-500 dark:text-green-400';
|
|
||||||
if (speed >= 5) return 'text-blue-500 dark:text-blue-400';
|
|
||||||
if (speed >= 2) return 'text-yellow-500 dark:text-yellow-400';
|
|
||||||
return 'text-red-500 dark:text-red-400';
|
|
||||||
}
|
|
||||||
|
|
||||||
function showFinalResults(data) {
|
|
||||||
// Stop the progress animation
|
|
||||||
document.querySelector('#progressBar').style.animation = 'none';
|
|
||||||
|
|
||||||
// Update the final results view
|
|
||||||
const validResults = Object.entries(data.results)
|
|
||||||
.filter(([, data]) => data.result !== null && !data.error)
|
|
||||||
.sort(([, a], [, b]) => (b.result.speed_mbps) - (a.result.speed_mbps));
|
|
||||||
|
|
||||||
const failedResults = Object.entries(data.results)
|
|
||||||
.filter(([, data]) => data.error || data.result === null);
|
|
||||||
|
|
||||||
// Update summary stats
|
|
||||||
if (validResults.length > 0) {
|
|
||||||
const speeds = validResults.map(([, data]) => data.result.speed_mbps);
|
|
||||||
const maxSpeed = Math.max(...speeds);
|
|
||||||
const avgSpeed = speeds.reduce((a, b) => a + b, 0) / speeds.length;
|
|
||||||
const fastestServer = validResults[0][0];
|
|
||||||
|
|
||||||
document.getElementById('fastestServer').textContent = fastestServer;
|
|
||||||
document.getElementById('topSpeed').textContent = `${maxSpeed.toFixed(2)} Mbps`;
|
|
||||||
document.getElementById('avgSpeed').textContent = `${avgSpeed.toFixed(2)} Mbps`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate detailed results HTML
|
|
||||||
const finalResultsHTML = `
|
|
||||||
${validResults.map(([location, data]) => `
|
|
||||||
<div class="bg-white dark:bg-gray-800 rounded-lg p-4 shadow-sm">
|
|
||||||
<div class="flex justify-between items-center">
|
|
||||||
<div>
|
|
||||||
<h3 class="text-lg font-medium text-gray-900 dark:text-white">${location}</h3>
|
|
||||||
<p class="text-sm text-gray-500 dark:text-gray-400">${data.server_name || ''}</p>
|
|
||||||
</div>
|
|
||||||
<div class="text-right">
|
|
||||||
<p class="text-2xl font-bold ${getSpeedClass(data.result.speed_mbps)}">
|
|
||||||
${data.result.speed_mbps.toFixed(2)} Mbps
|
|
||||||
</p>
|
|
||||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
|
||||||
${data.result.duration.toFixed(2)}s • ${formatBytes(data.result.data_transferred)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="mt-2 text-xs text-gray-400 dark:text-gray-500">
|
|
||||||
${data.server_url}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`).join('')}
|
|
||||||
|
|
||||||
${failedResults.length > 0 ? `
|
|
||||||
<div class="mt-6">
|
|
||||||
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-4">Failed Tests</h3>
|
|
||||||
${failedResults.map(([location, data]) => `
|
|
||||||
<div class="bg-red-50 dark:bg-red-900/20 rounded-lg p-4 mb-4">
|
|
||||||
<div class="flex justify-between items-center">
|
|
||||||
<div>
|
|
||||||
<h4 class="font-medium text-red-800 dark:text-red-200">
|
|
||||||
${location} ${data.server_name ? `(${data.server_name})` : ''}
|
|
||||||
</h4>
|
|
||||||
<p class="text-sm text-red-700 dark:text-red-300">
|
|
||||||
${data.error || 'Test failed'}
|
|
||||||
</p>
|
|
||||||
<p class="text-xs text-red-600 dark:text-red-400 mt-1">
|
|
||||||
${data.server_url}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`).join('')}
|
|
||||||
</div>
|
|
||||||
` : ''}
|
|
||||||
`;
|
|
||||||
|
|
||||||
document.getElementById('finalResults').innerHTML = finalResultsHTML;
|
|
||||||
|
|
||||||
// If we have user info from AllDebrid, copy it to the final view
|
|
||||||
const userInfoDiv = document.getElementById('userInfo');
|
|
||||||
if (!userInfoDiv.classList.contains('hidden') && data.user_info) {
|
|
||||||
const userInfoContent = userInfoDiv.innerHTML;
|
|
||||||
document.getElementById('finalResults').insertAdjacentHTML('afterbegin', `
|
|
||||||
<div class="mb-6">
|
|
||||||
${userInfoContent}
|
|
||||||
</div>
|
|
||||||
`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show the final results view
|
|
||||||
showView('resultsView');
|
|
||||||
}
|
|
||||||
|
|
||||||
function initializeView() {
|
|
||||||
initTheme();
|
|
||||||
showView(STATE.apiPassword ? 'selectionView' : 'passwordView');
|
|
||||||
}
|
|
||||||
|
|
||||||
function initializeFormHandlers() {
|
|
||||||
// Password form handler
|
|
||||||
document.getElementById('passwordForm').addEventListener('submit', (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const password = document.getElementById('apiPassword').value;
|
|
||||||
const remember = document.getElementById('rememberPassword').checked;
|
|
||||||
|
|
||||||
if (remember) {
|
|
||||||
localStorage.setItem('speedtest_api_password', password);
|
|
||||||
}
|
|
||||||
STATE.apiPassword = password;
|
|
||||||
showView('selectionView');
|
|
||||||
});
|
|
||||||
|
|
||||||
// AllDebrid form handler
|
|
||||||
document.getElementById('allDebridForm').addEventListener('submit', async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const apiKey = document.getElementById('adApiKey').value;
|
|
||||||
const remember = document.getElementById('rememberAdKey').checked;
|
|
||||||
|
|
||||||
if (remember) {
|
|
||||||
localStorage.setItem('ad_api_key', apiKey);
|
|
||||||
}
|
|
||||||
STATE.adApiKey = apiKey;
|
|
||||||
await startTest('all_debrid');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
|
||||||
initializeView();
|
|
||||||
initializeFormHandlers();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Theme Toggle Event Listener
|
|
||||||
document.getElementById('themeToggle').addEventListener('click', () => {
|
|
||||||
setTheme(document.documentElement.classList.contains('dark') ? 'light' : 'dark');
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -14,9 +14,7 @@ from typing import Optional, Union, Any
|
|||||||
|
|
||||||
import aiofiles
|
import aiofiles
|
||||||
import aiofiles.os
|
import aiofiles.os
|
||||||
from pydantic import ValidationError
|
|
||||||
|
|
||||||
from mediaflow_proxy.speedtest.models import SpeedTestTask
|
|
||||||
from mediaflow_proxy.utils.http_utils import download_file_with_retry, DownloadError
|
from mediaflow_proxy.utils.http_utils import download_file_with_retry, DownloadError
|
||||||
from mediaflow_proxy.utils.mpd_utils import parse_mpd, parse_mpd_dict
|
from mediaflow_proxy.utils.mpd_utils import parse_mpd, parse_mpd_dict
|
||||||
|
|
||||||
@@ -270,12 +268,6 @@ MPD_CACHE = AsyncMemoryCache(
|
|||||||
max_memory_size=100 * 1024 * 1024, # 100MB for MPD files
|
max_memory_size=100 * 1024 * 1024, # 100MB for MPD files
|
||||||
)
|
)
|
||||||
|
|
||||||
SPEEDTEST_CACHE = HybridCache(
|
|
||||||
cache_dir_name="speedtest_cache",
|
|
||||||
ttl=3600, # 1 hour
|
|
||||||
max_memory_size=50 * 1024 * 1024,
|
|
||||||
)
|
|
||||||
|
|
||||||
EXTRACTOR_CACHE = HybridCache(
|
EXTRACTOR_CACHE = HybridCache(
|
||||||
cache_dir_name="extractor_cache",
|
cache_dir_name="extractor_cache",
|
||||||
ttl=5 * 60, # 5 minutes
|
ttl=5 * 60, # 5 minutes
|
||||||
@@ -335,27 +327,6 @@ async def get_cached_mpd(
|
|||||||
raise error
|
raise error
|
||||||
|
|
||||||
|
|
||||||
async def get_cached_speedtest(task_id: str) -> Optional[SpeedTestTask]:
|
|
||||||
"""Get speed test results from cache."""
|
|
||||||
cached_data = await SPEEDTEST_CACHE.get(task_id)
|
|
||||||
if cached_data is not None:
|
|
||||||
try:
|
|
||||||
return SpeedTestTask.model_validate_json(cached_data.decode())
|
|
||||||
except ValidationError as e:
|
|
||||||
logger.error(f"Error parsing cached speed test data: {e}")
|
|
||||||
await SPEEDTEST_CACHE.delete(task_id)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
async def set_cache_speedtest(task_id: str, task: SpeedTestTask) -> bool:
|
|
||||||
"""Cache speed test results."""
|
|
||||||
try:
|
|
||||||
return await SPEEDTEST_CACHE.set(task_id, task.model_dump_json().encode())
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error caching speed test data: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
async def get_cached_extractor_result(key: str) -> Optional[dict]:
|
async def get_cached_extractor_result(key: str) -> Optional[dict]:
|
||||||
"""Get extractor result from cache."""
|
"""Get extractor result from cache."""
|
||||||
cached_data = await EXTRACTOR_CACHE.get(key)
|
cached_data = await EXTRACTOR_CACHE.get(key)
|
||||||
|
|||||||
@@ -175,7 +175,9 @@ class Streamer:
|
|||||||
logger.warning(f"Remote server closed connection prematurely: {e}")
|
logger.warning(f"Remote server closed connection prematurely: {e}")
|
||||||
# If we've received some data, just log the warning and return normally
|
# If we've received some data, just log the warning and return normally
|
||||||
if self.bytes_transferred > 0:
|
if self.bytes_transferred > 0:
|
||||||
logger.info(f"Partial content received ({self.bytes_transferred} bytes). Continuing with available data.")
|
logger.info(
|
||||||
|
f"Partial content received ({self.bytes_transferred} bytes). Continuing with available data."
|
||||||
|
)
|
||||||
return
|
return
|
||||||
else:
|
else:
|
||||||
# If we haven't received any data, raise an error
|
# If we haven't received any data, raise an error
|
||||||
@@ -375,6 +377,70 @@ def encode_mediaflow_proxy_url(
|
|||||||
return url
|
return url
|
||||||
|
|
||||||
|
|
||||||
|
def encode_stremio_proxy_url(
|
||||||
|
stremio_proxy_url: str,
|
||||||
|
destination_url: str,
|
||||||
|
request_headers: typing.Optional[dict] = None,
|
||||||
|
response_headers: typing.Optional[dict] = None,
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Encodes a Stremio proxy URL with destination URL and headers.
|
||||||
|
|
||||||
|
Format: http://127.0.0.1:11470/proxy/d=<encoded_origin>&h=<headers>&r=<response_headers>/<path><query>
|
||||||
|
|
||||||
|
Args:
|
||||||
|
stremio_proxy_url (str): The base Stremio proxy URL.
|
||||||
|
destination_url (str): The destination URL to proxy.
|
||||||
|
request_headers (dict, optional): Headers to include as query parameters. Defaults to None.
|
||||||
|
response_headers (dict, optional): Response headers to include as query parameters. Defaults to None.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: The encoded Stremio proxy URL.
|
||||||
|
"""
|
||||||
|
# Parse the destination URL to separate origin, path, and query
|
||||||
|
parsed_dest = parse.urlparse(destination_url)
|
||||||
|
dest_origin = f"{parsed_dest.scheme}://{parsed_dest.netloc}"
|
||||||
|
dest_path = parsed_dest.path.lstrip("/")
|
||||||
|
dest_query = parsed_dest.query
|
||||||
|
|
||||||
|
# Prepare query parameters list for proper handling of multiple headers
|
||||||
|
query_parts = []
|
||||||
|
|
||||||
|
# Add destination origin (scheme + netloc only) with proper encoding
|
||||||
|
query_parts.append(f"d={parse.quote_plus(dest_origin)}")
|
||||||
|
|
||||||
|
# Add request headers
|
||||||
|
if request_headers:
|
||||||
|
for key, value in request_headers.items():
|
||||||
|
header_string = f"{key}:{value}"
|
||||||
|
query_parts.append(f"h={parse.quote_plus(header_string)}")
|
||||||
|
|
||||||
|
# Add response headers
|
||||||
|
if response_headers:
|
||||||
|
for key, value in response_headers.items():
|
||||||
|
header_string = f"{key}:{value}"
|
||||||
|
query_parts.append(f"r={parse.quote_plus(header_string)}")
|
||||||
|
|
||||||
|
# Ensure base_url doesn't end with a slash for consistent handling
|
||||||
|
base_url = stremio_proxy_url.rstrip("/")
|
||||||
|
|
||||||
|
# Construct the URL path with query string
|
||||||
|
query_string = "&".join(query_parts)
|
||||||
|
|
||||||
|
# Build the final URL: /proxy/{opts}/{pathname}{search}
|
||||||
|
url_path = f"/proxy/{query_string}"
|
||||||
|
|
||||||
|
# Append the path from destination URL
|
||||||
|
if dest_path:
|
||||||
|
url_path = f"{url_path}/{dest_path}"
|
||||||
|
|
||||||
|
# Append the query string from destination URL
|
||||||
|
if dest_query:
|
||||||
|
url_path = f"{url_path}?{dest_query}"
|
||||||
|
|
||||||
|
return f"{base_url}{url_path}"
|
||||||
|
|
||||||
|
|
||||||
def get_original_scheme(request: Request) -> str:
|
def get_original_scheme(request: Request) -> str:
|
||||||
"""
|
"""
|
||||||
Determines the original scheme (http or https) of the request.
|
Determines the original scheme (http or https) of the request.
|
||||||
@@ -509,7 +575,9 @@ class EnhancedStreamingResponse(Response):
|
|||||||
logger.warning(f"Remote protocol error after partial streaming: {e}")
|
logger.warning(f"Remote protocol error after partial streaming: {e}")
|
||||||
try:
|
try:
|
||||||
await send({"type": "http.response.body", "body": b"", "more_body": False})
|
await send({"type": "http.response.body", "body": b"", "more_body": False})
|
||||||
logger.info(f"Response finalized after partial content ({self.actual_content_length} bytes transferred)")
|
logger.info(
|
||||||
|
f"Response finalized after partial content ({self.actual_content_length} bytes transferred)"
|
||||||
|
)
|
||||||
except Exception as close_err:
|
except Exception as close_err:
|
||||||
logger.warning(f"Could not finalize response after remote error: {close_err}")
|
logger.warning(f"Could not finalize response after remote error: {close_err}")
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -3,8 +3,9 @@ import re
|
|||||||
from typing import AsyncGenerator
|
from typing import AsyncGenerator
|
||||||
from urllib import parse
|
from urllib import parse
|
||||||
|
|
||||||
|
from mediaflow_proxy.configs import settings
|
||||||
from mediaflow_proxy.utils.crypto_utils import encryption_handler
|
from mediaflow_proxy.utils.crypto_utils import encryption_handler
|
||||||
from mediaflow_proxy.utils.http_utils import encode_mediaflow_proxy_url, get_original_scheme
|
from mediaflow_proxy.utils.http_utils import encode_mediaflow_proxy_url, encode_stremio_proxy_url, get_original_scheme
|
||||||
|
|
||||||
|
|
||||||
class M3U8Processor:
|
class M3U8Processor:
|
||||||
@@ -39,7 +40,7 @@ class M3U8Processor:
|
|||||||
if "URI=" in line:
|
if "URI=" in line:
|
||||||
processed_lines.append(await self.process_key_line(line, base_url))
|
processed_lines.append(await self.process_key_line(line, base_url))
|
||||||
elif not line.startswith("#") and line.strip():
|
elif not line.startswith("#") and line.strip():
|
||||||
processed_lines.append(await self.proxy_url(line, base_url))
|
processed_lines.append(await self.proxy_content_url(line, base_url))
|
||||||
else:
|
else:
|
||||||
processed_lines.append(line)
|
processed_lines.append(line)
|
||||||
return "\n".join(processed_lines)
|
return "\n".join(processed_lines)
|
||||||
@@ -104,7 +105,7 @@ class M3U8Processor:
|
|||||||
if "URI=" in line:
|
if "URI=" in line:
|
||||||
return await self.process_key_line(line, base_url)
|
return await self.process_key_line(line, base_url)
|
||||||
elif not line.startswith("#") and line.strip():
|
elif not line.startswith("#") and line.strip():
|
||||||
return await self.proxy_url(line, base_url)
|
return await self.proxy_content_url(line, base_url)
|
||||||
else:
|
else:
|
||||||
return line
|
return line
|
||||||
|
|
||||||
@@ -129,9 +130,9 @@ class M3U8Processor:
|
|||||||
line = line.replace(f'URI="{original_uri}"', f'URI="{new_uri}"')
|
line = line.replace(f'URI="{original_uri}"', f'URI="{new_uri}"')
|
||||||
return line
|
return line
|
||||||
|
|
||||||
async def proxy_url(self, url: str, base_url: str) -> str:
|
async def proxy_content_url(self, url: str, base_url: str) -> str:
|
||||||
"""
|
"""
|
||||||
Proxies a URL, encoding it with the MediaFlow proxy URL.
|
Proxies a content URL based on the configured routing strategy.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
url (str): The URL to proxy.
|
url (str): The URL to proxy.
|
||||||
@@ -141,6 +142,51 @@ class M3U8Processor:
|
|||||||
str: The proxied URL.
|
str: The proxied URL.
|
||||||
"""
|
"""
|
||||||
full_url = parse.urljoin(base_url, url)
|
full_url = parse.urljoin(base_url, url)
|
||||||
|
|
||||||
|
# Determine routing strategy based on configuration
|
||||||
|
routing_strategy = settings.m3u8_content_routing
|
||||||
|
|
||||||
|
# For playlist URLs, always use MediaFlow proxy regardless of strategy
|
||||||
|
if ".m3u" in full_url:
|
||||||
|
return await self.proxy_url(full_url, base_url, use_full_url=True)
|
||||||
|
|
||||||
|
# Route non-playlist content URLs based on strategy
|
||||||
|
if routing_strategy == "direct":
|
||||||
|
# Return the URL directly without any proxying
|
||||||
|
return full_url
|
||||||
|
elif routing_strategy == "stremio" and settings.stremio_proxy_url:
|
||||||
|
# Use Stremio proxy for content URLs
|
||||||
|
query_params = dict(self.request.query_params)
|
||||||
|
request_headers = {k[2:]: v for k, v in query_params.items() if k.startswith("h_")}
|
||||||
|
response_headers = {k[2:]: v for k, v in query_params.items() if k.startswith("r_")}
|
||||||
|
|
||||||
|
return encode_stremio_proxy_url(
|
||||||
|
settings.stremio_proxy_url,
|
||||||
|
full_url,
|
||||||
|
request_headers=request_headers if request_headers else None,
|
||||||
|
response_headers=response_headers if response_headers else None,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Default to MediaFlow proxy (routing_strategy == "mediaflow" or fallback)
|
||||||
|
return await self.proxy_url(full_url, base_url, use_full_url=True)
|
||||||
|
|
||||||
|
async def proxy_url(self, url: str, base_url: str, use_full_url: bool = False) -> str:
|
||||||
|
"""
|
||||||
|
Proxies a URL, encoding it with the MediaFlow proxy URL.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
url (str): The URL to proxy.
|
||||||
|
base_url (str): The base URL to resolve relative URLs.
|
||||||
|
use_full_url (bool): Whether to use the URL as-is (True) or join with base_url (False).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: The proxied URL.
|
||||||
|
"""
|
||||||
|
if use_full_url:
|
||||||
|
full_url = url
|
||||||
|
else:
|
||||||
|
full_url = parse.urljoin(base_url, url)
|
||||||
|
|
||||||
query_params = dict(self.request.query_params)
|
query_params = dict(self.request.query_params)
|
||||||
has_encrypted = query_params.pop("has_encrypted", False)
|
has_encrypted = query_params.pop("has_encrypted", False)
|
||||||
# Remove the response headers from the query params to avoid it being added to the consecutive requests
|
# Remove the response headers from the query params to avoid it being added to the consecutive requests
|
||||||
|
|||||||
@@ -253,6 +253,16 @@ def parse_representation(
|
|||||||
profile["frameRate"] = round(int(frame_rate.split("/")[0]) / int(frame_rate.split("/")[1]), 3)
|
profile["frameRate"] = round(int(frame_rate.split("/")[0]) / int(frame_rate.split("/")[1]), 3)
|
||||||
profile["sar"] = representation.get("@sar", "1:1")
|
profile["sar"] = representation.get("@sar", "1:1")
|
||||||
|
|
||||||
|
# Extract segment template start number for adaptive sequence calculation
|
||||||
|
segment_template_data = adaptation.get("SegmentTemplate") or representation.get("SegmentTemplate")
|
||||||
|
if segment_template_data:
|
||||||
|
try:
|
||||||
|
profile["segment_template_start_number"] = int(segment_template_data.get("@startNumber", 1))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
profile["segment_template_start_number"] = 1
|
||||||
|
else:
|
||||||
|
profile["segment_template_start_number"] = 1
|
||||||
|
|
||||||
if parse_segment_profile_id is None or profile["id"] != parse_segment_profile_id:
|
if parse_segment_profile_id is None or profile["id"] != parse_segment_profile_id:
|
||||||
return profile
|
return profile
|
||||||
|
|
||||||
@@ -502,6 +512,12 @@ def create_segment_data(segment: Dict, item: dict, profile: dict, source: str, t
|
|||||||
"number": segment["number"],
|
"number": segment["number"],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Add time and duration metadata for adaptive sequence calculation
|
||||||
|
if "time" in segment:
|
||||||
|
segment_data["time"] = segment["time"]
|
||||||
|
if "duration" in segment:
|
||||||
|
segment_data["duration_mpd_timescale"] = segment["duration"]
|
||||||
|
|
||||||
if "start_time" in segment and "end_time" in segment:
|
if "start_time" in segment and "end_time" in segment:
|
||||||
segment_data.update(
|
segment_data.update(
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user