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