Compare commits

...

2 Commits

Author SHA1 Message Date
UrloMythus
9431837e6c New version 2025-06-10 22:43:36 +02:00
UrloMythus
1b1458e7f3 New version 2025-06-10 22:42:56 +02:00
22 changed files with 1843 additions and 847 deletions

View File

@@ -1,4 +1,4 @@
from typing import Dict, Optional, Union from typing import Dict, Literal, Optional, Union
import httpx import httpx
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@@ -59,6 +59,10 @@ class Settings(BaseSettings):
disable_home_page: bool = False # Whether to disable the home page UI. disable_home_page: bool = False # Whether to disable the home page UI.
disable_docs: bool = False # Whether to disable the API documentation (Swagger UI). disable_docs: bool = False # Whether to disable the API documentation (Swagger UI).
disable_speedtest: bool = False # Whether to disable the speedtest UI. disable_speedtest: bool = False # Whether to disable the speedtest UI.
stremio_proxy_url: str | None = None # The Stremio server URL for alternative content proxying.
m3u8_content_routing: Literal["mediaflow", "stremio", "direct"] = (
"mediaflow" # Routing strategy for M3U8 content URLs: "mediaflow", "stremio", or "direct"
)
user_agent: str = ( user_agent: str = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36" # The user agent to use for HTTP requests. "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36" # The user agent to use for HTTP requests.

View File

@@ -43,7 +43,7 @@ class DLHDExtractor(BaseExtractor):
channel_headers = { channel_headers = {
"referer": player_origin + "/", "referer": player_origin + "/",
"origin": player_origin, "origin": player_origin,
"user-agent": self.base_headers["user-agent"] "user-agent": self.base_headers["user-agent"],
} }
channel_response = await self._make_request(channel_url, headers=channel_headers) channel_response = await self._make_request(channel_url, headers=channel_headers)
@@ -52,15 +52,21 @@ class DLHDExtractor(BaseExtractor):
if not player_url: if not player_url:
raise ExtractorError("Could not extract player URL from channel page") raise ExtractorError("Could not extract player URL from channel page")
# Check if this is a vecloud URL if not re.search(r"/stream/([a-zA-Z0-9-]+)", player_url):
if "vecloud" in player_url: iframe_player_url = await self._handle_playnow(player_url, player_origin)
player_origin = self._get_origin(player_url)
player_url = iframe_player_url
try:
return await self._handle_vecloud(player_url, player_origin + "/") return await self._handle_vecloud(player_url, player_origin + "/")
except Exception as e:
pass
# Get player page to extract authentication information # Get player page to extract authentication information
player_headers = { player_headers = {
"referer": player_origin + "/", "referer": player_origin + "/",
"origin": player_origin, "origin": player_origin,
"user-agent": self.base_headers["user-agent"] "user-agent": self.base_headers["user-agent"],
} }
player_response = await self._make_request(player_url, headers=player_headers) player_response = await self._make_request(player_url, headers=player_headers)
@@ -89,16 +95,18 @@ class DLHDExtractor(BaseExtractor):
raise ExtractorError("Could not determine auth URL base") raise ExtractorError("Could not determine auth URL base")
# Construct auth URL # Construct auth URL
auth_url = (f"{auth_url_base}/auth.php?channel_id={auth_data['channel_key']}" auth_url = (
f"&ts={auth_data['auth_ts']}&rnd={auth_data['auth_rnd']}" f"{auth_url_base}/auth.php?channel_id={auth_data['channel_key']}"
f"&sig={quote(auth_data['auth_sig'])}") f"&ts={auth_data['auth_ts']}&rnd={auth_data['auth_rnd']}"
f"&sig={quote(auth_data['auth_sig'])}"
)
# Make auth request # Make auth request
player_origin = self._get_origin(player_url) player_origin = self._get_origin(player_url)
auth_headers = { auth_headers = {
"referer": player_origin + "/", "referer": player_origin + "/",
"origin": player_origin, "origin": player_origin,
"user-agent": self.base_headers["user-agent"] "user-agent": self.base_headers["user-agent"],
} }
auth_response = await self._make_request(auth_url, headers=auth_headers) auth_response = await self._make_request(auth_url, headers=auth_headers)
@@ -113,14 +121,14 @@ class DLHDExtractor(BaseExtractor):
lookup_url_base=player_origin, lookup_url_base=player_origin,
auth_url_base=auth_url_base, auth_url_base=auth_url_base,
auth_data=auth_data, auth_data=auth_data,
headers=auth_headers headers=auth_headers,
) )
# Set up the final stream headers # Set up the final stream headers
stream_headers = { stream_headers = {
"referer": player_url, "referer": player_url,
"origin": player_origin, "origin": player_origin,
"user-agent": self.base_headers["user-agent"] "user-agent": self.base_headers["user-agent"],
} }
# Return the stream URL with headers # Return the stream URL with headers
@@ -144,12 +152,17 @@ class DLHDExtractor(BaseExtractor):
""" """
try: try:
# Extract stream ID from vecloud URL # Extract stream ID from vecloud URL
stream_id_match = re.search(r'/stream/([a-zA-Z0-9-]+)', player_url) stream_id_match = re.search(r"/stream/([a-zA-Z0-9-]+)", player_url)
if not stream_id_match: if not stream_id_match:
raise ExtractorError("Could not extract stream ID from vecloud URL") raise ExtractorError("Could not extract stream ID from vecloud URL")
stream_id = stream_id_match.group(1) stream_id = stream_id_match.group(1)
response = await self._make_request(
player_url, headers={"referer": channel_referer, "user-agent": self.base_headers["user-agent"]}
)
player_url = str(response.url)
# Construct API URL # Construct API URL
player_parsed = urlparse(player_url) player_parsed = urlparse(player_url)
player_domain = player_parsed.netloc player_domain = player_parsed.netloc
@@ -161,13 +174,10 @@ class DLHDExtractor(BaseExtractor):
"referer": player_url, "referer": player_url,
"origin": player_origin, "origin": player_origin,
"user-agent": self.base_headers["user-agent"], "user-agent": self.base_headers["user-agent"],
"content-type": "application/json" "content-type": "application/json",
} }
api_data = { api_data = {"r": channel_referer, "d": player_domain}
"r": channel_referer,
"d": player_domain
}
# Make API request # Make API request
api_response = await self._make_request(api_url, method="POST", headers=api_headers, json=api_data) api_response = await self._make_request(api_url, method="POST", headers=api_headers, json=api_data)
@@ -187,7 +197,7 @@ class DLHDExtractor(BaseExtractor):
stream_headers = { stream_headers = {
"referer": player_origin + "/", "referer": player_origin + "/",
"origin": player_origin, "origin": player_origin,
"user-agent": self.base_headers["user-agent"] "user-agent": self.base_headers["user-agent"],
} }
# Return the stream URL with headers # Return the stream URL with headers
@@ -200,14 +210,24 @@ class DLHDExtractor(BaseExtractor):
except Exception as e: except Exception as e:
raise ExtractorError(f"Vecloud extraction failed: {str(e)}") raise ExtractorError(f"Vecloud extraction failed: {str(e)}")
async def _handle_playnow(self, player_iframe: str, channel_origin: str) -> str:
"""Handle playnow URLs."""
# Set up headers for the playnow request
playnow_headers = {"referer": channel_origin + "/", "user-agent": self.base_headers["user-agent"]}
# Make the playnow request
playnow_response = await self._make_request(player_iframe, headers=playnow_headers)
player_url = self._extract_player_url(playnow_response.text)
if not player_url:
raise ExtractorError("Could not extract player URL from playnow response")
return player_url
def _extract_player_url(self, html_content: str) -> Optional[str]: def _extract_player_url(self, html_content: str) -> Optional[str]:
"""Extract player iframe URL from channel page HTML.""" """Extract player iframe URL from channel page HTML."""
try: try:
# Look for iframe with allowfullscreen attribute # Look for iframe with allowfullscreen attribute
iframe_match = re.search( iframe_match = re.search(
r'<iframe[^>]*src=["\']([^"\']+)["\'][^>]*allowfullscreen', r'<iframe[^>]*src=["\']([^"\']+)["\'][^>]*allowfullscreen', html_content, re.IGNORECASE
html_content,
re.IGNORECASE
) )
if not iframe_match: if not iframe_match:
@@ -215,7 +235,7 @@ class DLHDExtractor(BaseExtractor):
iframe_match = re.search( iframe_match = re.search(
r'<iframe[^>]*src=["\']([^"\']+(?:premiumtv|daddylivehd|vecloud)[^"\']*)["\']', r'<iframe[^>]*src=["\']([^"\']+(?:premiumtv|daddylivehd|vecloud)[^"\']*)["\']',
html_content, html_content,
re.IGNORECASE re.IGNORECASE,
) )
if iframe_match: if iframe_match:
@@ -225,17 +245,16 @@ class DLHDExtractor(BaseExtractor):
except Exception: except Exception:
return None return None
async def _lookup_server(self, lookup_url_base: str, auth_url_base: str, auth_data: Dict[str, str], headers: Dict[str, str]) -> str: async def _lookup_server(
self, lookup_url_base: str, auth_url_base: str, auth_data: Dict[str, str], headers: Dict[str, str]
) -> str:
"""Lookup server information and generate stream URL.""" """Lookup server information and generate stream URL."""
try: try:
# Construct server lookup URL # Construct server lookup URL
server_lookup_url = f"{lookup_url_base}/server_lookup.php?channel_id={quote(auth_data['channel_key'])}" server_lookup_url = f"{lookup_url_base}/server_lookup.php?channel_id={quote(auth_data['channel_key'])}"
# Make server lookup request # Make server lookup request
server_response = await self._make_request( server_response = await self._make_request(server_lookup_url, headers=headers)
server_lookup_url,
headers=headers
)
server_data = server_response.json() server_data = server_response.json()
server_key = server_data.get("server_key") server_key = server_data.get("server_key")
@@ -244,13 +263,13 @@ class DLHDExtractor(BaseExtractor):
raise ExtractorError("Failed to get server key") raise ExtractorError("Failed to get server key")
# Extract domain parts from auth URL for constructing stream URL # Extract domain parts from auth URL for constructing stream URL
auth_domain_parts = urlparse(auth_url_base).netloc.split('.') auth_domain_parts = urlparse(auth_url_base).netloc.split(".")
domain_suffix = '.'.join(auth_domain_parts[1:]) if len(auth_domain_parts) > 1 else auth_domain_parts[0] domain_suffix = ".".join(auth_domain_parts[1:]) if len(auth_domain_parts) > 1 else auth_domain_parts[0]
# Generate the m3u8 URL based on server response pattern # Generate the m3u8 URL based on server response pattern
if '/' in server_key: if "/" in server_key:
# Handle special case like "top1/cdn" # Handle special case like "top1/cdn"
parts = server_key.split('/') parts = server_key.split("/")
return f"https://{parts[0]}.{domain_suffix}/{server_key}/{auth_data['channel_key']}/mono.m3u8" return f"https://{parts[0]}.{domain_suffix}/{server_key}/{auth_data['channel_key']}/mono.m3u8"
else: else:
# Handle normal case # Handle normal case
@@ -278,7 +297,7 @@ class DLHDExtractor(BaseExtractor):
"channel_key": channel_key_match.group(1), "channel_key": channel_key_match.group(1),
"auth_ts": auth_ts_match.group(1), "auth_ts": auth_ts_match.group(1),
"auth_rnd": auth_rnd_match.group(1), "auth_rnd": auth_rnd_match.group(1),
"auth_sig": auth_sig_match.group(1) "auth_sig": auth_sig_match.group(1),
} }
except Exception: except Exception:
return {} return {}
@@ -287,21 +306,15 @@ class DLHDExtractor(BaseExtractor):
"""Extract auth URL base from player page script content.""" """Extract auth URL base from player page script content."""
try: try:
# Look for auth URL or domain in fetchWithRetry call or similar patterns # Look for auth URL or domain in fetchWithRetry call or similar patterns
auth_url_match = re.search( auth_url_match = re.search(r'fetchWithRetry\([\'"]([^\'"]*/auth\.php)', html_content)
r'fetchWithRetry\([\'"]([^\'"]*/auth\.php)',
html_content
)
if auth_url_match: if auth_url_match:
auth_url = auth_url_match.group(1) auth_url = auth_url_match.group(1)
# Extract base URL up to the auth.php part # Extract base URL up to the auth.php part
return auth_url.split('/auth.php')[0] return auth_url.split("/auth.php")[0]
# Try finding domain directly # Try finding domain directly
domain_match = re.search( domain_match = re.search(r'[\'"]https://([^/\'\"]+)(?:/[^\'\"]*)?/auth\.php', html_content)
r'[\'"]https://([^/\'\"]+)(?:/[^\'\"]*)?/auth\.php',
html_content
)
if domain_match: if domain_match:
return f"https://{domain_match.group(1)}" return f"https://{domain_match.group(1)}"
@@ -320,13 +333,13 @@ class DLHDExtractor(BaseExtractor):
try: try:
# Typical pattern is to use a subdomain for auth domain # Typical pattern is to use a subdomain for auth domain
parsed = urlparse(player_domain) parsed = urlparse(player_domain)
domain_parts = parsed.netloc.split('.') domain_parts = parsed.netloc.split(".")
# Get the top-level domain and second-level domain # Get the top-level domain and second-level domain
if len(domain_parts) >= 2: if len(domain_parts) >= 2:
base_domain = '.'.join(domain_parts[-2:]) base_domain = ".".join(domain_parts[-2:])
# Try common subdomains for auth # Try common subdomains for auth
for prefix in ['auth', 'api', 'cdn']: for prefix in ["auth", "api", "cdn"]:
potential_auth_domain = f"https://{prefix}.{base_domain}" potential_auth_domain = f"https://{prefix}.{base_domain}"
return potential_auth_domain return potential_auth_domain

View File

@@ -39,14 +39,15 @@ class VixCloudExtractor(BaseExtractor):
async def extract(self, url: str, **kwargs) -> Dict[str, Any]: async def extract(self, url: str, **kwargs) -> Dict[str, Any]:
"""Extract Vixcloud URL.""" """Extract Vixcloud URL."""
site_url = url.split("/iframe")[0] if "iframe" in url:
version = await self.version(site_url) site_url = url.split("/iframe")[0]
response = await self._make_request(url, headers={"x-inertia": "true", "x-inertia-version": version}) version = await self.version(site_url)
soup = BeautifulSoup(response.text, "lxml", parse_only=SoupStrainer("iframe")) response = await self._make_request(url, headers={"x-inertia": "true", "x-inertia-version": version})
iframe = soup.find("iframe").get("src") soup = BeautifulSoup(response.text, "lxml", parse_only=SoupStrainer("iframe"))
parsed_url = urlparse(iframe) iframe = soup.find("iframe").get("src")
query_params = parse_qs(parsed_url.query) response = await self._make_request(iframe, headers={"x-inertia": "true", "x-inertia-version": version})
response = await self._make_request(iframe, headers={"x-inertia": "true", "x-inertia-version": version}) elif "movie" in url or "tv" in url:
response = await self._make_request(url)
if response.status_code != 200: if response.status_code != 200:
raise ExtractorError("Failed to extract URL components, Invalid Request") raise ExtractorError("Failed to extract URL components, Invalid Request")
@@ -55,15 +56,15 @@ class VixCloudExtractor(BaseExtractor):
script = soup.find("body").find("script").text script = soup.find("body").find("script").text
token = re.search(r"'token':\s*'(\w+)'", script).group(1) token = re.search(r"'token':\s*'(\w+)'", script).group(1)
expires = re.search(r"'expires':\s*'(\d+)'", script).group(1) expires = re.search(r"'expires':\s*'(\d+)'", script).group(1)
vixid = iframe.split("/embed/")[1].split("?")[0] canPlayFHD = re.search(r"window\.canPlayFHD\s*=\s*(\w+)", script).group(1)
base_url = iframe.split("://")[1].split("/")[0] print(script,"A")
final_url = f"https://{base_url}/playlist/{vixid}.m3u8?token={token}&expires={expires}" server_url = re.search(r"url:\s*'([^']+)'", script).group(1)
if "canPlayFHD" in query_params: if "?b=1" in server_url:
# canPlayFHD = "h=1" final_url = f'{server_url}&token={token}&expires={expires}'
else:
final_url = f"{server_url}?token={token}&expires={expires}"
if "window.canPlayFHD = true" in script:
final_url += "&h=1" final_url += "&h=1"
if "b" in query_params:
# b = "b=1"
final_url += "&b=1"
self.base_headers["referer"] = url self.base_headers["referer"] = url
return { return {
"destination_url": final_url, "destination_url": final_url,

View File

@@ -23,4 +23,3 @@ class UIAccessControlMiddleware(BaseHTTPMiddleware):
return Response(status_code=403, content="Forbidden") return Response(status_code=403, content="Forbidden")
return await call_next(request) return await call_next(request)

View File

@@ -172,9 +172,29 @@ def build_hls_playlist(mpd_dict: dict, profiles: list[dict], request: Request) -
# Add headers for only the first profile # Add headers for only the first profile
if index == 0: if index == 0:
sequence = segments[0]["number"] first_segment = segments[0]
extinf_values = [f["extinf"] for f in segments if "extinf" in f] extinf_values = [f["extinf"] for f in segments if "extinf" in f]
target_duration = math.ceil(max(extinf_values)) if extinf_values else 3 target_duration = math.ceil(max(extinf_values)) if extinf_values else 3
# Calculate media sequence using adaptive logic for different MPD types
mpd_start_number = profile.get("segment_template_start_number")
if mpd_start_number and mpd_start_number >= 1000:
# Amazon-style: Use absolute segment numbering
sequence = first_segment.get("number", mpd_start_number)
else:
# Sky-style: Use time-based calculation if available
time_val = first_segment.get("time")
duration_val = first_segment.get("duration_mpd_timescale")
if time_val is not None and duration_val and duration_val > 0:
calculated_sequence = math.floor(time_val / duration_val)
# For live streams with very large sequence numbers, use modulo to keep reasonable range
if mpd_dict.get("isLive", False) and calculated_sequence > 100000:
sequence = calculated_sequence % 100000
else:
sequence = calculated_sequence
else:
sequence = first_segment.get("number", 1)
hls.extend( hls.extend(
[ [
f"#EXT-X-TARGETDURATION:{target_duration}", f"#EXT-X-TARGETDURATION:{target_duration}",

View File

@@ -1,9 +1,11 @@
import uuid from fastapi import APIRouter, HTTPException
from fastapi import APIRouter, BackgroundTasks, HTTPException, Request
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse
from mediaflow_proxy.speedtest.service import SpeedTestService, SpeedTestProvider from mediaflow_proxy.speedtest.models import (
BrowserSpeedTestConfig,
BrowserSpeedTestRequest,
)
from mediaflow_proxy.speedtest.service import SpeedTestService
speedtest_router = APIRouter() speedtest_router = APIRouter()
@@ -11,33 +13,29 @@ speedtest_router = APIRouter()
speedtest_service = SpeedTestService() speedtest_service = SpeedTestService()
@speedtest_router.get("/", summary="Show speed test interface") @speedtest_router.get("/", summary="Show browser speed test interface")
async def show_speedtest_page(): async def show_speedtest_page():
"""Return the speed test HTML interface.""" """Return the browser-based speed test HTML interface."""
return RedirectResponse(url="/speedtest.html") return RedirectResponse(url="/speedtest.html")
@speedtest_router.post("/start", summary="Start a new speed test", response_model=dict) @speedtest_router.post("/config", summary="Get browser speed test configuration")
async def start_speedtest(background_tasks: BackgroundTasks, provider: SpeedTestProvider, request: Request): async def get_browser_speedtest_config(
"""Start a new speed test for the specified provider.""" test_request: BrowserSpeedTestRequest,
task_id = str(uuid.uuid4()) ) -> BrowserSpeedTestConfig:
api_key = request.headers.get("api_key") """Get configuration for browser-based speed test."""
try:
provider_impl = speedtest_service.get_provider(test_request.provider, test_request.api_key)
# Create and initialize the task # Get test URLs and user info
await speedtest_service.create_test(task_id, provider, api_key) test_urls, user_info = await provider_impl.get_test_urls()
config = await provider_impl.get_config()
# Schedule the speed test return BrowserSpeedTestConfig(
background_tasks.add_task(speedtest_service.run_speedtest, task_id, provider, api_key) provider=test_request.provider,
test_urls=test_urls,
return {"task_id": task_id} test_duration=config.test_duration,
user_info=user_info,
)
@speedtest_router.get("/results/{task_id}", summary="Get speed test results") except Exception as e:
async def get_speedtest_results(task_id: str): raise HTTPException(status_code=400, detail=str(e))
"""Get the results or current status of a speed test."""
task = await speedtest_service.get_test_results(task_id)
if not task:
raise HTTPException(status_code=404, detail="Speed test task not found or expired")
return task.dict()

View File

@@ -98,7 +98,6 @@ class ExtractorURLParams(GenericParams):
description="Additional parameters required for specific extractors (e.g., stream_title for LiveTV)", description="Additional parameters required for specific extractors (e.g., stream_title for LiveTV)",
) )
@field_validator("extra_params", mode="before") @field_validator("extra_params", mode="before")
def validate_extra_params(cls, value: Any): def validate_extra_params(cls, value: Any):
if isinstance(value, str): if isinstance(value, str):

View File

@@ -1,8 +1,7 @@
from datetime import datetime
from enum import Enum from enum import Enum
from typing import Dict, Optional from typing import Dict, Optional
from pydantic import BaseModel, Field from pydantic import BaseModel, HttpUrl
class SpeedTestProvider(str, Enum): class SpeedTestProvider(str, Enum):
@@ -21,26 +20,20 @@ class UserInfo(BaseModel):
country: Optional[str] = None country: Optional[str] = None
class SpeedTestResult(BaseModel): class MediaFlowServer(BaseModel):
speed_mbps: float = Field(..., description="Speed in Mbps") url: HttpUrl
duration: float = Field(..., description="Test duration in seconds") api_password: Optional[str] = None
data_transferred: int = Field(..., description="Data transferred in bytes") name: Optional[str] = None
timestamp: datetime = Field(default_factory=datetime.utcnow)
class LocationResult(BaseModel): class BrowserSpeedTestConfig(BaseModel):
result: Optional[SpeedTestResult] = None
error: Optional[str] = None
server_name: str
server_url: str
class SpeedTestTask(BaseModel):
task_id: str
provider: SpeedTestProvider provider: SpeedTestProvider
results: Dict[str, LocationResult] = {} test_urls: Dict[str, str]
started_at: datetime test_duration: int = 10
completed_at: Optional[datetime] = None
status: str = "running"
user_info: Optional[UserInfo] = None user_info: Optional[UserInfo] = None
current_location: Optional[str] = None
class BrowserSpeedTestRequest(BaseModel):
provider: SpeedTestProvider
api_key: Optional[str] = None
current_api_password: Optional[str] = None

View File

@@ -1,20 +1,13 @@
import logging
import time
from datetime import datetime, timezone
from typing import Dict, Optional, Type from typing import Dict, Optional, Type
from mediaflow_proxy.utils.cache_utils import get_cached_speedtest, set_cache_speedtest from .models import SpeedTestProvider
from mediaflow_proxy.utils.http_utils import Streamer, create_httpx_client
from .models import SpeedTestTask, LocationResult, SpeedTestResult, SpeedTestProvider
from .providers.all_debrid import AllDebridSpeedTest from .providers.all_debrid import AllDebridSpeedTest
from .providers.base import BaseSpeedTestProvider from .providers.base import BaseSpeedTestProvider
from .providers.real_debrid import RealDebridSpeedTest from .providers.real_debrid import RealDebridSpeedTest
logger = logging.getLogger(__name__)
class SpeedTestService: class SpeedTestService:
"""Service for managing speed tests across different providers.""" """Service for managing speed test provider configurations."""
def __init__(self): def __init__(self):
# Provider mapping # Provider mapping
@@ -23,7 +16,7 @@ class SpeedTestService:
SpeedTestProvider.ALL_DEBRID: AllDebridSpeedTest, SpeedTestProvider.ALL_DEBRID: AllDebridSpeedTest,
} }
def _get_provider(self, provider: SpeedTestProvider, api_key: Optional[str] = None) -> BaseSpeedTestProvider: def get_provider(self, provider: SpeedTestProvider, api_key: Optional[str] = None) -> BaseSpeedTestProvider:
"""Get the appropriate provider implementation.""" """Get the appropriate provider implementation."""
provider_class = self._providers.get(provider) provider_class = self._providers.get(provider)
if not provider_class: if not provider_class:
@@ -33,97 +26,3 @@ class SpeedTestService:
raise ValueError("API key required for AllDebrid") raise ValueError("API key required for AllDebrid")
return provider_class(api_key) if provider == SpeedTestProvider.ALL_DEBRID else provider_class() return provider_class(api_key) if provider == SpeedTestProvider.ALL_DEBRID else provider_class()
async def create_test(
self, task_id: str, provider: SpeedTestProvider, api_key: Optional[str] = None
) -> SpeedTestTask:
"""Create a new speed test task."""
provider_impl = self._get_provider(provider, api_key)
# Get initial URLs and user info
urls, user_info = await provider_impl.get_test_urls()
task = SpeedTestTask(
task_id=task_id, provider=provider, started_at=datetime.now(tz=timezone.utc), user_info=user_info
)
await set_cache_speedtest(task_id, task)
return task
@staticmethod
async def get_test_results(task_id: str) -> Optional[SpeedTestTask]:
"""Get results for a specific task."""
return await get_cached_speedtest(task_id)
async def run_speedtest(self, task_id: str, provider: SpeedTestProvider, api_key: Optional[str] = None):
"""Run the speed test with real-time updates."""
try:
task = await get_cached_speedtest(task_id)
if not task:
raise ValueError(f"Task {task_id} not found")
provider_impl = self._get_provider(provider, api_key)
config = await provider_impl.get_config()
async with create_httpx_client() as client:
streamer = Streamer(client)
for location, url in config.test_urls.items():
try:
task.current_location = location
await set_cache_speedtest(task_id, task)
result = await self._test_location(location, url, streamer, config.test_duration, provider_impl)
task.results[location] = result
await set_cache_speedtest(task_id, task)
except Exception as e:
logger.error(f"Error testing {location}: {str(e)}")
task.results[location] = LocationResult(
error=str(e), server_name=location, server_url=config.test_urls[location]
)
await set_cache_speedtest(task_id, task)
# Mark task as completed
task.completed_at = datetime.now(tz=timezone.utc)
task.status = "completed"
task.current_location = None
await set_cache_speedtest(task_id, task)
except Exception as e:
logger.error(f"Error in speed test task {task_id}: {str(e)}")
if task := await get_cached_speedtest(task_id):
task.status = "failed"
await set_cache_speedtest(task_id, task)
async def _test_location(
self, location: str, url: str, streamer: Streamer, test_duration: int, provider: BaseSpeedTestProvider
) -> LocationResult:
"""Test speed for a specific location."""
try:
start_time = time.time()
total_bytes = 0
await streamer.create_streaming_response(url, headers={})
async for chunk in streamer.stream_content():
if time.time() - start_time >= test_duration:
break
total_bytes += len(chunk)
duration = time.time() - start_time
speed_mbps = (total_bytes * 8) / (duration * 1_000_000)
# Get server info if available (for AllDebrid)
server_info = getattr(provider, "servers", {}).get(location)
server_url = server_info.url if server_info else url
return LocationResult(
result=SpeedTestResult(
speed_mbps=round(speed_mbps, 2), duration=round(duration, 2), data_transferred=total_bytes
),
server_name=location,
server_url=server_url,
)
except Exception as e:
logger.error(f"Error testing {location}: {str(e)}")
raise # Re-raise to be handled by run_speedtest

View File

@@ -43,6 +43,44 @@
margin-bottom: 10px; margin-bottom: 10px;
} }
.speed-test-section {
background-color: #e8f4fd;
border-left: 4px solid #2196f3;
padding: 15px;
margin: 20px 0;
border-radius: 5px;
}
.speed-test-links {
display: flex;
gap: 15px;
margin-top: 10px;
flex-wrap: wrap;
}
.speed-test-link {
display: inline-block;
padding: 10px 20px;
background-color: #2196f3;
color: white;
text-decoration: none;
border-radius: 5px;
transition: background-color 0.3s;
}
.speed-test-link:hover {
background-color: #1976d2;
color: white;
}
.speed-test-link.browser {
background-color: #4caf50;
}
.speed-test-link.browser:hover {
background-color: #388e3c;
}
a { a {
color: #3498db; color: #3498db;
} }
@@ -63,6 +101,17 @@
<div class="feature">Proxy and modify HLS (M3U8) streams in real-time with custom headers and key URL modifications for bypassing some sneaky restrictions.</div> <div class="feature">Proxy and modify HLS (M3U8) streams in real-time with custom headers and key URL modifications for bypassing some sneaky restrictions.</div>
<div class="feature">Protect against unauthorized access and network bandwidth abuses</div> <div class="feature">Protect against unauthorized access and network bandwidth abuses</div>
<div class="speed-test-section">
<h3>🚀 Speed Test Tool</h3>
<p>Test your connection speed with debrid services to optimize your streaming experience:</p>
<div class="speed-test-links">
<a href="/speedtest" class="speed-test-link browser">Browser Speed Test</a>
</div>
<p style="margin-top: 10px; font-size: 14px; color: #666;">
<strong>Browser Speed Test:</strong> Tests your actual connection speed through MediaFlow proxy vs direct connection with support for multiple servers, interactive charts, and comprehensive analytics.
</p>
</div>
<h2>Getting Started</h2> <h2>Getting Started</h2>
<p>Visit the <a href="https://github.com/mhdzumair/mediaflow-proxy">GitHub repository</a> for installation instructions and documentation.</p> <p>Visit the <a href="https://github.com/mhdzumair/mediaflow-proxy">GitHub repository</a> for installation instructions and documentation.</p>

View File

@@ -3,38 +3,39 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Debrid Speed Test</title> <title>MediaFlow Speed Test</title>
<script src="https://cdn.tailwindcss.com"></script> <script src="https://cdn.tailwindcss.com"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script src="/speedtest.js"></script>
<script> <script>
tailwind.config = { tailwind.config = {
darkMode: 'class', darkMode: 'class',
theme: { theme: {
extend: { extend: {
animation: { animation: {
'progress': 'progress 180s linear forwards', 'pulse-slow': 'pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite',
}, 'bounce-slow': 'bounce 2s infinite',
keyframes: {
progress: {
'0%': {width: '0%'},
'100%': {width: '100%'}
}
} }
} }
} }
} }
</script> </script>
<style> <style>
.provider-card { .result-card {
transition: all 0.3s ease; transition: all 0.3s ease;
} }
.provider-card:hover { .result-card:hover {
transform: translateY(-5px); transform: translateY(-2px);
}
.server-input {
animation: slideIn 0.3s ease-out;
} }
@keyframes slideIn { @keyframes slideIn {
from { from {
transform: translateY(20px); transform: translateY(-10px);
opacity: 0; opacity: 0;
} }
to { to {
@@ -43,8 +44,21 @@
} }
} }
.slide-in { .metric-card {
animation: slideIn 0.3s ease-out forwards; background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
}
.metric-card.success {
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
}
.metric-card.warning {
background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);
}
.metric-card.error {
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
} }
</style> </style>
</head> </head>
@@ -63,634 +77,301 @@
</div> </div>
<main class="container mx-auto px-4 py-8"> <main class="container mx-auto px-4 py-8">
<!-- Views Container --> <!-- Header -->
<div id="views-container"> <div class="text-center mb-8">
<!-- API Password View --> <h1 class="text-4xl font-bold text-gray-800 dark:text-white mb-2">
<div id="passwordView" class="space-y-8"> 🚀 MediaFlow Speed Test
<h1 class="text-3xl font-bold text-center text-gray-800 dark:text-white mb-8"> </h1>
Enter API Password <p class="text-gray-600 dark:text-gray-300">
</h1> Compare your connection speed through MediaFlow proxy vs direct connection
</p>
</div>
<div class="max-w-md mx-auto"> <!-- Configuration View -->
<form id="passwordForm" class="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6 space-y-4"> <div id="configView" class="max-w-4xl mx-auto space-y-6">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6">
<h2 class="text-2xl font-semibold text-gray-800 dark:text-white mb-6">Test Configuration</h2>
<form id="configForm" class="space-y-6">
<!-- Provider Selection -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="space-y-2"> <div class="space-y-2">
<label for="apiPassword" class="block text-sm font-medium text-gray-700 dark:text-gray-300"> <label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
API Password Debrid Provider
</label> </label>
<input <select id="provider"
type="password" class="w-full px-4 py-2 rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500">
id="apiPassword" <option value="real_debrid">Real-Debrid</option>
class="w-full px-4 py-2 rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500" <option value="all_debrid">AllDebrid</option>
required </select>
>
</div> </div>
<div class="flex items-center space-x-2">
<input
type="checkbox"
id="rememberPassword"
class="rounded border-gray-300 dark:border-gray-600"
>
<label for="rememberPassword" class="text-sm text-gray-600 dark:text-gray-400">
Remember password
</label>
</div>
<button
type="submit"
class="w-full px-4 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-colors"
>
Continue
</button>
</form>
</div>
</div>
<!-- Provider Selection View --> <!-- API Key (for AllDebrid) -->
<div id="selectionView" class="space-y-8 hidden"> <div id="apiKeySection" class="space-y-2 hidden">
<h1 class="text-3xl font-bold text-center text-gray-800 dark:text-white mb-8"> <label for="apiKey" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
Select Debrid Service for Speed Test
</h1>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 max-w-4xl mx-auto">
<!-- Real-Debrid Card -->
<button onclick="startTest('real_debrid')" class="provider-card bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6 text-left hover:shadow-xl transition-shadow">
<h2 class="text-xl font-semibold text-gray-800 dark:text-white mb-2">Real-Debrid</h2>
<p class="text-gray-600 dark:text-gray-300">Test speeds across multiple Real-Debrid servers worldwide</p>
</button>
<!-- AllDebrid Card -->
<button onclick="showAllDebridSetup()" class="provider-card bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6 text-left hover:shadow-xl transition-shadow">
<h2 class="text-xl font-semibold text-gray-800 dark:text-white mb-2">AllDebrid</h2>
<p class="text-gray-600 dark:text-gray-300">Measure download speeds from AllDebrid servers</p>
</button>
</div>
</div>
<!-- AllDebrid Setup View -->
<div id="allDebridSetupView" class="max-w-md mx-auto space-y-6 hidden">
<h2 class="text-2xl font-bold text-center text-gray-800 dark:text-white mb-8">
AllDebrid Setup
</h2>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6">
<form id="allDebridForm" class="space-y-4">
<div class="space-y-2">
<label for="adApiKey" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
AllDebrid API Key AllDebrid API Key
</label> </label>
<input <input
type="password" type="password"
id="adApiKey" id="apiKey"
class="w-full px-4 py-2 rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500" class="w-full px-4 py-2 rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
required placeholder="Enter your AllDebrid API key"
> >
<p class="text-sm text-gray-500 dark:text-gray-400">
You can find your API key in the AllDebrid dashboard
</p>
</div> </div>
<div class="flex items-center space-x-2"> </div>
<input
type="checkbox" <!-- Current Instance API Password -->
id="rememberAdKey" <div class="space-y-2">
class="rounded border-gray-300 dark:border-gray-600" <label for="currentApiPassword" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
> Current MediaFlow API Password (if required)
<label for="rememberAdKey" class="text-sm text-gray-600 dark:text-gray-400"> </label>
Remember API key <input
type="password"
id="currentApiPassword"
class="w-full px-4 py-2 rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Enter API password for current instance"
>
<p class="text-sm text-gray-500 dark:text-gray-400">
Required to fetch test configuration if this instance has API password protection
</p>
</div>
<!-- MediaFlow Servers -->
<div class="space-y-4">
<div class="flex justify-between items-center">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
MediaFlow Servers to Test
</label> </label>
</div>
<div class="flex space-x-3">
<button <button
type="button" type="button"
onclick="showView('selectionView')" id="addServerBtn"
class="flex-1 px-4 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-md hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors" class="px-3 py-1 bg-blue-500 text-white rounded-md hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm"
> >
Back + Add Server
</button>
<button
type="submit"
class="flex-1 px-4 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-colors"
>
Start Test
</button> </button>
</div> </div>
</form>
</div>
</div>
<!-- Testing View --> <div id="serversContainer" class="space-y-3">
<div id="testingView" class="max-w-4xl mx-auto space-y-6 hidden"> <!-- Current server (auto-added) -->
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6"> <div class="server-input grid grid-cols-1 md:grid-cols-3 gap-3 p-3 bg-gray-50 dark:bg-gray-700 rounded-lg">
<!-- User Info Section --> <input
<div id="userInfo" class="mb-6 hidden"> type="url"
<!-- User info will be populated dynamically --> placeholder="MediaFlow URL"
class="server-url w-full px-3 py-2 rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm"
required
>
<input
type="text"
placeholder="Server Name (optional)"
class="server-name w-full px-3 py-2 rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm"
>
<div class="flex gap-2">
<input
type="password"
placeholder="API Password (optional)"
class="server-password flex-1 px-3 py-2 rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm"
>
<button
type="button"
class="remove-server px-2 py-2 bg-red-500 text-white rounded-md hover:bg-red-600 focus:outline-none text-sm hidden"
>
×
</button>
</div>
</div>
</div>
</div> </div>
<!-- Progress Section --> <!-- CDN Location Selection -->
<div class="space-y-4"> <div class="space-y-4">
<div class="text-center text-gray-600 dark:text-gray-300" id="currentLocation"> <h3 class="text-lg font-medium text-gray-800 dark:text-white">CDN Locations</h3>
Initializing test...
<!-- CDN Status and Selection Container -->
<div id="cdnStatusContainer">
<!-- Status message will be populated here -->
</div> </div>
<div class="h-2 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
<div class="h-full bg-blue-500 animate-progress" id="progressBar"></div> <div id="cdnSelection" class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">
<!-- CDN checkboxes will be populated here -->
</div>
<div class="flex flex-wrap gap-2">
<button type="button" id="refreshCdnBtn"
class="px-4 py-2 bg-gradient-to-r from-green-500 to-emerald-600 text-white rounded-lg hover:from-green-600 hover:to-emerald-700 focus:outline-none focus:ring-2 focus:ring-green-500 transition-all duration-200 font-medium shadow-md">
🔄 Refresh CDNs
</button>
<button type="button" id="selectAllCdn"
class="px-3 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors text-sm font-medium">Select
All
</button>
<button type="button" id="selectNoneCdn"
class="px-3 py-2 bg-gray-500 text-white rounded-lg hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-gray-500 transition-colors text-sm font-medium">Select
None
</button>
</div> </div>
</div> </div>
<!-- Results Container --> <!-- Test Options -->
<div id="resultsContainer" class="mt-8"> <div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Results will be populated dynamically --> <div class="space-y-4">
</div> <h3 class="text-lg font-medium text-gray-800 dark:text-white">Test Options</h3>
</div> <div class="space-y-2">
</div> <div class="flex items-center space-x-2">
<input type="checkbox" id="testProxy" checked class="rounded border-gray-300 dark:border-gray-600">
<!-- Results View --> <label for="testProxy" class="text-sm text-gray-700 dark:text-gray-300">
<div id="resultsView" class="max-w-4xl mx-auto space-y-6 hidden"> Test through MediaFlow proxy
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6"> </label>
<div class="space-y-6">
<!-- Summary Section -->
<div class="border-b border-gray-200 dark:border-gray-700 pb-4">
<h3 class="text-lg font-semibold text-gray-800 dark:text-white mb-4">Test Summary</h3>
<div class="grid grid-cols-2 md:grid-cols-3 gap-4">
<div class="space-y-1">
<div class="text-sm text-gray-500 dark:text-gray-400">Fastest Server</div>
<div id="fastestServer" class="font-medium text-gray-900 dark:text-white"></div>
</div> </div>
<div class="space-y-1"> <div class="flex items-center space-x-2">
<div class="text-sm text-gray-500 dark:text-gray-400">Top Speed</div> <input type="checkbox" id="testDirect" checked class="rounded border-gray-300 dark:border-gray-600">
<div id="topSpeed" class="font-medium text-green-500"></div> <label for="testDirect" class="text-sm text-gray-700 dark:text-gray-300">
</div> Test direct connection (for comparison)
<div class="space-y-1"> </label>
<div class="text-sm text-gray-500 dark:text-gray-400">Average Speed</div>
<div id="avgSpeed" class="font-medium text-blue-500"></div>
</div> </div>
</div> </div>
</div> </div>
<!-- Detailed Results --> <div class="space-y-4">
<div id="finalResults" class="space-y-4"> <h3 class="text-lg font-medium text-gray-800 dark:text-white">Advanced Settings</h3>
<!-- Results will be populated here --> <div class="space-y-2">
<label for="testDuration" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
Test Duration (seconds)
</label>
<input
type="number"
id="testDuration"
value="10"
min="5"
max="30"
class="w-full px-3 py-2 rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
>
</div>
</div> </div>
</div> </div>
</div>
<div class="text-center mt-6"> <button
<button onclick="resetTest()" class="px-6 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors"> type="submit"
Test Another Provider class="w-full px-6 py-3 bg-gradient-to-r from-blue-500 to-purple-600 text-white rounded-md hover:from-blue-600 hover:to-purple-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-all duration-200 font-medium"
>
🚀 Start Speed Test
</button>
</form>
</div>
</div>
<!-- Testing View -->
<div id="testingView" class="max-w-6xl mx-auto space-y-6 hidden">
<!-- Progress Section -->
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6">
<div class="text-center space-y-4">
<h2 class="text-2xl font-semibold text-gray-800 dark:text-white">Running Speed Tests</h2>
<div id="currentTest" class="text-gray-600 dark:text-gray-300">
Initializing...
</div>
<div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-3">
<div id="progressBar" class="bg-gradient-to-r from-blue-500 to-purple-600 h-3 rounded-full transition-all duration-300" style="width: 0%"></div>
</div>
<div id="progressText" class="text-sm text-gray-500 dark:text-gray-400">
0% complete
</div>
<button
id="cancelTestBtn"
class="mt-4 px-6 py-2 bg-red-500 text-white rounded-md hover:bg-red-600 focus:outline-none focus:ring-2 focus:ring-red-500 transition-colors"
>
⏹️ Cancel Test
</button> </button>
</div> </div>
</div> </div>
<!-- Error View --> <!-- Live Results -->
<div id="errorView" class="max-w-4xl mx-auto space-y-6 hidden"> <div id="liveResults" class="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-4">
<div class="bg-red-50 dark:bg-red-900/50 border-l-4 border-red-500 p-4 rounded"> <!-- Results will be populated here -->
<div class="flex"> </div>
<div class="flex-shrink-0"> </div>
<svg class="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
clip-rule="evenodd"/>
</svg>
</div>
<div class="ml-3">
<p class="text-sm text-red-700 dark:text-red-200" id="errorMessage"></p>
</div>
</div>
</div>
<div class="text-center"> <!-- Results View -->
<button onclick="resetTest()" <div id="resultsView" class="max-w-7xl mx-auto space-y-6 hidden">
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 dark:hover:bg-blue-500 transition-colors duration-200"> <!-- Key Metrics -->
Try Again <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
</button> <div id="bestProxyMetric" class="metric-card text-white p-4 rounded-lg text-center">
<div class="text-2xl font-bold" id="bestProxySpeed">-- Mbps</div>
<div class="text-sm opacity-90">Best Proxy Speed</div>
<div class="text-xs opacity-75 mt-1" id="bestProxyServer">--</div>
</div>
<div id="bestDirectMetric" class="metric-card text-white p-4 rounded-lg text-center">
<div class="text-2xl font-bold" id="bestDirectSpeed">-- Mbps</div>
<div class="text-sm opacity-90">Best Direct Speed</div>
<div class="text-xs opacity-75 mt-1" id="bestDirectLocation">--</div>
</div>
<div id="avgProxyMetric" class="metric-card text-white p-4 rounded-lg text-center">
<div class="text-2xl font-bold" id="avgProxySpeed">-- Mbps</div>
<div class="text-sm opacity-90">Avg Proxy Speed</div>
<div class="text-xs opacity-75 mt-1" id="proxyTestCount">-- tests</div>
</div>
<div id="speedDiffMetric" class="metric-card text-white p-4 rounded-lg text-center">
<div class="text-2xl font-bold" id="speedDifference">--%</div>
<div class="text-sm opacity-90">Speed Difference</div>
<div class="text-xs opacity-75 mt-1">Proxy vs Direct</div>
</div> </div>
</div> </div>
<!-- Server Comparison Metrics -->
<div id="serverMetrics" class="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6 mb-6">
<h3 class="text-lg font-semibold text-gray-800 dark:text-white mb-4">MediaFlow Server Performance</h3>
<div id="serverComparisonGrid" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<!-- Server metrics will be populated here -->
</div>
</div>
<!-- Charts Section -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6">
<h3 class="text-lg font-semibold text-gray-800 dark:text-white mb-4">Speed Comparison by Location</h3>
<div class="relative h-64 w-full">
<canvas id="speedChart"></canvas>
</div>
</div>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6">
<h3 class="text-lg font-semibold text-gray-800 dark:text-white mb-4">Server Performance Overview</h3>
<div class="relative h-64 w-full">
<canvas id="serverChart"></canvas>
</div>
</div>
</div>
<!-- Detailed Results -->
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6">
<h2 class="text-2xl font-semibold text-gray-800 dark:text-white mb-4">Detailed Results</h2>
<div id="detailedResults" class="space-y-4">
<!-- Detailed results will be populated here -->
</div>
</div>
<!-- Actions -->
<div class="text-center">
<button
id="runAgainBtn"
class="px-6 py-3 bg-gradient-to-r from-green-500 to-blue-600 text-white rounded-md hover:from-green-600 hover:to-blue-700 focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2 transition-all duration-200 font-medium"
>
🔄 Run Another Test
</button>
</div>
</div> </div>
</main> </main>
<script> <script>
// Config and State // Theme management
const STATE = { const themeToggle = document.getElementById('themeToggle');
apiPassword: localStorage.getItem('speedtest_api_password'), const html = document.documentElement;
adApiKey: localStorage.getItem('ad_api_key'),
currentTaskId: null,
resultsCount: 0,
};
// Theme handling // Check for saved theme preference or default to light mode
function setTheme(theme) { const savedTheme = localStorage.getItem('theme') || 'light';
document.documentElement.classList.toggle('dark', theme === 'dark'); html.classList.toggle('dark', savedTheme === 'dark');
localStorage.theme = theme;
}
function initTheme() { themeToggle.addEventListener('click', () => {
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; html.classList.toggle('dark');
setTheme(localStorage.theme || (prefersDark ? 'dark' : 'light')); const newTheme = html.classList.contains('dark') ? 'dark' : 'light';
} localStorage.setItem('theme', newTheme);
// View management
function showView(viewId) {
document.querySelectorAll('#views-container > div').forEach(view => {
view.classList.toggle('hidden', view.id !== viewId);
});
}
function createErrorResult(location, data) {
return `
<div class="py-4">
<div class="flex justify-between items-center">
<div>
<span class="font-medium text-gray-800 dark:text-white">${location}</span>
<span class="ml-2 text-sm text-gray-500 dark:text-gray-400">${data.server_name || ''}</span>
</div>
<span class="text-sm text-red-500 dark:text-red-400">
Failed
</span>
</div>
<div class="mt-1 text-sm text-red-400 dark:text-red-300">
${data.error || 'Test failed'}
</div>
<div class="mt-1 text-xs text-gray-400 dark:text-gray-500">
Server: ${data.server_url}
</div>
</div>
`;
}
function formatBytes(bytes) {
const units = ['B', 'KB', 'MB', 'GB'];
let value = bytes;
let unitIndex = 0;
while (value >= 1024 && unitIndex < units.length - 1) {
value /= 1024;
unitIndex++;
}
return `${value.toFixed(2)} ${units[unitIndex]}`;
}
function handleAuthError() {
localStorage.removeItem('speedtest_api_password');
STATE.apiPassword = null;
showError('Authentication failed. Please check your API password.');
}
function showError(message) {
document.getElementById('errorMessage').textContent = message;
showView('errorView');
}
function resetTest() {
window.location.reload();
}
function showAllDebridSetup() {
showView('allDebridSetupView');
}
async function startTest(provider) {
if (provider === 'all_debrid' && !STATE.adApiKey) {
showAllDebridSetup();
return;
}
showView('testingView');
initializeResultsContainer();
try {
const params = new URLSearchParams({provider});
const headers = {'api_password': STATE.apiPassword};
if (provider === 'all_debrid' && STATE.adApiKey) {
headers['api_key'] = STATE.adApiKey;
}
const response = await fetch(`/speedtest/start?${params}`, {
method: 'POST',
headers
});
if (!response.ok) {
if (response.status === 403) {
handleAuthError();
return;
}
throw new Error('Failed to start speed test');
}
const {task_id} = await response.json();
STATE.currentTaskId = task_id;
await pollResults(task_id);
} catch (error) {
showError(error.message);
}
}
function initializeResultsContainer() {
const container = document.getElementById('resultsContainer');
container.innerHTML = `
<div class="space-y-4">
<div id="locationResults" class="divide-y divide-gray-200 dark:divide-gray-700">
<!-- Results will be populated here -->
</div>
<div id="summaryStats" class="hidden pt-4">
<!-- Summary stats will be populated here -->
</div>
</div>
`;
}
async function pollResults(taskId) {
let retryCount = 0;
const maxRetries = 10;
try {
while (true) {
const response = await fetch(`/speedtest/results/${taskId}`, {
headers: {'api_password': STATE.apiPassword}
});
if (!response.ok) {
if (response.status === 403) {
handleAuthError();
return;
}
if (retryCount < maxRetries) {
retryCount++
await new Promise(resolve => setTimeout(resolve, 2000));
continue;
}
throw new Error('Failed to fetch results after multiple attempts');
}
const data = await response.json();
retryCount = 0; //reset the retry count
if (data.status === 'failed') {
throw new Error('Speed test failed');
}
updateUI(data);
if (data.status === 'completed') {
showFinalResults(data);
break;
}
await new Promise(resolve => setTimeout(resolve, 2000));
}
} catch (error) {
showError(error.message);
}
}
function updateUI(data) {
if (data.user_info) {
updateUserInfo(data.user_info);
}
if (data.current_location) {
document.getElementById('currentLocation').textContent =
`Testing server ${data.current_location}...`;
}
updateResults(data.results);
}
function updateUserInfo(userInfo) {
const userInfoDiv = document.getElementById('userInfo');
userInfoDiv.innerHTML = `
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
<div class="space-y-1">
<div class="text-sm text-gray-500 dark:text-gray-400">IP Address</div>
<div class="font-medium text-gray-900 dark:text-white">${userInfo.ip}</div>
</div>
<div class="space-y-1">
<div class="text-sm text-gray-500 dark:text-gray-400">ISP</div>
<div class="font-medium text-gray-900 dark:text-white">${userInfo.isp}</div>
</div>
<div class="space-y-1">
<div class="text-sm text-gray-500 dark:text-gray-400">Country</div>
<div class="font-medium text-gray-900 dark:text-white">${userInfo.country?.toUpperCase()}</div>
</div>
</div>
`;
userInfoDiv.classList.remove('hidden');
}
function updateResults(results) {
const container = document.getElementById('resultsContainer');
const validResults = Object.entries(results)
.filter(([, data]) => data.result !== null && !data.error)
.sort(([, a], [, b]) => (b.result.speed_mbps) - (a.result.speed_mbps));
const failedResults = Object.entries(results)
.filter(([, data]) => data.error || data.result === null);
// Generate HTML for results
const resultsHTML = [
// Successful results
...validResults.map(([location, data]) => createSuccessResult(location, data)),
// Failed results
...failedResults.map(([location, data]) => createErrorResult(location, data))
].join('');
container.innerHTML = `
<div class="space-y-4">
<!-- Summary Stats -->
${createSummaryStats(validResults)}
<!-- Individual Results -->
<div class="mt-6 divide-y divide-gray-200 dark:divide-gray-700">
${resultsHTML}
</div>
</div>
`;
}
function createSummaryStats(validResults) {
if (validResults.length === 0) return '';
const speeds = validResults.map(([, data]) => data.result.speed_mbps);
const maxSpeed = Math.max(...speeds);
const avgSpeed = speeds.reduce((a, b) => a + b, 0) / speeds.length;
const fastestServer = validResults[0][0]; // First server after sorting
return `
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 bg-gray-50 dark:bg-gray-800 p-4 rounded-lg">
<div class="text-center">
<div class="text-sm text-gray-500 dark:text-gray-400">Fastest Server</div>
<div class="font-medium text-gray-900 dark:text-white">${fastestServer}</div>
</div>
<div class="text-center">
<div class="text-sm text-gray-500 dark:text-gray-400">Top Speed</div>
<div class="font-medium text-green-500">${maxSpeed.toFixed(2)} Mbps</div>
</div>
<div class="text-center">
<div class="text-sm text-gray-500 dark:text-gray-400">Average Speed</div>
<div class="font-medium text-blue-500">${avgSpeed.toFixed(2)} Mbps</div>
</div>
</div>
`;
}
function createSuccessResult(location, data) {
const speedClass = getSpeedClass(data.result.speed_mbps);
return `
<div class="py-4">
<div class="flex justify-between items-center">
<div>
<span class="font-medium text-gray-800 dark:text-white">${location}</span>
<span class="ml-2 text-sm text-gray-500 dark:text-gray-400">${data.server_name || ''}</span>
</div>
<span class="text-lg font-semibold ${speedClass}">${data.result.speed_mbps.toFixed(2)} Mbps</span>
</div>
<div class="mt-1 text-sm text-gray-500 dark:text-gray-400">
Duration: ${data.result.duration.toFixed(2)}s •
Data: ${formatBytes(data.result.data_transferred)}
</div>
<div class="mt-1 text-xs text-gray-400 dark:text-gray-500">
Server: ${data.server_url}
</div>
</div>
`;
}
function getSpeedClass(speed) {
if (speed >= 10) return 'text-green-500 dark:text-green-400';
if (speed >= 5) return 'text-blue-500 dark:text-blue-400';
if (speed >= 2) return 'text-yellow-500 dark:text-yellow-400';
return 'text-red-500 dark:text-red-400';
}
function showFinalResults(data) {
// Stop the progress animation
document.querySelector('#progressBar').style.animation = 'none';
// Update the final results view
const validResults = Object.entries(data.results)
.filter(([, data]) => data.result !== null && !data.error)
.sort(([, a], [, b]) => (b.result.speed_mbps) - (a.result.speed_mbps));
const failedResults = Object.entries(data.results)
.filter(([, data]) => data.error || data.result === null);
// Update summary stats
if (validResults.length > 0) {
const speeds = validResults.map(([, data]) => data.result.speed_mbps);
const maxSpeed = Math.max(...speeds);
const avgSpeed = speeds.reduce((a, b) => a + b, 0) / speeds.length;
const fastestServer = validResults[0][0];
document.getElementById('fastestServer').textContent = fastestServer;
document.getElementById('topSpeed').textContent = `${maxSpeed.toFixed(2)} Mbps`;
document.getElementById('avgSpeed').textContent = `${avgSpeed.toFixed(2)} Mbps`;
}
// Generate detailed results HTML
const finalResultsHTML = `
${validResults.map(([location, data]) => `
<div class="bg-white dark:bg-gray-800 rounded-lg p-4 shadow-sm">
<div class="flex justify-between items-center">
<div>
<h3 class="text-lg font-medium text-gray-900 dark:text-white">${location}</h3>
<p class="text-sm text-gray-500 dark:text-gray-400">${data.server_name || ''}</p>
</div>
<div class="text-right">
<p class="text-2xl font-bold ${getSpeedClass(data.result.speed_mbps)}">
${data.result.speed_mbps.toFixed(2)} Mbps
</p>
<p class="text-sm text-gray-500 dark:text-gray-400">
${data.result.duration.toFixed(2)}s • ${formatBytes(data.result.data_transferred)}
</p>
</div>
</div>
<div class="mt-2 text-xs text-gray-400 dark:text-gray-500">
${data.server_url}
</div>
</div>
`).join('')}
${failedResults.length > 0 ? `
<div class="mt-6">
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-4">Failed Tests</h3>
${failedResults.map(([location, data]) => `
<div class="bg-red-50 dark:bg-red-900/20 rounded-lg p-4 mb-4">
<div class="flex justify-between items-center">
<div>
<h4 class="font-medium text-red-800 dark:text-red-200">
${location} ${data.server_name ? `(${data.server_name})` : ''}
</h4>
<p class="text-sm text-red-700 dark:text-red-300">
${data.error || 'Test failed'}
</p>
<p class="text-xs text-red-600 dark:text-red-400 mt-1">
${data.server_url}
</p>
</div>
</div>
</div>
`).join('')}
</div>
` : ''}
`;
document.getElementById('finalResults').innerHTML = finalResultsHTML;
// If we have user info from AllDebrid, copy it to the final view
const userInfoDiv = document.getElementById('userInfo');
if (!userInfoDiv.classList.contains('hidden') && data.user_info) {
const userInfoContent = userInfoDiv.innerHTML;
document.getElementById('finalResults').insertAdjacentHTML('afterbegin', `
<div class="mb-6">
${userInfoContent}
</div>
`);
}
// Show the final results view
showView('resultsView');
}
function initializeView() {
initTheme();
showView(STATE.apiPassword ? 'selectionView' : 'passwordView');
}
function initializeFormHandlers() {
// Password form handler
document.getElementById('passwordForm').addEventListener('submit', (e) => {
e.preventDefault();
const password = document.getElementById('apiPassword').value;
const remember = document.getElementById('rememberPassword').checked;
if (remember) {
localStorage.setItem('speedtest_api_password', password);
}
STATE.apiPassword = password;
showView('selectionView');
});
// AllDebrid form handler
document.getElementById('allDebridForm').addEventListener('submit', async (e) => {
e.preventDefault();
const apiKey = document.getElementById('adApiKey').value;
const remember = document.getElementById('rememberAdKey').checked;
if (remember) {
localStorage.setItem('ad_api_key', apiKey);
}
STATE.adApiKey = apiKey;
await startTest('all_debrid');
});
}
document.addEventListener('DOMContentLoaded', () => {
initializeView();
initializeFormHandlers();
});
// Theme Toggle Event Listener
document.getElementById('themeToggle').addEventListener('click', () => {
setTheme(document.documentElement.classList.contains('dark') ? 'light' : 'dark');
}); });
</script> </script>
</body> </body>

File diff suppressed because it is too large Load Diff

View File

@@ -14,9 +14,7 @@ from typing import Optional, Union, Any
import aiofiles import aiofiles
import aiofiles.os import aiofiles.os
from pydantic import ValidationError
from mediaflow_proxy.speedtest.models import SpeedTestTask
from mediaflow_proxy.utils.http_utils import download_file_with_retry, DownloadError from mediaflow_proxy.utils.http_utils import download_file_with_retry, DownloadError
from mediaflow_proxy.utils.mpd_utils import parse_mpd, parse_mpd_dict from mediaflow_proxy.utils.mpd_utils import parse_mpd, parse_mpd_dict
@@ -270,12 +268,6 @@ MPD_CACHE = AsyncMemoryCache(
max_memory_size=100 * 1024 * 1024, # 100MB for MPD files max_memory_size=100 * 1024 * 1024, # 100MB for MPD files
) )
SPEEDTEST_CACHE = HybridCache(
cache_dir_name="speedtest_cache",
ttl=3600, # 1 hour
max_memory_size=50 * 1024 * 1024,
)
EXTRACTOR_CACHE = HybridCache( EXTRACTOR_CACHE = HybridCache(
cache_dir_name="extractor_cache", cache_dir_name="extractor_cache",
ttl=5 * 60, # 5 minutes ttl=5 * 60, # 5 minutes
@@ -335,27 +327,6 @@ async def get_cached_mpd(
raise error raise error
async def get_cached_speedtest(task_id: str) -> Optional[SpeedTestTask]:
"""Get speed test results from cache."""
cached_data = await SPEEDTEST_CACHE.get(task_id)
if cached_data is not None:
try:
return SpeedTestTask.model_validate_json(cached_data.decode())
except ValidationError as e:
logger.error(f"Error parsing cached speed test data: {e}")
await SPEEDTEST_CACHE.delete(task_id)
return None
async def set_cache_speedtest(task_id: str, task: SpeedTestTask) -> bool:
"""Cache speed test results."""
try:
return await SPEEDTEST_CACHE.set(task_id, task.model_dump_json().encode())
except Exception as e:
logger.error(f"Error caching speed test data: {e}")
return False
async def get_cached_extractor_result(key: str) -> Optional[dict]: async def get_cached_extractor_result(key: str) -> Optional[dict]:
"""Get extractor result from cache.""" """Get extractor result from cache."""
cached_data = await EXTRACTOR_CACHE.get(key) cached_data = await EXTRACTOR_CACHE.get(key)

View File

@@ -175,7 +175,9 @@ class Streamer:
logger.warning(f"Remote server closed connection prematurely: {e}") logger.warning(f"Remote server closed connection prematurely: {e}")
# If we've received some data, just log the warning and return normally # If we've received some data, just log the warning and return normally
if self.bytes_transferred > 0: if self.bytes_transferred > 0:
logger.info(f"Partial content received ({self.bytes_transferred} bytes). Continuing with available data.") logger.info(
f"Partial content received ({self.bytes_transferred} bytes). Continuing with available data."
)
return return
else: else:
# If we haven't received any data, raise an error # If we haven't received any data, raise an error
@@ -375,6 +377,70 @@ def encode_mediaflow_proxy_url(
return url return url
def encode_stremio_proxy_url(
stremio_proxy_url: str,
destination_url: str,
request_headers: typing.Optional[dict] = None,
response_headers: typing.Optional[dict] = None,
) -> str:
"""
Encodes a Stremio proxy URL with destination URL and headers.
Format: http://127.0.0.1:11470/proxy/d=<encoded_origin>&h=<headers>&r=<response_headers>/<path><query>
Args:
stremio_proxy_url (str): The base Stremio proxy URL.
destination_url (str): The destination URL to proxy.
request_headers (dict, optional): Headers to include as query parameters. Defaults to None.
response_headers (dict, optional): Response headers to include as query parameters. Defaults to None.
Returns:
str: The encoded Stremio proxy URL.
"""
# Parse the destination URL to separate origin, path, and query
parsed_dest = parse.urlparse(destination_url)
dest_origin = f"{parsed_dest.scheme}://{parsed_dest.netloc}"
dest_path = parsed_dest.path.lstrip("/")
dest_query = parsed_dest.query
# Prepare query parameters list for proper handling of multiple headers
query_parts = []
# Add destination origin (scheme + netloc only) with proper encoding
query_parts.append(f"d={parse.quote_plus(dest_origin)}")
# Add request headers
if request_headers:
for key, value in request_headers.items():
header_string = f"{key}:{value}"
query_parts.append(f"h={parse.quote_plus(header_string)}")
# Add response headers
if response_headers:
for key, value in response_headers.items():
header_string = f"{key}:{value}"
query_parts.append(f"r={parse.quote_plus(header_string)}")
# Ensure base_url doesn't end with a slash for consistent handling
base_url = stremio_proxy_url.rstrip("/")
# Construct the URL path with query string
query_string = "&".join(query_parts)
# Build the final URL: /proxy/{opts}/{pathname}{search}
url_path = f"/proxy/{query_string}"
# Append the path from destination URL
if dest_path:
url_path = f"{url_path}/{dest_path}"
# Append the query string from destination URL
if dest_query:
url_path = f"{url_path}?{dest_query}"
return f"{base_url}{url_path}"
def get_original_scheme(request: Request) -> str: def get_original_scheme(request: Request) -> str:
""" """
Determines the original scheme (http or https) of the request. Determines the original scheme (http or https) of the request.
@@ -509,7 +575,9 @@ class EnhancedStreamingResponse(Response):
logger.warning(f"Remote protocol error after partial streaming: {e}") logger.warning(f"Remote protocol error after partial streaming: {e}")
try: try:
await send({"type": "http.response.body", "body": b"", "more_body": False}) await send({"type": "http.response.body", "body": b"", "more_body": False})
logger.info(f"Response finalized after partial content ({self.actual_content_length} bytes transferred)") logger.info(
f"Response finalized after partial content ({self.actual_content_length} bytes transferred)"
)
except Exception as close_err: except Exception as close_err:
logger.warning(f"Could not finalize response after remote error: {close_err}") logger.warning(f"Could not finalize response after remote error: {close_err}")
else: else:

View File

@@ -3,8 +3,9 @@ import re
from typing import AsyncGenerator from typing import AsyncGenerator
from urllib import parse from urllib import parse
from mediaflow_proxy.configs import settings
from mediaflow_proxy.utils.crypto_utils import encryption_handler from mediaflow_proxy.utils.crypto_utils import encryption_handler
from mediaflow_proxy.utils.http_utils import encode_mediaflow_proxy_url, get_original_scheme from mediaflow_proxy.utils.http_utils import encode_mediaflow_proxy_url, encode_stremio_proxy_url, get_original_scheme
class M3U8Processor: class M3U8Processor:
@@ -39,7 +40,7 @@ class M3U8Processor:
if "URI=" in line: if "URI=" in line:
processed_lines.append(await self.process_key_line(line, base_url)) processed_lines.append(await self.process_key_line(line, base_url))
elif not line.startswith("#") and line.strip(): elif not line.startswith("#") and line.strip():
processed_lines.append(await self.proxy_url(line, base_url)) processed_lines.append(await self.proxy_content_url(line, base_url))
else: else:
processed_lines.append(line) processed_lines.append(line)
return "\n".join(processed_lines) return "\n".join(processed_lines)
@@ -104,7 +105,7 @@ class M3U8Processor:
if "URI=" in line: if "URI=" in line:
return await self.process_key_line(line, base_url) return await self.process_key_line(line, base_url)
elif not line.startswith("#") and line.strip(): elif not line.startswith("#") and line.strip():
return await self.proxy_url(line, base_url) return await self.proxy_content_url(line, base_url)
else: else:
return line return line
@@ -129,9 +130,9 @@ class M3U8Processor:
line = line.replace(f'URI="{original_uri}"', f'URI="{new_uri}"') line = line.replace(f'URI="{original_uri}"', f'URI="{new_uri}"')
return line return line
async def proxy_url(self, url: str, base_url: str) -> str: async def proxy_content_url(self, url: str, base_url: str) -> str:
""" """
Proxies a URL, encoding it with the MediaFlow proxy URL. Proxies a content URL based on the configured routing strategy.
Args: Args:
url (str): The URL to proxy. url (str): The URL to proxy.
@@ -141,6 +142,51 @@ class M3U8Processor:
str: The proxied URL. str: The proxied URL.
""" """
full_url = parse.urljoin(base_url, url) full_url = parse.urljoin(base_url, url)
# Determine routing strategy based on configuration
routing_strategy = settings.m3u8_content_routing
# For playlist URLs, always use MediaFlow proxy regardless of strategy
if ".m3u" in full_url:
return await self.proxy_url(full_url, base_url, use_full_url=True)
# Route non-playlist content URLs based on strategy
if routing_strategy == "direct":
# Return the URL directly without any proxying
return full_url
elif routing_strategy == "stremio" and settings.stremio_proxy_url:
# Use Stremio proxy for content URLs
query_params = dict(self.request.query_params)
request_headers = {k[2:]: v for k, v in query_params.items() if k.startswith("h_")}
response_headers = {k[2:]: v for k, v in query_params.items() if k.startswith("r_")}
return encode_stremio_proxy_url(
settings.stremio_proxy_url,
full_url,
request_headers=request_headers if request_headers else None,
response_headers=response_headers if response_headers else None,
)
else:
# Default to MediaFlow proxy (routing_strategy == "mediaflow" or fallback)
return await self.proxy_url(full_url, base_url, use_full_url=True)
async def proxy_url(self, url: str, base_url: str, use_full_url: bool = False) -> str:
"""
Proxies a URL, encoding it with the MediaFlow proxy URL.
Args:
url (str): The URL to proxy.
base_url (str): The base URL to resolve relative URLs.
use_full_url (bool): Whether to use the URL as-is (True) or join with base_url (False).
Returns:
str: The proxied URL.
"""
if use_full_url:
full_url = url
else:
full_url = parse.urljoin(base_url, url)
query_params = dict(self.request.query_params) query_params = dict(self.request.query_params)
has_encrypted = query_params.pop("has_encrypted", False) has_encrypted = query_params.pop("has_encrypted", False)
# Remove the response headers from the query params to avoid it being added to the consecutive requests # Remove the response headers from the query params to avoid it being added to the consecutive requests

View File

@@ -253,6 +253,16 @@ def parse_representation(
profile["frameRate"] = round(int(frame_rate.split("/")[0]) / int(frame_rate.split("/")[1]), 3) profile["frameRate"] = round(int(frame_rate.split("/")[0]) / int(frame_rate.split("/")[1]), 3)
profile["sar"] = representation.get("@sar", "1:1") profile["sar"] = representation.get("@sar", "1:1")
# Extract segment template start number for adaptive sequence calculation
segment_template_data = adaptation.get("SegmentTemplate") or representation.get("SegmentTemplate")
if segment_template_data:
try:
profile["segment_template_start_number"] = int(segment_template_data.get("@startNumber", 1))
except (ValueError, TypeError):
profile["segment_template_start_number"] = 1
else:
profile["segment_template_start_number"] = 1
if parse_segment_profile_id is None or profile["id"] != parse_segment_profile_id: if parse_segment_profile_id is None or profile["id"] != parse_segment_profile_id:
return profile return profile
@@ -502,6 +512,12 @@ def create_segment_data(segment: Dict, item: dict, profile: dict, source: str, t
"number": segment["number"], "number": segment["number"],
} }
# Add time and duration metadata for adaptive sequence calculation
if "time" in segment:
segment_data["time"] = segment["time"]
if "duration" in segment:
segment_data["duration_mpd_timescale"] = segment["duration"]
if "start_time" in segment and "end_time" in segment: if "start_time" in segment and "end_time" in segment:
segment_data.update( segment_data.update(
{ {