diff --git a/mediaflow_proxy/__pycache__/__init__.cpython-313.pyc b/mediaflow_proxy/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..d1dafe9 Binary files /dev/null and b/mediaflow_proxy/__pycache__/__init__.cpython-313.pyc differ diff --git a/mediaflow_proxy/__pycache__/configs.cpython-313.pyc b/mediaflow_proxy/__pycache__/configs.cpython-313.pyc new file mode 100644 index 0000000..6cf092f Binary files /dev/null and b/mediaflow_proxy/__pycache__/configs.cpython-313.pyc differ diff --git a/mediaflow_proxy/__pycache__/const.cpython-313.pyc b/mediaflow_proxy/__pycache__/const.cpython-313.pyc new file mode 100644 index 0000000..9d752f2 Binary files /dev/null and b/mediaflow_proxy/__pycache__/const.cpython-313.pyc differ diff --git a/mediaflow_proxy/__pycache__/handlers.cpython-313.pyc b/mediaflow_proxy/__pycache__/handlers.cpython-313.pyc new file mode 100644 index 0000000..1835c17 Binary files /dev/null and b/mediaflow_proxy/__pycache__/handlers.cpython-313.pyc differ diff --git a/mediaflow_proxy/__pycache__/main.cpython-313.pyc b/mediaflow_proxy/__pycache__/main.cpython-313.pyc new file mode 100644 index 0000000..d42c45e Binary files /dev/null and b/mediaflow_proxy/__pycache__/main.cpython-313.pyc differ diff --git a/mediaflow_proxy/__pycache__/middleware.cpython-313.pyc b/mediaflow_proxy/__pycache__/middleware.cpython-313.pyc new file mode 100644 index 0000000..bebb7b0 Binary files /dev/null and b/mediaflow_proxy/__pycache__/middleware.cpython-313.pyc differ diff --git a/mediaflow_proxy/__pycache__/mpd_processor.cpython-313.pyc b/mediaflow_proxy/__pycache__/mpd_processor.cpython-313.pyc new file mode 100644 index 0000000..df84977 Binary files /dev/null and b/mediaflow_proxy/__pycache__/mpd_processor.cpython-313.pyc differ diff --git a/mediaflow_proxy/__pycache__/schemas.cpython-313.pyc b/mediaflow_proxy/__pycache__/schemas.cpython-313.pyc new file mode 100644 index 0000000..cd0a210 Binary files /dev/null and b/mediaflow_proxy/__pycache__/schemas.cpython-313.pyc differ diff --git a/mediaflow_proxy/configs.py b/mediaflow_proxy/configs.py index 9e42d13..99410b5 100644 --- a/mediaflow_proxy/configs.py +++ b/mediaflow_proxy/configs.py @@ -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. diff --git a/mediaflow_proxy/drm/__pycache__/__init__.cpython-313.pyc b/mediaflow_proxy/drm/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..78c4935 Binary files /dev/null and b/mediaflow_proxy/drm/__pycache__/__init__.cpython-313.pyc differ diff --git a/mediaflow_proxy/drm/__pycache__/decrypter.cpython-313.pyc b/mediaflow_proxy/drm/__pycache__/decrypter.cpython-313.pyc new file mode 100644 index 0000000..613938d Binary files /dev/null and b/mediaflow_proxy/drm/__pycache__/decrypter.cpython-313.pyc differ diff --git a/mediaflow_proxy/extractors/__pycache__/__init__.cpython-313.pyc b/mediaflow_proxy/extractors/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..3756a31 Binary files /dev/null and b/mediaflow_proxy/extractors/__pycache__/__init__.cpython-313.pyc differ diff --git a/mediaflow_proxy/extractors/__pycache__/base.cpython-313.pyc b/mediaflow_proxy/extractors/__pycache__/base.cpython-313.pyc new file mode 100644 index 0000000..38e6c40 Binary files /dev/null and b/mediaflow_proxy/extractors/__pycache__/base.cpython-313.pyc differ diff --git a/mediaflow_proxy/extractors/__pycache__/dlhd.cpython-313.pyc b/mediaflow_proxy/extractors/__pycache__/dlhd.cpython-313.pyc new file mode 100644 index 0000000..1e7ffc5 Binary files /dev/null and b/mediaflow_proxy/extractors/__pycache__/dlhd.cpython-313.pyc differ diff --git a/mediaflow_proxy/extractors/__pycache__/doodstream.cpython-313.pyc b/mediaflow_proxy/extractors/__pycache__/doodstream.cpython-313.pyc new file mode 100644 index 0000000..0e81274 Binary files /dev/null and b/mediaflow_proxy/extractors/__pycache__/doodstream.cpython-313.pyc differ diff --git a/mediaflow_proxy/extractors/__pycache__/factory.cpython-313.pyc b/mediaflow_proxy/extractors/__pycache__/factory.cpython-313.pyc new file mode 100644 index 0000000..db1b442 Binary files /dev/null and b/mediaflow_proxy/extractors/__pycache__/factory.cpython-313.pyc differ diff --git a/mediaflow_proxy/extractors/__pycache__/livetv.cpython-313.pyc b/mediaflow_proxy/extractors/__pycache__/livetv.cpython-313.pyc new file mode 100644 index 0000000..8a843f3 Binary files /dev/null and b/mediaflow_proxy/extractors/__pycache__/livetv.cpython-313.pyc differ diff --git a/mediaflow_proxy/extractors/__pycache__/maxstream.cpython-313.pyc b/mediaflow_proxy/extractors/__pycache__/maxstream.cpython-313.pyc new file mode 100644 index 0000000..29345bd Binary files /dev/null and b/mediaflow_proxy/extractors/__pycache__/maxstream.cpython-313.pyc differ diff --git a/mediaflow_proxy/extractors/__pycache__/mixdrop.cpython-313.pyc b/mediaflow_proxy/extractors/__pycache__/mixdrop.cpython-313.pyc new file mode 100644 index 0000000..f95bcc1 Binary files /dev/null and b/mediaflow_proxy/extractors/__pycache__/mixdrop.cpython-313.pyc differ diff --git a/mediaflow_proxy/extractors/__pycache__/okru.cpython-313.pyc b/mediaflow_proxy/extractors/__pycache__/okru.cpython-313.pyc new file mode 100644 index 0000000..f769a97 Binary files /dev/null and b/mediaflow_proxy/extractors/__pycache__/okru.cpython-313.pyc differ diff --git a/mediaflow_proxy/extractors/__pycache__/streamtape.cpython-313.pyc b/mediaflow_proxy/extractors/__pycache__/streamtape.cpython-313.pyc new file mode 100644 index 0000000..977a971 Binary files /dev/null and b/mediaflow_proxy/extractors/__pycache__/streamtape.cpython-313.pyc differ diff --git a/mediaflow_proxy/extractors/__pycache__/supervideo.cpython-313.pyc b/mediaflow_proxy/extractors/__pycache__/supervideo.cpython-313.pyc new file mode 100644 index 0000000..a0135f9 Binary files /dev/null and b/mediaflow_proxy/extractors/__pycache__/supervideo.cpython-313.pyc differ diff --git a/mediaflow_proxy/extractors/__pycache__/uqload.cpython-313.pyc b/mediaflow_proxy/extractors/__pycache__/uqload.cpython-313.pyc new file mode 100644 index 0000000..5292dba Binary files /dev/null and b/mediaflow_proxy/extractors/__pycache__/uqload.cpython-313.pyc differ diff --git a/mediaflow_proxy/extractors/__pycache__/vixcloud.cpython-313.pyc b/mediaflow_proxy/extractors/__pycache__/vixcloud.cpython-313.pyc new file mode 100644 index 0000000..faea6f3 Binary files /dev/null and b/mediaflow_proxy/extractors/__pycache__/vixcloud.cpython-313.pyc differ diff --git a/mediaflow_proxy/extractors/dlhd.py b/mediaflow_proxy/extractors/dlhd.py index a971f28..d290b7a 100644 --- a/mediaflow_proxy/extractors/dlhd.py +++ b/mediaflow_proxy/extractors/dlhd.py @@ -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']*src=["\']([^"\']+)["\'][^>]*allowfullscreen', - html_content, - re.IGNORECASE + r']*src=["\']([^"\']+)["\'][^>]*allowfullscreen', html_content, re.IGNORECASE ) if not iframe_match: @@ -215,7 +235,7 @@ class DLHDExtractor(BaseExtractor): iframe_match = re.search( r']*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 diff --git a/mediaflow_proxy/extractors/vixcloud.py b/mediaflow_proxy/extractors/vixcloud.py index 21118a7..b480da5 100644 --- a/mediaflow_proxy/extractors/vixcloud.py +++ b/mediaflow_proxy/extractors/vixcloud.py @@ -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, diff --git a/mediaflow_proxy/middleware.py b/mediaflow_proxy/middleware.py index d217587..25eebbb 100644 --- a/mediaflow_proxy/middleware.py +++ b/mediaflow_proxy/middleware.py @@ -23,4 +23,3 @@ class UIAccessControlMiddleware(BaseHTTPMiddleware): return Response(status_code=403, content="Forbidden") return await call_next(request) - diff --git a/mediaflow_proxy/mpd_processor.py b/mediaflow_proxy/mpd_processor.py index 76fba69..a6569f7 100644 --- a/mediaflow_proxy/mpd_processor.py +++ b/mediaflow_proxy/mpd_processor.py @@ -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}", diff --git a/mediaflow_proxy/routes/__pycache__/__init__.cpython-313.pyc b/mediaflow_proxy/routes/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..5bbc171 Binary files /dev/null and b/mediaflow_proxy/routes/__pycache__/__init__.cpython-313.pyc differ diff --git a/mediaflow_proxy/routes/__pycache__/extractor.cpython-313.pyc b/mediaflow_proxy/routes/__pycache__/extractor.cpython-313.pyc new file mode 100644 index 0000000..92bbf0e Binary files /dev/null and b/mediaflow_proxy/routes/__pycache__/extractor.cpython-313.pyc differ diff --git a/mediaflow_proxy/routes/__pycache__/proxy.cpython-313.pyc b/mediaflow_proxy/routes/__pycache__/proxy.cpython-313.pyc new file mode 100644 index 0000000..451ae2c Binary files /dev/null and b/mediaflow_proxy/routes/__pycache__/proxy.cpython-313.pyc differ diff --git a/mediaflow_proxy/routes/__pycache__/speedtest.cpython-313.pyc b/mediaflow_proxy/routes/__pycache__/speedtest.cpython-313.pyc new file mode 100644 index 0000000..53d5a7a Binary files /dev/null and b/mediaflow_proxy/routes/__pycache__/speedtest.cpython-313.pyc differ diff --git a/mediaflow_proxy/routes/speedtest.py b/mediaflow_proxy/routes/speedtest.py index ec33bfd..8a6abfe 100644 --- a/mediaflow_proxy/routes/speedtest.py +++ b/mediaflow_proxy/routes/speedtest.py @@ -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)) diff --git a/mediaflow_proxy/schemas.py b/mediaflow_proxy/schemas.py index 9883883..d621754 100644 --- a/mediaflow_proxy/schemas.py +++ b/mediaflow_proxy/schemas.py @@ -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): diff --git a/mediaflow_proxy/speedtest/__pycache__/__init__.cpython-313.pyc b/mediaflow_proxy/speedtest/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..82bf3c9 Binary files /dev/null and b/mediaflow_proxy/speedtest/__pycache__/__init__.cpython-313.pyc differ diff --git a/mediaflow_proxy/speedtest/__pycache__/models.cpython-313.pyc b/mediaflow_proxy/speedtest/__pycache__/models.cpython-313.pyc new file mode 100644 index 0000000..cbdfcc8 Binary files /dev/null and b/mediaflow_proxy/speedtest/__pycache__/models.cpython-313.pyc differ diff --git a/mediaflow_proxy/speedtest/__pycache__/service.cpython-313.pyc b/mediaflow_proxy/speedtest/__pycache__/service.cpython-313.pyc new file mode 100644 index 0000000..5dd28fe Binary files /dev/null and b/mediaflow_proxy/speedtest/__pycache__/service.cpython-313.pyc differ diff --git a/mediaflow_proxy/speedtest/models.py b/mediaflow_proxy/speedtest/models.py index c789fe1..e21dad4 100644 --- a/mediaflow_proxy/speedtest/models.py +++ b/mediaflow_proxy/speedtest/models.py @@ -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 diff --git a/mediaflow_proxy/speedtest/providers/__pycache__/all_debrid.cpython-312.pyc b/mediaflow_proxy/speedtest/providers/__pycache__/all_debrid.cpython-312.pyc deleted file mode 100644 index f399fde..0000000 Binary files a/mediaflow_proxy/speedtest/providers/__pycache__/all_debrid.cpython-312.pyc and /dev/null differ diff --git a/mediaflow_proxy/speedtest/providers/__pycache__/all_debrid.cpython-313.pyc b/mediaflow_proxy/speedtest/providers/__pycache__/all_debrid.cpython-313.pyc new file mode 100644 index 0000000..789cb1e Binary files /dev/null and b/mediaflow_proxy/speedtest/providers/__pycache__/all_debrid.cpython-313.pyc differ diff --git a/mediaflow_proxy/speedtest/providers/__pycache__/base.cpython-312.pyc b/mediaflow_proxy/speedtest/providers/__pycache__/base.cpython-312.pyc deleted file mode 100644 index 01373e6..0000000 Binary files a/mediaflow_proxy/speedtest/providers/__pycache__/base.cpython-312.pyc and /dev/null differ diff --git a/mediaflow_proxy/speedtest/providers/__pycache__/base.cpython-313.pyc b/mediaflow_proxy/speedtest/providers/__pycache__/base.cpython-313.pyc new file mode 100644 index 0000000..b8f9a0c Binary files /dev/null and b/mediaflow_proxy/speedtest/providers/__pycache__/base.cpython-313.pyc differ diff --git a/mediaflow_proxy/speedtest/providers/__pycache__/real_debrid.cpython-312.pyc b/mediaflow_proxy/speedtest/providers/__pycache__/real_debrid.cpython-312.pyc deleted file mode 100644 index 6aa4e67..0000000 Binary files a/mediaflow_proxy/speedtest/providers/__pycache__/real_debrid.cpython-312.pyc and /dev/null differ diff --git a/mediaflow_proxy/speedtest/providers/__pycache__/real_debrid.cpython-313.pyc b/mediaflow_proxy/speedtest/providers/__pycache__/real_debrid.cpython-313.pyc new file mode 100644 index 0000000..bc729af Binary files /dev/null and b/mediaflow_proxy/speedtest/providers/__pycache__/real_debrid.cpython-313.pyc differ diff --git a/mediaflow_proxy/speedtest/service.py b/mediaflow_proxy/speedtest/service.py index b40c639..611c32d 100644 --- a/mediaflow_proxy/speedtest/service.py +++ b/mediaflow_proxy/speedtest/service.py @@ -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 diff --git a/mediaflow_proxy/static/index.html b/mediaflow_proxy/static/index.html index ea185a0..9885320 100644 --- a/mediaflow_proxy/static/index.html +++ b/mediaflow_proxy/static/index.html @@ -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 @@
Proxy and modify HLS (M3U8) streams in real-time with custom headers and key URL modifications for bypassing some sneaky restrictions.
Protect against unauthorized access and network bandwidth abuses
+
+

🚀 Speed Test Tool

+

Test your connection speed with debrid services to optimize your streaming experience:

+ +

+ Browser Speed Test: Tests your actual connection speed through MediaFlow proxy vs direct connection with support for multiple servers, interactive charts, and comprehensive analytics. +

+
+

Getting Started

Visit the GitHub repository for installation instructions and documentation.

diff --git a/mediaflow_proxy/static/speedtest.html b/mediaflow_proxy/static/speedtest.html index 386fa8e..f6375b5 100644 --- a/mediaflow_proxy/static/speedtest.html +++ b/mediaflow_proxy/static/speedtest.html @@ -3,38 +3,39 @@ - Debrid Speed Test + MediaFlow Speed Test + + @@ -63,634 +77,301 @@
- -
- -
-

- Enter API Password -

+ +
+

+ 🚀 MediaFlow Speed Test +

+

+ Compare your connection speed through MediaFlow proxy vs direct connection +

+
-
-
+ +
+
+

Test Configuration

+ + + +
-
-
- - -
- - -
-
- - - - -
diff --git a/mediaflow_proxy/static/speedtest.js b/mediaflow_proxy/static/speedtest.js new file mode 100644 index 0000000..8973b1d --- /dev/null +++ b/mediaflow_proxy/static/speedtest.js @@ -0,0 +1,1239 @@ +// Speed test functionality +class MediaFlowSpeedTest { + constructor() { + this.config = null; + this.results = { + proxy: {}, + direct: {} + }; + this.servers = []; + this.currentTestIndex = 0; + this.totalTests = 0; + this.charts = {}; + this.testCancelled = false; + this.selectedCdns = new Set(); + this.activeAbortControllers = new Set(); + + this.initializeEventListeners(); + this.initializeForm(); + this.setupResizeHandler(); + } + + initializeEventListeners() { + document.getElementById('configForm').addEventListener('submit', (e) => { + e.preventDefault(); + this.startSpeedTest(); + }); + + document.getElementById('provider').addEventListener('change', (e) => { + const apiKeySection = document.getElementById('apiKeySection'); + if (e.target.value === 'all_debrid') { + apiKeySection.classList.remove('hidden'); + } else { + apiKeySection.classList.add('hidden'); + } + + // Clear CDN selection when provider changes + this.config = null; + this.selectedCdns.clear(); + this.showPlaceholderCdnSelection(); + this.updateCdnButtonStates(); + }); + + document.getElementById('addServerBtn').addEventListener('click', () => { + this.addServerInput(); + }); + + document.getElementById('runAgainBtn').addEventListener('click', () => { + this.resetTest(); + }); + + document.getElementById('cancelTestBtn').addEventListener('click', () => { + this.cancelTest(); + }); + + document.getElementById('selectAllCdn').addEventListener('click', () => { + this.selectAllCdns(true); + }); + + document.getElementById('selectNoneCdn').addEventListener('click', () => { + this.selectAllCdns(false); + }); + + document.getElementById('refreshCdnBtn').addEventListener('click', async () => { + await this.refreshCdnLocations(); + }); + + // Handle server removal + document.addEventListener('click', (e) => { + if (e.target.classList.contains('remove-server')) { + e.target.closest('.server-input').remove(); + this.updateRemoveButtons(); + } + }); + } + + initializeForm() { + // Set current URL as default MediaFlow URL + const currentUrl = new URL(window.location.href); + const baseUrl = `${currentUrl.protocol}//${currentUrl.host}`; + const firstServerUrl = document.querySelector('.server-url'); + firstServerUrl.value = baseUrl; + firstServerUrl.placeholder = `${baseUrl} (Current Instance)`; + + // Show placeholder CDN selection initially + this.showPlaceholderCdnSelection(); + this.updateCdnButtonStates(); + } + + showPlaceholderCdnSelection() { + const cdnStatusContainer = document.getElementById('cdnStatusContainer'); + const cdnContainer = document.getElementById('cdnSelection'); + + // Clear status container + cdnStatusContainer.innerHTML = ''; + + // Show placeholder in main CDN container + cdnContainer.innerHTML = ` +
+
🌐
+

CDN Locations Not Loaded

+

+ Configure your debrid provider settings above, then click "🔄 Refresh CDNs" to load available locations. +

+
+ Steps:
+ 1. Select your debrid provider
+ 2. Enter API key (if required)
+ 3. Click "🔄 Refresh CDNs"
+ 4. Select desired locations
+ 5. Start speed test +
+
+ `; + } + + addServerInput() { + const container = document.getElementById('serversContainer'); + const serverDiv = document.createElement('div'); + serverDiv.className = 'server-input grid grid-cols-1 md:grid-cols-3 gap-3 p-3 bg-gray-50 dark:bg-gray-700 rounded-lg'; + + serverDiv.innerHTML = ` + + +
+ + +
+ `; + + container.appendChild(serverDiv); + this.updateRemoveButtons(); + } + + updateRemoveButtons() { + const serverInputs = document.querySelectorAll('.server-input'); + serverInputs.forEach((input, index) => { + const removeBtn = input.querySelector('.remove-server'); + if (index === 0) { + removeBtn.classList.add('hidden'); + } else { + removeBtn.classList.remove('hidden'); + } + }); + } + + async startSpeedTest() { + try { + this.testCancelled = false; + + // Check if CDN configuration is loaded + if (!this.config || !this.config.test_urls) { + alert('Please fetch CDN locations first by clicking "🔄 Refresh CDNs" button.'); + return; + } + + // Collect server configurations + this.collectServerConfigurations(); + + // Validate server configurations + if (this.servers.length === 0) { + alert('Please add at least one MediaFlow server to test.'); + return; + } + + // Validate CDN selections + if (this.selectedCdns.size === 0) { + alert('Please select at least one CDN location to test.'); + return; + } + + // Validate test options + const testProxy = document.getElementById('testProxy').checked; + const testDirect = document.getElementById('testDirect').checked; + + if (!testProxy && !testDirect) { + alert('Please select at least one test option (Proxy or Direct).'); + return; + } + + // Calculate total tests + this.calculateTotalTests(); + + this.showTestingView(); + await this.runTests(); + if (!this.testCancelled) { + this.showResults(); + } + } catch (error) { + console.error('Speed test failed:', error); + if (!this.testCancelled) { + alert('Speed test failed: ' + error.message); + this.resetTest(); + } + } + } + + collectServerConfigurations() { + // Collect server configurations + this.servers = []; + const serverInputs = document.querySelectorAll('.server-input'); + + serverInputs.forEach(input => { + const url = input.querySelector('.server-url').value.trim(); + const name = input.querySelector('.server-name').value.trim(); + const password = input.querySelector('.server-password').value.trim(); + + if (url) { + this.servers.push({ + url: url, + name: name || new URL(url).host, + api_password: password || null + }); + } + }); + } + + async refreshCdnLocations() { + const refreshBtn = document.getElementById('refreshCdnBtn'); + const originalText = refreshBtn.textContent; + + try { + // Show loading state + refreshBtn.textContent = 'âŗ Loading...'; + refreshBtn.disabled = true; + + // Show loading in CDN container + const cdnStatusContainer = document.getElementById('cdnStatusContainer'); + const cdnContainer = document.getElementById('cdnSelection'); + + cdnStatusContainer.innerHTML = ''; + cdnContainer.innerHTML = ` +
+
âŗ
+

Loading CDN locations...

+
+ `; + + await this.loadConfiguration(); + this.populateCdnSelection(); + + // Show success message + const successMsg = document.createElement('div'); + successMsg.className = 'fixed top-4 right-4 bg-green-500 text-white px-4 py-2 rounded-lg shadow-lg z-50'; + successMsg.textContent = `✅ Loaded ${Object.keys(this.config.test_urls).length} CDN locations`; + document.body.appendChild(successMsg); + + setTimeout(() => { + successMsg.remove(); + }, 3000); + + } catch (error) { + console.error('Failed to refresh CDN locations:', error); + + // Show error message + const errorMsg = document.createElement('div'); + errorMsg.className = 'fixed top-4 right-4 bg-red-500 text-white px-4 py-2 rounded-lg shadow-lg z-50'; + errorMsg.textContent = `❌ Failed: ${error.message}`; + document.body.appendChild(errorMsg); + + setTimeout(() => { + errorMsg.remove(); + }, 5000); + + this.showPlaceholderCdnSelection(); + this.updateCdnButtonStates(); + } finally { + // Restore button state + refreshBtn.textContent = originalText; + refreshBtn.disabled = false; + } + } + + cancelTest() { + this.testCancelled = true; + document.getElementById('currentTest').textContent = 'Test cancelled by user'; + document.getElementById('progressText').textContent = 'Cancelling...'; + + // Cancel all active network requests + this.activeAbortControllers.forEach(controller => { + try { + controller.abort(); + } catch (e) { + console.warn('Error aborting request:', e); + } + }); + this.activeAbortControllers.clear(); + + setTimeout(() => { + this.resetTest(); + }, 1000); + } + + populateCdnSelection() { + const cdnStatusContainer = document.getElementById('cdnStatusContainer'); + const cdnContainer = document.getElementById('cdnSelection'); + const locations = Object.keys(this.config.test_urls); + + // Initialize all CDNs as selected if none are selected + if (this.selectedCdns.size === 0) { + locations.forEach(location => this.selectedCdns.add(location)); + } + + // Show success status + cdnStatusContainer.innerHTML = ` +
+
+ ✅ + CDN Locations Loaded Successfully + ${locations.length} locations +
+
+ `; + + // Populate CDN checkboxes in the grid + cdnContainer.innerHTML = locations.map(location => ` +
+ + +
+ `).join(''); + + // Add event listeners to checkboxes + document.querySelectorAll('.cdn-checkbox').forEach(checkbox => { + checkbox.addEventListener('change', (e) => { + const location = e.target.dataset.location; + this.toggleCdn(location); + }); + }); + + // Update button states + this.updateCdnButtonStates(); + } + + toggleCdn(location) { + if (this.selectedCdns.has(location)) { + this.selectedCdns.delete(location); + } else { + this.selectedCdns.add(location); + } + } + + selectAllCdns(selectAll) { + if (!this.config || !this.config.test_urls) { + // Show a brief message if CDNs aren't loaded + const message = document.createElement('div'); + message.className = 'fixed top-4 right-4 bg-yellow-500 text-white px-4 py-2 rounded-lg shadow-lg z-50'; + message.textContent = 'âš ī¸ Please load CDN locations first'; + document.body.appendChild(message); + setTimeout(() => message.remove(), 2000); + return; + } + + const checkboxes = document.querySelectorAll('.cdn-checkbox'); + const locations = Object.keys(this.config.test_urls); + + if (selectAll) { + this.selectedCdns = new Set(locations); + checkboxes.forEach(cb => cb.checked = true); + } else { + this.selectedCdns.clear(); + checkboxes.forEach(cb => cb.checked = false); + } + } + + updateCdnButtonStates() { + const selectAllBtn = document.getElementById('selectAllCdn'); + const selectNoneBtn = document.getElementById('selectNoneCdn'); + const hasConfig = this.config && this.config.test_urls; + + if (hasConfig) { + selectAllBtn.disabled = false; + selectNoneBtn.disabled = false; + selectAllBtn.classList.remove('opacity-50', 'cursor-not-allowed'); + selectNoneBtn.classList.remove('opacity-50', 'cursor-not-allowed'); + } else { + selectAllBtn.disabled = true; + selectNoneBtn.disabled = true; + selectAllBtn.classList.add('opacity-50', 'cursor-not-allowed'); + selectNoneBtn.classList.add('opacity-50', 'cursor-not-allowed'); + } + } + + async loadConfiguration() { + const provider = document.getElementById('provider').value; + const apiKey = document.getElementById('apiKey').value; + const currentApiPassword = document.getElementById('currentApiPassword').value; + + // Use current MediaFlow instance for fetching CDN configuration + const currentUrl = new URL(window.location.href); + const baseUrl = `${currentUrl.protocol}//${currentUrl.host}`; + + const requestBody = { + provider: provider, + api_key: apiKey || null, + current_api_password: currentApiPassword || null + }; + + const headers = { + 'Content-Type': 'application/json', + }; + + // Add current API password to headers if provided + if (currentApiPassword) { + headers['api_password'] = currentApiPassword; + } + + const response = await fetch('/speedtest/config', { + method: 'POST', + headers: headers, + body: JSON.stringify(requestBody) + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.detail || 'Failed to load configuration'); + } + + this.config = await response.json(); + } + + calculateTotalTests() { + // Calculate total tests based on selected CDNs and servers + const testProxy = document.getElementById('testProxy').checked; + const testDirect = document.getElementById('testDirect').checked; + const selectedLocationCount = this.selectedCdns.size; + const serverCount = this.servers.length; + + this.totalTests = 0; + if (testProxy) this.totalTests += selectedLocationCount * serverCount; + if (testDirect) this.totalTests += selectedLocationCount; + } + + showTestingView() { + document.getElementById('configView').classList.add('hidden'); + document.getElementById('testingView').classList.remove('hidden'); + document.getElementById('resultsView').classList.add('hidden'); + + // Clear previous results and any existing abort controllers + this.results = {proxy: {}, direct: {}}; + this.activeAbortControllers.forEach(controller => { + try { + controller.abort(); + } catch (e) { + console.warn('Error aborting request:', e); + } + }); + this.activeAbortControllers.clear(); + + document.getElementById('liveResults').innerHTML = ''; + } + + showResults() { + document.getElementById('configView').classList.add('hidden'); + document.getElementById('testingView').classList.add('hidden'); + document.getElementById('resultsView').classList.remove('hidden'); + + this.renderMetrics(); + this.renderCharts(); + this.renderDetailedResults(); + } + + resetTest() { + this.results = {proxy: {}, direct: {}}; + this.currentTestIndex = 0; + this.totalTests = 0; + this.testCancelled = false; + + // Cancel and clear any remaining abort controllers + this.activeAbortControllers.forEach(controller => { + try { + controller.abort(); + } catch (e) { + console.warn('Error aborting request:', e); + } + }); + this.activeAbortControllers.clear(); + + // Destroy existing charts safely + Object.values(this.charts).forEach(chart => { + if (chart && typeof chart.destroy === 'function') { + try { + chart.destroy(); + } catch (e) { + console.warn('Error destroying chart:', e); + } + } + }); + this.charts = {}; + + // Clear canvas elements + ['speedChart', 'serverChart'].forEach(id => { + const canvas = document.getElementById(id); + if (canvas) { + const ctx = canvas.getContext('2d'); + ctx.clearRect(0, 0, canvas.width, canvas.height); + } + }); + + // Clear live results + document.getElementById('liveResults').innerHTML = ''; + + document.getElementById('configView').classList.remove('hidden'); + document.getElementById('testingView').classList.add('hidden'); + document.getElementById('resultsView').classList.add('hidden'); + } + + async runTests() { + const testProxy = document.getElementById('testProxy').checked; + const testDirect = document.getElementById('testDirect').checked; + const testDuration = parseInt(document.getElementById('testDuration').value) || 10; + + this.currentTestIndex = 0; + + // Validate selected CDNs + if (this.selectedCdns.size === 0) { + throw new Error('Please select at least one CDN location to test'); + } + + // Filter test URLs to only selected CDNs + const selectedTestUrls = Object.fromEntries( + Object.entries(this.config.test_urls).filter(([location]) => + this.selectedCdns.has(location) + ) + ); + + // Run proxy tests for each server + if (testProxy && !this.testCancelled) { + for (const server of this.servers) { + if (this.testCancelled) break; + for (const [location, url] of Object.entries(selectedTestUrls)) { + if (this.testCancelled) break; + await this.runSingleTest(location, url, 'proxy', server, testDuration); + } + } + } + + // Run direct tests + if (testDirect && !this.testCancelled) { + for (const [location, url] of Object.entries(selectedTestUrls)) { + if (this.testCancelled) break; + await this.runSingleTest(location, url, 'direct', null, testDuration); + } + } + } + + async runSingleTest(location, url, testType, server, duration) { + if (this.testCancelled) return; + + let testUrl; + let testKey = location; + + if (testType === 'proxy') { + testUrl = `${server.url.replace(/\/$/, '')}/proxy/stream?d=${encodeURIComponent(url)}`; + if (server.api_password) { + testUrl += `&api_password=${encodeURIComponent(server.api_password)}`; + } + testKey = `${location}_${server.name}`; + } else { + testUrl = url; + } + + this.updateProgress(location, testType, server); + + try { + const result = await this.measureSpeed(testUrl, duration); + if (this.testCancelled) return; + + this.results[testType][testKey] = { + ...result, + server_url: url, + test_url: testUrl, + server_name: server ? server.name : 'Direct', + location: location + }; + + this.updateLiveResults(testKey, testType, this.results[testType][testKey]); + } catch (error) { + if (this.testCancelled) return; + + // Don't log or update UI for cancelled tests + if (error.message === 'Test cancelled') { + return; + } + + console.error(`Test failed for ${location} (${testType}):`, error); + this.results[testType][testKey] = { + error: error.message, + server_url: url, + test_url: testUrl, + server_name: server ? server.name : 'Direct', + location: location + }; + + this.updateLiveResults(testKey, testType, this.results[testType][testKey]); + } + + this.currentTestIndex++; + } + + async measureSpeed(url, duration) { + const startTime = performance.now(); + let totalBytes = 0; + + // Create AbortController for this request + const abortController = new AbortController(); + this.activeAbortControllers.add(abortController); + + try { + // Check if test was cancelled before starting + if (this.testCancelled) { + throw new Error('Test cancelled'); + } + + const response = await fetch(url, { + method: 'GET', + headers: { + 'Range': 'bytes=0-' + }, + signal: abortController.signal + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + const reader = response.body.getReader(); + + while (true) { + // Check for cancellation in the reading loop + if (this.testCancelled) { + reader.cancel(); + throw new Error('Test cancelled'); + } + + const {done, value} = await reader.read(); + + if (done) break; + + totalBytes += value.length; + const currentTime = performance.now(); + + // Check if duration exceeded + if (currentTime - startTime >= duration * 1000) { + reader.cancel(); + break; + } + } + + // Final cancellation check before returning results + if (this.testCancelled) { + throw new Error('Test cancelled'); + } + + const actualDuration = (performance.now() - startTime) / 1000; + const speedMbps = (totalBytes * 8) / (actualDuration * 1_000_000); + + return { + speed_mbps: Math.round(speedMbps * 100) / 100, + duration: Math.round(actualDuration * 100) / 100, + data_transferred: totalBytes, + timestamp: new Date().toISOString() + }; + } catch (error) { + // If it's an abort error and test was cancelled, don't propagate the error + if (error.name === 'AbortError' && this.testCancelled) { + throw new Error('Test cancelled'); + } + throw new Error(`Network error: ${error.message}`); + } finally { + // Clean up the abort controller + this.activeAbortControllers.delete(abortController); + } + } + + updateProgress(location, testType, server) { + const progress = this.totalTests > 0 ? (this.currentTestIndex / this.totalTests) * 100 : 0; + const progressPercent = Math.min(Math.round(progress), 100); // Cap at 100% + + document.getElementById('progressBar').style.width = `${progressPercent}%`; + document.getElementById('progressText').textContent = `${progressPercent}% complete`; + + const serverName = server ? server.name : 'Direct'; + document.getElementById('currentTest').textContent = `Testing ${location} via ${serverName} (${testType})...`; + } + + updateLiveResults(testKey, testType, result) { + const liveResults = document.getElementById('liveResults'); + + let card = document.getElementById(`result-${testKey}-${testType}`); + if (!card) { + card = document.createElement('div'); + card.id = `result-${testKey}-${testType}`; + card.className = 'result-card bg-white dark:bg-gray-800 rounded-lg shadow-lg p-4'; + liveResults.appendChild(card); + } + + const speedText = result.error + ? `Error: ${result.error}` + : `${result.speed_mbps} Mbps`; + + const badgeColor = testType === 'proxy' + ? 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200' + : 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200'; + + card.innerHTML = ` +
+

${result.location}

+ + ${result.server_name} + +
+
+ ${speedText} +
+ ${!result.error ? ` +
+ ${(result.data_transferred / 1024 / 1024).toFixed(2)} MB in ${result.duration}s +
+ ` : ''} + `; + } + + renderMetrics() { + const proxyResults = Object.values(this.results.proxy).filter(r => !r.error); + const directResults = Object.values(this.results.direct).filter(r => !r.error); + + // Find best proxy result + const bestProxyResult = proxyResults.length > 0 + ? proxyResults.reduce((best, current) => + current.speed_mbps > best.speed_mbps ? current : best) + : null; + + // Find best direct result + const bestDirectResult = directResults.length > 0 + ? directResults.reduce((best, current) => + current.speed_mbps > best.speed_mbps ? current : best) + : null; + + const bestProxySpeed = bestProxyResult ? bestProxyResult.speed_mbps : 0; + const bestDirectSpeed = bestDirectResult ? bestDirectResult.speed_mbps : 0; + + // Calculate averages + const avgProxySpeed = proxyResults.length > 0 + ? proxyResults.reduce((sum, r) => sum + r.speed_mbps, 0) / proxyResults.length + : 0; + + // Speed difference based on best speeds (more relevant) + const speedDifference = bestDirectSpeed > 0 + ? ((bestProxySpeed - bestDirectSpeed) / bestDirectSpeed * 100) + : 0; + + // Update metrics + document.getElementById('bestProxySpeed').textContent = `${bestProxySpeed.toFixed(2)} Mbps`; + document.getElementById('bestDirectSpeed').textContent = `${bestDirectSpeed.toFixed(2)} Mbps`; + document.getElementById('avgProxySpeed').textContent = `${avgProxySpeed.toFixed(2)} Mbps`; + document.getElementById('speedDifference').textContent = `${speedDifference >= 0 ? '+' : ''}${speedDifference.toFixed(1)}%`; + + // Update additional info + document.getElementById('bestProxyServer').textContent = bestProxyResult + ? `${bestProxyResult.server_name} - ${bestProxyResult.location}` + : '--'; + document.getElementById('bestDirectLocation').textContent = bestDirectResult + ? bestDirectResult.location + : '--'; + document.getElementById('proxyTestCount').textContent = `${proxyResults.length} tests`; + + // Update metric card colors based on performance + const bestProxyMetric = document.getElementById('bestProxyMetric'); + const speedDiffMetric = document.getElementById('speedDiffMetric'); + + // Reset classes + bestProxyMetric.className = 'metric-card text-white p-4 rounded-lg text-center'; + speedDiffMetric.className = 'metric-card text-white p-4 rounded-lg text-center'; + + if (bestProxySpeed >= bestDirectSpeed * 0.8) { + bestProxyMetric.className += ' success'; + } else if (bestProxySpeed >= bestDirectSpeed * 0.5) { + bestProxyMetric.className += ' warning'; + } + + if (speedDifference >= -20) { + speedDiffMetric.className += ' success'; + } else { + speedDiffMetric.className += ' warning'; + } + + // Render server comparison metrics + this.renderServerMetrics(); + } + + renderServerMetrics() { + const serverComparisonGrid = document.getElementById('serverComparisonGrid'); + const proxyResults = Object.values(this.results.proxy).filter(r => !r.error); + + // Group results by server + const serverStats = {}; + proxyResults.forEach(result => { + if (!serverStats[result.server_name]) { + serverStats[result.server_name] = []; + } + serverStats[result.server_name].push(result); + }); + + const serverMetrics = Object.entries(serverStats).map(([serverName, results]) => { + const speeds = results.map(r => r.speed_mbps); + const avgSpeed = speeds.reduce((sum, speed) => sum + speed, 0) / speeds.length; + const bestSpeed = Math.max(...speeds); + const testCount = results.length; + const bestLocation = results.find(r => r.speed_mbps === bestSpeed)?.location || '--'; + + return { + name: serverName, + avgSpeed, + bestSpeed, + testCount, + bestLocation + }; + }).sort((a, b) => b.bestSpeed - a.bestSpeed); + + serverComparisonGrid.innerHTML = serverMetrics.map((server, index) => { + const rankClass = index === 0 ? 'border-green-500 bg-green-50 dark:bg-green-900/20' : + index === 1 ? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20' : + 'border-gray-300 dark:border-gray-600'; + + const rankIcon = index === 0 ? 'đŸĨ‡' : index === 1 ? 'đŸĨˆ' : index === 2 ? 'đŸĨ‰' : `#${index + 1}`; + + return ` +
+
+

${server.name}

+ ${rankIcon} +
+
+
+ Best Speed: + ${server.bestSpeed.toFixed(2)} Mbps +
+
+ Avg Speed: + ${server.avgSpeed.toFixed(2)} Mbps +
+
+ Best Location: + ${server.bestLocation} +
+
+ Tests: + ${server.testCount} +
+
+
+ `; + }).join(''); + + if (serverMetrics.length === 0) { + serverComparisonGrid.innerHTML = ` +
+ No proxy test results available +
+ `; + } + } + + renderCharts() { + const isDark = html.classList.contains('dark'); + const textColor = isDark ? '#e5e7eb' : '#374151'; + const gridColor = isDark ? '#374151' : '#e5e7eb'; + + // Add a small delay to ensure DOM is ready + setTimeout(() => { + // Speed Comparison Chart + this.renderSpeedChart(textColor, gridColor); + + // Server Performance Chart + this.renderServerChart(textColor, gridColor); + }, 100); + } + + setupResizeHandler() { + let resizeTimeout; + window.addEventListener('resize', () => { + clearTimeout(resizeTimeout); + resizeTimeout = setTimeout(() => { + if (document.getElementById('resultsView').classList.contains('hidden')) { + return; // Don't resize if results view is not visible + } + this.renderCharts(); + }, 250); + }); + } + + renderSpeedChart(textColor, gridColor) { + const canvas = document.getElementById('speedChart'); + const ctx = canvas.getContext('2d'); + + if (this.charts.speedChart) { + this.charts.speedChart.destroy(); + } + + // Ensure proper canvas sizing + const container = canvas.parentElement; + const containerRect = container.getBoundingClientRect(); + canvas.style.width = '100%'; + canvas.style.height = '100%'; + + const locations = [...new Set([ + ...Object.values(this.results.proxy).map(r => r.location), + ...Object.values(this.results.direct).map(r => r.location) + ])].filter(Boolean); + + if (locations.length === 0) { + return; + } + + // Group proxy results by server and location + const serverNames = [...new Set(Object.values(this.results.proxy).map(r => r.server_name))]; + + // Create datasets for each server + direct + const datasets = []; + + // Color palette for different servers + const colors = [ + {bg: 'rgba(59, 130, 246, 0.8)', border: 'rgba(59, 130, 246, 1)'}, // Blue + {bg: 'rgba(16, 185, 129, 0.8)', border: 'rgba(16, 185, 129, 1)'}, // Green + {bg: 'rgba(245, 158, 11, 0.8)', border: 'rgba(245, 158, 11, 1)'}, // Yellow + {bg: 'rgba(239, 68, 68, 0.8)', border: 'rgba(239, 68, 68, 1)'}, // Red + {bg: 'rgba(168, 85, 247, 0.8)', border: 'rgba(168, 85, 247, 1)'}, // Purple + ]; + + // Add datasets for each server + serverNames.forEach((serverName, index) => { + const color = colors[index % colors.length]; + const data = locations.map(location => { + const result = Object.values(this.results.proxy).find(r => + r.location === location && r.server_name === serverName && !r.error + ); + return result ? result.speed_mbps : 0; + }); + + datasets.push({ + label: `${serverName} (Proxy)`, + data: data, + backgroundColor: color.bg, + borderColor: color.border, + borderWidth: 1 + }); + }); + + // Add direct speed dataset + const directData = locations.map(location => { + const result = this.results.direct[location]; + return result && !result.error ? result.speed_mbps : 0; + }); + + datasets.push({ + label: 'Direct Connection', + data: directData, + backgroundColor: 'rgba(107, 114, 128, 0.8)', + borderColor: 'rgba(107, 114, 128, 1)', + borderWidth: 1 + }); + + this.charts.speedChart = new Chart(ctx, { + type: 'bar', + data: { + labels: locations, + datasets: datasets + }, + options: { + responsive: true, + maintainAspectRatio: false, + interaction: { + intersect: false, + }, + plugins: { + legend: { + labels: { + color: textColor, + usePointStyle: true, + padding: 15, + font: { + size: 12 + } + } + } + }, + scales: { + y: { + beginAtZero: true, + title: { + display: true, + text: 'Speed (Mbps)', + color: textColor + }, + ticks: { + color: textColor, + maxTicksLimit: 8 + }, + grid: { + color: gridColor + } + }, + x: { + ticks: { + color: textColor, + maxRotation: 45 + }, + grid: { + color: gridColor + } + } + } + } + }); + } + + renderServerChart(textColor, gridColor) { + const canvas = document.getElementById('serverChart'); + const ctx = canvas.getContext('2d'); + + if (!ctx) { + console.error('Failed to get canvas context'); + return; + } + + if (this.charts.serverChart) { + this.charts.serverChart.destroy(); + } + + // Ensure proper canvas sizing + const container = canvas.parentElement; + const containerRect = container.getBoundingClientRect(); + canvas.style.width = '100%'; + canvas.style.height = '100%'; + + // Group results by server + const serverStats = {}; + + Object.values(this.results.proxy).forEach(result => { + if (!result.error && result.server_name) { + if (!serverStats[result.server_name]) { + serverStats[result.server_name] = []; + } + serverStats[result.server_name].push(result.speed_mbps); + } + }); + + const serverNames = Object.keys(serverStats); + + if (serverNames.length === 0) { + // Show a message for no data + ctx.fillStyle = textColor; + ctx.font = '16px Arial'; + ctx.textAlign = 'center'; + ctx.fillText('No server data available', canvas.width / 2, canvas.height / 2); + return; + } + + // Use a bar chart instead of radar for better clarity + const avgSpeeds = serverNames.map(name => { + const speeds = serverStats[name]; + return speeds.reduce((sum, speed) => sum + speed, 0) / speeds.length; + }); + const maxSpeeds = serverNames.map(name => Math.max(...serverStats[name])); + const minSpeeds = serverNames.map(name => Math.min(...serverStats[name])); + + this.charts.serverChart = new Chart(ctx, { + type: 'bar', + data: { + labels: serverNames, + datasets: [ + { + label: 'Best Speed', + data: maxSpeeds, + backgroundColor: 'rgba(34, 197, 94, 0.8)', + borderColor: 'rgba(34, 197, 94, 1)', + borderWidth: 1, + order: 1 + }, + { + label: 'Average Speed', + data: avgSpeeds, + backgroundColor: 'rgba(59, 130, 246, 0.8)', + borderColor: 'rgba(59, 130, 246, 1)', + borderWidth: 1, + order: 2 + }, + { + label: 'Worst Speed', + data: minSpeeds, + backgroundColor: 'rgba(239, 68, 68, 0.8)', + borderColor: 'rgba(239, 68, 68, 1)', + borderWidth: 1, + order: 3 + } + ] + }, + options: { + responsive: true, + maintainAspectRatio: false, + interaction: { + intersect: false, + }, + plugins: { + legend: { + labels: { + color: textColor, + usePointStyle: true, + padding: 15, + font: { + size: 12 + } + } + }, + tooltip: { + callbacks: { + afterLabel: function (context) { + const serverName = context.label; + const speeds = serverStats[serverName]; + return `Tests: ${speeds.length}`; + } + } + } + }, + scales: { + y: { + beginAtZero: true, + title: { + display: true, + text: 'Speed (Mbps)', + color: textColor + }, + ticks: { + color: textColor, + maxTicksLimit: 8 + }, + grid: { + color: gridColor + } + }, + x: { + ticks: { + color: textColor, + maxRotation: 45 + }, + grid: { + color: gridColor + } + } + } + } + }); + } + + renderDetailedResults() { + const detailedResults = document.getElementById('detailedResults'); + const locations = [...new Set([ + ...Object.values(this.results.proxy).map(r => r.location), + ...Object.values(this.results.direct).map(r => r.location) + ])].filter(Boolean); + + detailedResults.innerHTML = locations.map(location => { + const proxyResults = Object.values(this.results.proxy).filter(r => r.location === location); + const directResult = this.results.direct[location]; + + return ` +
+

${location}

+
+ ${proxyResults.map(result => ` +
+
+ ${result.server_name} +
+ ${result.error ? ` +
Error: ${result.error}
+ ` : ` +
${result.speed_mbps} Mbps
+
+ ${(result.data_transferred / 1024 / 1024).toFixed(2)} MB in ${result.duration}s +
+ `} +
+ `).join('')} + + ${directResult ? ` +
+
+ Direct +
+ ${directResult.error ? ` +
Error: ${directResult.error}
+ ` : ` +
${directResult.speed_mbps} Mbps
+
+ ${(directResult.data_transferred / 1024 / 1024).toFixed(2)} MB in ${directResult.duration}s +
+ `} +
+ ` : '
Direct test not performed
'} +
+
+ `; + }).join(''); + } +} + + +// Initialize the speed test when the page loads +let speedTest; +document.addEventListener('DOMContentLoaded', () => { + speedTest = new MediaFlowSpeedTest(); +}); \ No newline at end of file diff --git a/mediaflow_proxy/utils/__pycache__/__init__.cpython-313.pyc b/mediaflow_proxy/utils/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..148e6c7 Binary files /dev/null and b/mediaflow_proxy/utils/__pycache__/__init__.cpython-313.pyc differ diff --git a/mediaflow_proxy/utils/__pycache__/cache_utils.cpython-313.pyc b/mediaflow_proxy/utils/__pycache__/cache_utils.cpython-313.pyc new file mode 100644 index 0000000..c0fa4fa Binary files /dev/null and b/mediaflow_proxy/utils/__pycache__/cache_utils.cpython-313.pyc differ diff --git a/mediaflow_proxy/utils/__pycache__/crypto_utils.cpython-313.pyc b/mediaflow_proxy/utils/__pycache__/crypto_utils.cpython-313.pyc new file mode 100644 index 0000000..03997f1 Binary files /dev/null and b/mediaflow_proxy/utils/__pycache__/crypto_utils.cpython-313.pyc differ diff --git a/mediaflow_proxy/utils/__pycache__/http_utils.cpython-313.pyc b/mediaflow_proxy/utils/__pycache__/http_utils.cpython-313.pyc new file mode 100644 index 0000000..950f408 Binary files /dev/null and b/mediaflow_proxy/utils/__pycache__/http_utils.cpython-313.pyc differ diff --git a/mediaflow_proxy/utils/__pycache__/m3u8_processor.cpython-313.pyc b/mediaflow_proxy/utils/__pycache__/m3u8_processor.cpython-313.pyc new file mode 100644 index 0000000..d166589 Binary files /dev/null and b/mediaflow_proxy/utils/__pycache__/m3u8_processor.cpython-313.pyc differ diff --git a/mediaflow_proxy/utils/__pycache__/mpd_utils.cpython-313.pyc b/mediaflow_proxy/utils/__pycache__/mpd_utils.cpython-313.pyc new file mode 100644 index 0000000..5015543 Binary files /dev/null and b/mediaflow_proxy/utils/__pycache__/mpd_utils.cpython-313.pyc differ diff --git a/mediaflow_proxy/utils/cache_utils.py b/mediaflow_proxy/utils/cache_utils.py index a94cf1b..f445e4a 100644 --- a/mediaflow_proxy/utils/cache_utils.py +++ b/mediaflow_proxy/utils/cache_utils.py @@ -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) diff --git a/mediaflow_proxy/utils/http_utils.py b/mediaflow_proxy/utils/http_utils.py index 76cfe00..cb23d21 100644 --- a/mediaflow_proxy/utils/http_utils.py +++ b/mediaflow_proxy/utils/http_utils.py @@ -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=&h=&r=/ + + 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: diff --git a/mediaflow_proxy/utils/m3u8_processor.py b/mediaflow_proxy/utils/m3u8_processor.py index 1c6e743..b71fe51 100644 --- a/mediaflow_proxy/utils/m3u8_processor.py +++ b/mediaflow_proxy/utils/m3u8_processor.py @@ -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 diff --git a/mediaflow_proxy/utils/mpd_utils.py b/mediaflow_proxy/utils/mpd_utils.py index 7e14e26..28cb3c3 100644 --- a/mediaflow_proxy/utils/mpd_utils.py +++ b/mediaflow_proxy/utils/mpd_utils.py @@ -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( {