New version

This commit is contained in:
UrloMythus
2025-06-10 22:42:56 +02:00
parent 4b5891457e
commit 1b1458e7f3
58 changed files with 1843 additions and 847 deletions

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.

View File

@@ -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.

View File

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

View File

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

View File

@@ -23,4 +23,3 @@ class UIAccessControlMiddleware(BaseHTTPMiddleware):
return Response(status_code=403, content="Forbidden")
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
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}",

View File

@@ -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))

View File

@@ -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):

View File

@@ -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

View File

@@ -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

View File

@@ -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>

View File

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

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.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)

View File

@@ -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:

View File

@@ -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

View File

@@ -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(
{