mirror of
https://github.com/UrloMythus/UnHided.git
synced 2026-04-09 02:40:47 +00:00
New version
This commit is contained in:
BIN
mediaflow_proxy/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
mediaflow_proxy/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
BIN
mediaflow_proxy/__pycache__/configs.cpython-313.pyc
Normal file
BIN
mediaflow_proxy/__pycache__/configs.cpython-313.pyc
Normal file
Binary file not shown.
BIN
mediaflow_proxy/__pycache__/const.cpython-313.pyc
Normal file
BIN
mediaflow_proxy/__pycache__/const.cpython-313.pyc
Normal file
Binary file not shown.
BIN
mediaflow_proxy/__pycache__/handlers.cpython-313.pyc
Normal file
BIN
mediaflow_proxy/__pycache__/handlers.cpython-313.pyc
Normal file
Binary file not shown.
BIN
mediaflow_proxy/__pycache__/main.cpython-313.pyc
Normal file
BIN
mediaflow_proxy/__pycache__/main.cpython-313.pyc
Normal file
Binary file not shown.
BIN
mediaflow_proxy/__pycache__/middleware.cpython-313.pyc
Normal file
BIN
mediaflow_proxy/__pycache__/middleware.cpython-313.pyc
Normal file
Binary file not shown.
BIN
mediaflow_proxy/__pycache__/mpd_processor.cpython-313.pyc
Normal file
BIN
mediaflow_proxy/__pycache__/mpd_processor.cpython-313.pyc
Normal file
Binary file not shown.
BIN
mediaflow_proxy/__pycache__/schemas.cpython-313.pyc
Normal file
BIN
mediaflow_proxy/__pycache__/schemas.cpython-313.pyc
Normal file
Binary file not shown.
@@ -1,4 +1,4 @@
|
||||
from typing import Dict, Optional, Union
|
||||
from typing import Dict, Literal, Optional, Union
|
||||
|
||||
import httpx
|
||||
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_docs: bool = False # Whether to disable the API documentation (Swagger 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 = (
|
||||
"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.
|
||||
|
||||
BIN
mediaflow_proxy/drm/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
mediaflow_proxy/drm/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
BIN
mediaflow_proxy/drm/__pycache__/decrypter.cpython-313.pyc
Normal file
BIN
mediaflow_proxy/drm/__pycache__/decrypter.cpython-313.pyc
Normal file
Binary file not shown.
BIN
mediaflow_proxy/extractors/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
mediaflow_proxy/extractors/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
BIN
mediaflow_proxy/extractors/__pycache__/base.cpython-313.pyc
Normal file
BIN
mediaflow_proxy/extractors/__pycache__/base.cpython-313.pyc
Normal file
Binary file not shown.
BIN
mediaflow_proxy/extractors/__pycache__/dlhd.cpython-313.pyc
Normal file
BIN
mediaflow_proxy/extractors/__pycache__/dlhd.cpython-313.pyc
Normal file
Binary file not shown.
Binary file not shown.
BIN
mediaflow_proxy/extractors/__pycache__/factory.cpython-313.pyc
Normal file
BIN
mediaflow_proxy/extractors/__pycache__/factory.cpython-313.pyc
Normal file
Binary file not shown.
BIN
mediaflow_proxy/extractors/__pycache__/livetv.cpython-313.pyc
Normal file
BIN
mediaflow_proxy/extractors/__pycache__/livetv.cpython-313.pyc
Normal file
Binary file not shown.
BIN
mediaflow_proxy/extractors/__pycache__/maxstream.cpython-313.pyc
Normal file
BIN
mediaflow_proxy/extractors/__pycache__/maxstream.cpython-313.pyc
Normal file
Binary file not shown.
BIN
mediaflow_proxy/extractors/__pycache__/mixdrop.cpython-313.pyc
Normal file
BIN
mediaflow_proxy/extractors/__pycache__/mixdrop.cpython-313.pyc
Normal file
Binary file not shown.
BIN
mediaflow_proxy/extractors/__pycache__/okru.cpython-313.pyc
Normal file
BIN
mediaflow_proxy/extractors/__pycache__/okru.cpython-313.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
mediaflow_proxy/extractors/__pycache__/uqload.cpython-313.pyc
Normal file
BIN
mediaflow_proxy/extractors/__pycache__/uqload.cpython-313.pyc
Normal file
Binary file not shown.
BIN
mediaflow_proxy/extractors/__pycache__/vixcloud.cpython-313.pyc
Normal file
BIN
mediaflow_proxy/extractors/__pycache__/vixcloud.cpython-313.pyc
Normal file
Binary file not shown.
@@ -43,7 +43,7 @@ class DLHDExtractor(BaseExtractor):
|
||||
channel_headers = {
|
||||
"referer": 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)
|
||||
@@ -52,15 +52,21 @@ class DLHDExtractor(BaseExtractor):
|
||||
if not player_url:
|
||||
raise ExtractorError("Could not extract player URL from channel page")
|
||||
|
||||
# Check if this is a vecloud URL
|
||||
if "vecloud" in player_url:
|
||||
if not re.search(r"/stream/([a-zA-Z0-9-]+)", 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 + "/")
|
||||
except Exception as e:
|
||||
pass
|
||||
|
||||
# Get player page to extract authentication information
|
||||
player_headers = {
|
||||
"referer": 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)
|
||||
@@ -89,16 +95,18 @@ class DLHDExtractor(BaseExtractor):
|
||||
raise ExtractorError("Could not determine auth URL base")
|
||||
|
||||
# Construct auth URL
|
||||
auth_url = (f"{auth_url_base}/auth.php?channel_id={auth_data['channel_key']}"
|
||||
f"&ts={auth_data['auth_ts']}&rnd={auth_data['auth_rnd']}"
|
||||
f"&sig={quote(auth_data['auth_sig'])}")
|
||||
auth_url = (
|
||||
f"{auth_url_base}/auth.php?channel_id={auth_data['channel_key']}"
|
||||
f"&ts={auth_data['auth_ts']}&rnd={auth_data['auth_rnd']}"
|
||||
f"&sig={quote(auth_data['auth_sig'])}"
|
||||
)
|
||||
|
||||
# Make auth request
|
||||
player_origin = self._get_origin(player_url)
|
||||
auth_headers = {
|
||||
"referer": 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)
|
||||
@@ -113,14 +121,14 @@ class DLHDExtractor(BaseExtractor):
|
||||
lookup_url_base=player_origin,
|
||||
auth_url_base=auth_url_base,
|
||||
auth_data=auth_data,
|
||||
headers=auth_headers
|
||||
headers=auth_headers,
|
||||
)
|
||||
|
||||
# Set up the final stream headers
|
||||
stream_headers = {
|
||||
"referer": player_url,
|
||||
"origin": player_origin,
|
||||
"user-agent": self.base_headers["user-agent"]
|
||||
"user-agent": self.base_headers["user-agent"],
|
||||
}
|
||||
|
||||
# Return the stream URL with headers
|
||||
@@ -144,12 +152,17 @@ class DLHDExtractor(BaseExtractor):
|
||||
"""
|
||||
try:
|
||||
# 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:
|
||||
raise ExtractorError("Could not extract stream ID from vecloud URL")
|
||||
|
||||
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
|
||||
player_parsed = urlparse(player_url)
|
||||
player_domain = player_parsed.netloc
|
||||
@@ -161,13 +174,10 @@ class DLHDExtractor(BaseExtractor):
|
||||
"referer": player_url,
|
||||
"origin": player_origin,
|
||||
"user-agent": self.base_headers["user-agent"],
|
||||
"content-type": "application/json"
|
||||
"content-type": "application/json",
|
||||
}
|
||||
|
||||
api_data = {
|
||||
"r": channel_referer,
|
||||
"d": player_domain
|
||||
}
|
||||
api_data = {"r": channel_referer, "d": player_domain}
|
||||
|
||||
# Make API request
|
||||
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 = {
|
||||
"referer": 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
|
||||
@@ -200,14 +210,24 @@ class DLHDExtractor(BaseExtractor):
|
||||
except Exception as 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]:
|
||||
"""Extract player iframe URL from channel page HTML."""
|
||||
try:
|
||||
# Look for iframe with allowfullscreen attribute
|
||||
iframe_match = re.search(
|
||||
r'<iframe[^>]*src=["\']([^"\']+)["\'][^>]*allowfullscreen',
|
||||
html_content,
|
||||
re.IGNORECASE
|
||||
r'<iframe[^>]*src=["\']([^"\']+)["\'][^>]*allowfullscreen', html_content, re.IGNORECASE
|
||||
)
|
||||
|
||||
if not iframe_match:
|
||||
@@ -215,7 +235,7 @@ class DLHDExtractor(BaseExtractor):
|
||||
iframe_match = re.search(
|
||||
r'<iframe[^>]*src=["\']([^"\']+(?:premiumtv|daddylivehd|vecloud)[^"\']*)["\']',
|
||||
html_content,
|
||||
re.IGNORECASE
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
if iframe_match:
|
||||
@@ -225,17 +245,16 @@ class DLHDExtractor(BaseExtractor):
|
||||
except Exception:
|
||||
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."""
|
||||
try:
|
||||
# Construct server lookup URL
|
||||
server_lookup_url = f"{lookup_url_base}/server_lookup.php?channel_id={quote(auth_data['channel_key'])}"
|
||||
|
||||
# Make server lookup request
|
||||
server_response = await self._make_request(
|
||||
server_lookup_url,
|
||||
headers=headers
|
||||
)
|
||||
server_response = await self._make_request(server_lookup_url, headers=headers)
|
||||
|
||||
server_data = server_response.json()
|
||||
server_key = server_data.get("server_key")
|
||||
@@ -244,13 +263,13 @@ class DLHDExtractor(BaseExtractor):
|
||||
raise ExtractorError("Failed to get server key")
|
||||
|
||||
# Extract domain parts from auth URL for constructing stream URL
|
||||
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]
|
||||
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]
|
||||
|
||||
# Generate the m3u8 URL based on server response pattern
|
||||
if '/' in server_key:
|
||||
if "/" in server_key:
|
||||
# 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"
|
||||
else:
|
||||
# Handle normal case
|
||||
@@ -278,7 +297,7 @@ class DLHDExtractor(BaseExtractor):
|
||||
"channel_key": channel_key_match.group(1),
|
||||
"auth_ts": auth_ts_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:
|
||||
return {}
|
||||
@@ -287,21 +306,15 @@ class DLHDExtractor(BaseExtractor):
|
||||
"""Extract auth URL base from player page script content."""
|
||||
try:
|
||||
# Look for auth URL or domain in fetchWithRetry call or similar patterns
|
||||
auth_url_match = re.search(
|
||||
r'fetchWithRetry\([\'"]([^\'"]*/auth\.php)',
|
||||
html_content
|
||||
)
|
||||
auth_url_match = re.search(r'fetchWithRetry\([\'"]([^\'"]*/auth\.php)', html_content)
|
||||
|
||||
if auth_url_match:
|
||||
auth_url = auth_url_match.group(1)
|
||||
# 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
|
||||
domain_match = re.search(
|
||||
r'[\'"]https://([^/\'\"]+)(?:/[^\'\"]*)?/auth\.php',
|
||||
html_content
|
||||
)
|
||||
domain_match = re.search(r'[\'"]https://([^/\'\"]+)(?:/[^\'\"]*)?/auth\.php', html_content)
|
||||
|
||||
if domain_match:
|
||||
return f"https://{domain_match.group(1)}"
|
||||
@@ -320,13 +333,13 @@ class DLHDExtractor(BaseExtractor):
|
||||
try:
|
||||
# Typical pattern is to use a subdomain for auth domain
|
||||
parsed = urlparse(player_domain)
|
||||
domain_parts = parsed.netloc.split('.')
|
||||
domain_parts = parsed.netloc.split(".")
|
||||
|
||||
# Get the top-level domain and second-level domain
|
||||
if len(domain_parts) >= 2:
|
||||
base_domain = '.'.join(domain_parts[-2:])
|
||||
base_domain = ".".join(domain_parts[-2:])
|
||||
# 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}"
|
||||
return potential_auth_domain
|
||||
|
||||
|
||||
@@ -39,15 +39,16 @@ class VixCloudExtractor(BaseExtractor):
|
||||
|
||||
async def extract(self, url: str, **kwargs) -> Dict[str, Any]:
|
||||
"""Extract Vixcloud URL."""
|
||||
site_url = url.split("/iframe")[0]
|
||||
version = await self.version(site_url)
|
||||
response = await self._make_request(url, headers={"x-inertia": "true", "x-inertia-version": version})
|
||||
soup = BeautifulSoup(response.text, "lxml", parse_only=SoupStrainer("iframe"))
|
||||
iframe = soup.find("iframe").get("src")
|
||||
parsed_url = urlparse(iframe)
|
||||
query_params = parse_qs(parsed_url.query)
|
||||
response = await self._make_request(iframe, headers={"x-inertia": "true", "x-inertia-version": version})
|
||||
|
||||
if "iframe" in url:
|
||||
site_url = url.split("/iframe")[0]
|
||||
version = await self.version(site_url)
|
||||
response = await self._make_request(url, headers={"x-inertia": "true", "x-inertia-version": version})
|
||||
soup = BeautifulSoup(response.text, "lxml", parse_only=SoupStrainer("iframe"))
|
||||
iframe = soup.find("iframe").get("src")
|
||||
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:
|
||||
raise ExtractorError("Failed to extract URL components, Invalid Request")
|
||||
soup = BeautifulSoup(response.text, "lxml", parse_only=SoupStrainer("body"))
|
||||
@@ -55,15 +56,15 @@ class VixCloudExtractor(BaseExtractor):
|
||||
script = soup.find("body").find("script").text
|
||||
token = re.search(r"'token':\s*'(\w+)'", script).group(1)
|
||||
expires = re.search(r"'expires':\s*'(\d+)'", script).group(1)
|
||||
vixid = iframe.split("/embed/")[1].split("?")[0]
|
||||
base_url = iframe.split("://")[1].split("/")[0]
|
||||
final_url = f"https://{base_url}/playlist/{vixid}.m3u8?token={token}&expires={expires}"
|
||||
if "canPlayFHD" in query_params:
|
||||
# canPlayFHD = "h=1"
|
||||
canPlayFHD = re.search(r"window\.canPlayFHD\s*=\s*(\w+)", script).group(1)
|
||||
print(script,"A")
|
||||
server_url = re.search(r"url:\s*'([^']+)'", script).group(1)
|
||||
if "?b=1" in server_url:
|
||||
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"
|
||||
if "b" in query_params:
|
||||
# b = "b=1"
|
||||
final_url += "&b=1"
|
||||
self.base_headers["referer"] = url
|
||||
return {
|
||||
"destination_url": final_url,
|
||||
|
||||
@@ -23,4 +23,3 @@ class UIAccessControlMiddleware(BaseHTTPMiddleware):
|
||||
return Response(status_code=403, content="Forbidden")
|
||||
|
||||
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
|
||||
if index == 0:
|
||||
sequence = segments[0]["number"]
|
||||
first_segment = segments[0]
|
||||
extinf_values = [f["extinf"] for f in segments if "extinf" in f]
|
||||
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(
|
||||
[
|
||||
f"#EXT-X-TARGETDURATION:{target_duration}",
|
||||
|
||||
BIN
mediaflow_proxy/routes/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
mediaflow_proxy/routes/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
BIN
mediaflow_proxy/routes/__pycache__/extractor.cpython-313.pyc
Normal file
BIN
mediaflow_proxy/routes/__pycache__/extractor.cpython-313.pyc
Normal file
Binary file not shown.
BIN
mediaflow_proxy/routes/__pycache__/proxy.cpython-313.pyc
Normal file
BIN
mediaflow_proxy/routes/__pycache__/proxy.cpython-313.pyc
Normal file
Binary file not shown.
BIN
mediaflow_proxy/routes/__pycache__/speedtest.cpython-313.pyc
Normal file
BIN
mediaflow_proxy/routes/__pycache__/speedtest.cpython-313.pyc
Normal file
Binary file not shown.
@@ -1,9 +1,11 @@
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, HTTPException, Request
|
||||
from fastapi import APIRouter, HTTPException
|
||||
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()
|
||||
|
||||
@@ -11,33 +13,29 @@ speedtest_router = APIRouter()
|
||||
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():
|
||||
"""Return the speed test HTML interface."""
|
||||
"""Return the browser-based speed test HTML interface."""
|
||||
return RedirectResponse(url="/speedtest.html")
|
||||
|
||||
|
||||
@speedtest_router.post("/start", summary="Start a new speed test", response_model=dict)
|
||||
async def start_speedtest(background_tasks: BackgroundTasks, provider: SpeedTestProvider, request: Request):
|
||||
"""Start a new speed test for the specified provider."""
|
||||
task_id = str(uuid.uuid4())
|
||||
api_key = request.headers.get("api_key")
|
||||
@speedtest_router.post("/config", summary="Get browser speed test configuration")
|
||||
async def get_browser_speedtest_config(
|
||||
test_request: BrowserSpeedTestRequest,
|
||||
) -> BrowserSpeedTestConfig:
|
||||
"""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
|
||||
await speedtest_service.create_test(task_id, provider, api_key)
|
||||
# Get test URLs and user info
|
||||
test_urls, user_info = await provider_impl.get_test_urls()
|
||||
config = await provider_impl.get_config()
|
||||
|
||||
# Schedule the speed test
|
||||
background_tasks.add_task(speedtest_service.run_speedtest, task_id, provider, api_key)
|
||||
|
||||
return {"task_id": task_id}
|
||||
|
||||
|
||||
@speedtest_router.get("/results/{task_id}", summary="Get speed test results")
|
||||
async def get_speedtest_results(task_id: str):
|
||||
"""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()
|
||||
return BrowserSpeedTestConfig(
|
||||
provider=test_request.provider,
|
||||
test_urls=test_urls,
|
||||
test_duration=config.test_duration,
|
||||
user_info=user_info,
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
@@ -98,7 +98,6 @@ class ExtractorURLParams(GenericParams):
|
||||
description="Additional parameters required for specific extractors (e.g., stream_title for LiveTV)",
|
||||
)
|
||||
|
||||
|
||||
@field_validator("extra_params", mode="before")
|
||||
def validate_extra_params(cls, value: Any):
|
||||
if isinstance(value, str):
|
||||
|
||||
BIN
mediaflow_proxy/speedtest/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
mediaflow_proxy/speedtest/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
BIN
mediaflow_proxy/speedtest/__pycache__/models.cpython-313.pyc
Normal file
BIN
mediaflow_proxy/speedtest/__pycache__/models.cpython-313.pyc
Normal file
Binary file not shown.
BIN
mediaflow_proxy/speedtest/__pycache__/service.cpython-313.pyc
Normal file
BIN
mediaflow_proxy/speedtest/__pycache__/service.cpython-313.pyc
Normal file
Binary file not shown.
@@ -1,8 +1,7 @@
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from typing import Dict, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, HttpUrl
|
||||
|
||||
|
||||
class SpeedTestProvider(str, Enum):
|
||||
@@ -21,26 +20,20 @@ class UserInfo(BaseModel):
|
||||
country: Optional[str] = None
|
||||
|
||||
|
||||
class SpeedTestResult(BaseModel):
|
||||
speed_mbps: float = Field(..., description="Speed in Mbps")
|
||||
duration: float = Field(..., description="Test duration in seconds")
|
||||
data_transferred: int = Field(..., description="Data transferred in bytes")
|
||||
timestamp: datetime = Field(default_factory=datetime.utcnow)
|
||||
class MediaFlowServer(BaseModel):
|
||||
url: HttpUrl
|
||||
api_password: Optional[str] = None
|
||||
name: Optional[str] = None
|
||||
|
||||
|
||||
class LocationResult(BaseModel):
|
||||
result: Optional[SpeedTestResult] = None
|
||||
error: Optional[str] = None
|
||||
server_name: str
|
||||
server_url: str
|
||||
|
||||
|
||||
class SpeedTestTask(BaseModel):
|
||||
task_id: str
|
||||
class BrowserSpeedTestConfig(BaseModel):
|
||||
provider: SpeedTestProvider
|
||||
results: Dict[str, LocationResult] = {}
|
||||
started_at: datetime
|
||||
completed_at: Optional[datetime] = None
|
||||
status: str = "running"
|
||||
test_urls: Dict[str, str]
|
||||
test_duration: int = 10
|
||||
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 mediaflow_proxy.utils.cache_utils import get_cached_speedtest, set_cache_speedtest
|
||||
from mediaflow_proxy.utils.http_utils import Streamer, create_httpx_client
|
||||
from .models import SpeedTestTask, LocationResult, SpeedTestResult, SpeedTestProvider
|
||||
from .models import SpeedTestProvider
|
||||
from .providers.all_debrid import AllDebridSpeedTest
|
||||
from .providers.base import BaseSpeedTestProvider
|
||||
from .providers.real_debrid import RealDebridSpeedTest
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SpeedTestService:
|
||||
"""Service for managing speed tests across different providers."""
|
||||
"""Service for managing speed test provider configurations."""
|
||||
|
||||
def __init__(self):
|
||||
# Provider mapping
|
||||
@@ -23,7 +16,7 @@ class SpeedTestService:
|
||||
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."""
|
||||
provider_class = self._providers.get(provider)
|
||||
if not provider_class:
|
||||
@@ -33,97 +26,3 @@ class SpeedTestService:
|
||||
raise ValueError("API key required for AllDebrid")
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
.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 {
|
||||
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">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>
|
||||
<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>
|
||||
<meta charset="UTF-8">
|
||||
<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.jsdelivr.net/npm/chart.js"></script>
|
||||
<script src="/speedtest.js"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
animation: {
|
||||
'progress': 'progress 180s linear forwards',
|
||||
},
|
||||
keyframes: {
|
||||
progress: {
|
||||
'0%': {width: '0%'},
|
||||
'100%': {width: '100%'}
|
||||
}
|
||||
'pulse-slow': 'pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite',
|
||||
'bounce-slow': 'bounce 2s infinite',
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
.provider-card {
|
||||
.result-card {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.provider-card:hover {
|
||||
transform: translateY(-5px);
|
||||
.result-card:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.server-input {
|
||||
animation: slideIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
transform: translateY(20px);
|
||||
transform: translateY(-10px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
@@ -43,8 +44,21 @@
|
||||
}
|
||||
}
|
||||
|
||||
.slide-in {
|
||||
animation: slideIn 0.3s ease-out forwards;
|
||||
.metric-card {
|
||||
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>
|
||||
</head>
|
||||
@@ -63,634 +77,301 @@
|
||||
</div>
|
||||
|
||||
<main class="container mx-auto px-4 py-8">
|
||||
<!-- Views Container -->
|
||||
<div id="views-container">
|
||||
<!-- API Password View -->
|
||||
<div id="passwordView" class="space-y-8">
|
||||
<h1 class="text-3xl font-bold text-center text-gray-800 dark:text-white mb-8">
|
||||
Enter API Password
|
||||
</h1>
|
||||
<!-- Header -->
|
||||
<div class="text-center mb-8">
|
||||
<h1 class="text-4xl font-bold text-gray-800 dark:text-white mb-2">
|
||||
🚀 MediaFlow Speed Test
|
||||
</h1>
|
||||
<p class="text-gray-600 dark:text-gray-300">
|
||||
Compare your connection speed through MediaFlow proxy vs direct connection
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="max-w-md mx-auto">
|
||||
<form id="passwordForm" class="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6 space-y-4">
|
||||
<!-- Configuration View -->
|
||||
<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">
|
||||
<label for="apiPassword" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
API Password
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Debrid Provider
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
id="apiPassword"
|
||||
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
|
||||
>
|
||||
<select id="provider"
|
||||
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="real_debrid">Real-Debrid</option>
|
||||
<option value="all_debrid">AllDebrid</option>
|
||||
</select>
|
||||
</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 -->
|
||||
<div id="selectionView" class="space-y-8 hidden">
|
||||
<h1 class="text-3xl font-bold text-center text-gray-800 dark:text-white mb-8">
|
||||
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">
|
||||
<!-- API Key (for AllDebrid) -->
|
||||
<div id="apiKeySection" class="space-y-2 hidden">
|
||||
<label for="apiKey" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
AllDebrid API Key
|
||||
</label>
|
||||
<input
|
||||
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"
|
||||
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 class="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="rememberAdKey"
|
||||
class="rounded border-gray-300 dark:border-gray-600"
|
||||
>
|
||||
<label for="rememberAdKey" class="text-sm text-gray-600 dark:text-gray-400">
|
||||
Remember API key
|
||||
</div>
|
||||
|
||||
<!-- Current Instance API Password -->
|
||||
<div class="space-y-2">
|
||||
<label for="currentApiPassword" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Current MediaFlow API Password (if required)
|
||||
</label>
|
||||
<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>
|
||||
</div>
|
||||
<div class="flex space-x-3">
|
||||
<button
|
||||
type="button"
|
||||
onclick="showView('selectionView')"
|
||||
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"
|
||||
id="addServerBtn"
|
||||
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
|
||||
</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
|
||||
+ Add Server
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Testing View -->
|
||||
<div id="testingView" class="max-w-4xl mx-auto space-y-6 hidden">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6">
|
||||
<!-- User Info Section -->
|
||||
<div id="userInfo" class="mb-6 hidden">
|
||||
<!-- User info will be populated dynamically -->
|
||||
<div id="serversContainer" class="space-y-3">
|
||||
<!-- Current server (auto-added) -->
|
||||
<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">
|
||||
<input
|
||||
type="url"
|
||||
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>
|
||||
|
||||
<!-- Progress Section -->
|
||||
<!-- CDN Location Selection -->
|
||||
<div class="space-y-4">
|
||||
<div class="text-center text-gray-600 dark:text-gray-300" id="currentLocation">
|
||||
Initializing test...
|
||||
<h3 class="text-lg font-medium text-gray-800 dark:text-white">CDN Locations</h3>
|
||||
|
||||
<!-- CDN Status and Selection Container -->
|
||||
<div id="cdnStatusContainer">
|
||||
<!-- Status message will be populated here -->
|
||||
</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>
|
||||
|
||||
<!-- Results Container -->
|
||||
<div id="resultsContainer" class="mt-8">
|
||||
<!-- Results will be populated dynamically -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Results View -->
|
||||
<div id="resultsView" class="max-w-4xl mx-auto space-y-6 hidden">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6">
|
||||
<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>
|
||||
<!-- Test Options -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div class="space-y-4">
|
||||
<h3 class="text-lg font-medium text-gray-800 dark:text-white">Test Options</h3>
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center space-x-2">
|
||||
<input type="checkbox" id="testProxy" checked class="rounded border-gray-300 dark:border-gray-600">
|
||||
<label for="testProxy" class="text-sm text-gray-700 dark:text-gray-300">
|
||||
Test through MediaFlow proxy
|
||||
</label>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">Top Speed</div>
|
||||
<div id="topSpeed" class="font-medium text-green-500"></div>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<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 class="flex items-center space-x-2">
|
||||
<input type="checkbox" id="testDirect" checked class="rounded border-gray-300 dark:border-gray-600">
|
||||
<label for="testDirect" class="text-sm text-gray-700 dark:text-gray-300">
|
||||
Test direct connection (for comparison)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Detailed Results -->
|
||||
<div id="finalResults" class="space-y-4">
|
||||
<!-- Results will be populated here -->
|
||||
<div class="space-y-4">
|
||||
<h3 class="text-lg font-medium text-gray-800 dark:text-white">Advanced Settings</h3>
|
||||
<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 class="text-center mt-6">
|
||||
<button onclick="resetTest()" class="px-6 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors">
|
||||
Test Another Provider
|
||||
<button
|
||||
type="submit"
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error View -->
|
||||
<div id="errorView" class="max-w-4xl mx-auto space-y-6 hidden">
|
||||
<div class="bg-red-50 dark:bg-red-900/50 border-l-4 border-red-500 p-4 rounded">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<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>
|
||||
<!-- Live Results -->
|
||||
<div id="liveResults" class="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||
<!-- Results will be populated here -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-center">
|
||||
<button onclick="resetTest()"
|
||||
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">
|
||||
Try Again
|
||||
</button>
|
||||
<!-- Results View -->
|
||||
<div id="resultsView" class="max-w-7xl mx-auto space-y-6 hidden">
|
||||
<!-- Key Metrics -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||
<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>
|
||||
|
||||
<!-- 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>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
// Config and State
|
||||
const STATE = {
|
||||
apiPassword: localStorage.getItem('speedtest_api_password'),
|
||||
adApiKey: localStorage.getItem('ad_api_key'),
|
||||
currentTaskId: null,
|
||||
resultsCount: 0,
|
||||
};
|
||||
// Theme management
|
||||
const themeToggle = document.getElementById('themeToggle');
|
||||
const html = document.documentElement;
|
||||
|
||||
// Theme handling
|
||||
function setTheme(theme) {
|
||||
document.documentElement.classList.toggle('dark', theme === 'dark');
|
||||
localStorage.theme = theme;
|
||||
}
|
||||
// Check for saved theme preference or default to light mode
|
||||
const savedTheme = localStorage.getItem('theme') || 'light';
|
||||
html.classList.toggle('dark', savedTheme === 'dark');
|
||||
|
||||
function initTheme() {
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
setTheme(localStorage.theme || (prefersDark ? 'dark' : 'light'));
|
||||
}
|
||||
|
||||
// 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');
|
||||
themeToggle.addEventListener('click', () => {
|
||||
html.classList.toggle('dark');
|
||||
const newTheme = html.classList.contains('dark') ? 'dark' : 'light';
|
||||
localStorage.setItem('theme', newTheme);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
|
||||
1239
mediaflow_proxy/static/speedtest.js
Normal file
1239
mediaflow_proxy/static/speedtest.js
Normal file
File diff suppressed because it is too large
Load Diff
BIN
mediaflow_proxy/utils/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
mediaflow_proxy/utils/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
BIN
mediaflow_proxy/utils/__pycache__/cache_utils.cpython-313.pyc
Normal file
BIN
mediaflow_proxy/utils/__pycache__/cache_utils.cpython-313.pyc
Normal file
Binary file not shown.
BIN
mediaflow_proxy/utils/__pycache__/crypto_utils.cpython-313.pyc
Normal file
BIN
mediaflow_proxy/utils/__pycache__/crypto_utils.cpython-313.pyc
Normal file
Binary file not shown.
BIN
mediaflow_proxy/utils/__pycache__/http_utils.cpython-313.pyc
Normal file
BIN
mediaflow_proxy/utils/__pycache__/http_utils.cpython-313.pyc
Normal file
Binary file not shown.
BIN
mediaflow_proxy/utils/__pycache__/m3u8_processor.cpython-313.pyc
Normal file
BIN
mediaflow_proxy/utils/__pycache__/m3u8_processor.cpython-313.pyc
Normal file
Binary file not shown.
BIN
mediaflow_proxy/utils/__pycache__/mpd_utils.cpython-313.pyc
Normal file
BIN
mediaflow_proxy/utils/__pycache__/mpd_utils.cpython-313.pyc
Normal file
Binary file not shown.
@@ -14,9 +14,7 @@ from typing import Optional, Union, Any
|
||||
|
||||
import aiofiles
|
||||
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.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
|
||||
)
|
||||
|
||||
SPEEDTEST_CACHE = HybridCache(
|
||||
cache_dir_name="speedtest_cache",
|
||||
ttl=3600, # 1 hour
|
||||
max_memory_size=50 * 1024 * 1024,
|
||||
)
|
||||
|
||||
EXTRACTOR_CACHE = HybridCache(
|
||||
cache_dir_name="extractor_cache",
|
||||
ttl=5 * 60, # 5 minutes
|
||||
@@ -335,27 +327,6 @@ async def get_cached_mpd(
|
||||
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]:
|
||||
"""Get extractor result from cache."""
|
||||
cached_data = await EXTRACTOR_CACHE.get(key)
|
||||
|
||||
@@ -175,7 +175,9 @@ class Streamer:
|
||||
logger.warning(f"Remote server closed connection prematurely: {e}")
|
||||
# If we've received some data, just log the warning and return normally
|
||||
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
|
||||
else:
|
||||
# If we haven't received any data, raise an error
|
||||
@@ -375,6 +377,70 @@ def encode_mediaflow_proxy_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:
|
||||
"""
|
||||
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}")
|
||||
try:
|
||||
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:
|
||||
logger.warning(f"Could not finalize response after remote error: {close_err}")
|
||||
else:
|
||||
|
||||
@@ -3,8 +3,9 @@ import re
|
||||
from typing import AsyncGenerator
|
||||
from urllib import parse
|
||||
|
||||
from mediaflow_proxy.configs import settings
|
||||
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:
|
||||
@@ -39,7 +40,7 @@ class M3U8Processor:
|
||||
if "URI=" in line:
|
||||
processed_lines.append(await self.process_key_line(line, base_url))
|
||||
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:
|
||||
processed_lines.append(line)
|
||||
return "\n".join(processed_lines)
|
||||
@@ -104,7 +105,7 @@ class M3U8Processor:
|
||||
if "URI=" in line:
|
||||
return await self.process_key_line(line, base_url)
|
||||
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:
|
||||
return line
|
||||
|
||||
@@ -129,9 +130,9 @@ class M3U8Processor:
|
||||
line = line.replace(f'URI="{original_uri}"', f'URI="{new_uri}"')
|
||||
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:
|
||||
url (str): The URL to proxy.
|
||||
@@ -141,6 +142,51 @@ class M3U8Processor:
|
||||
str: The proxied 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)
|
||||
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
|
||||
|
||||
@@ -253,6 +253,16 @@ def parse_representation(
|
||||
profile["frameRate"] = round(int(frame_rate.split("/")[0]) / int(frame_rate.split("/")[1]), 3)
|
||||
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:
|
||||
return profile
|
||||
|
||||
@@ -502,6 +512,12 @@ def create_segment_data(segment: Dict, item: dict, profile: dict, source: str, t
|
||||
"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:
|
||||
segment_data.update(
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user