Add AD speed test & improve speed test UI & refactoring

This commit is contained in:
mhdzumair
2024-11-12 07:09:42 +05:30
parent 4b4f6af536
commit 64eeb8f901
12 changed files with 1026 additions and 166 deletions
+9 -25
View File
@@ -1,19 +1,17 @@
import logging
import uuid
from importlib import resources
from fastapi import FastAPI, Depends, Security, HTTPException, BackgroundTasks
from fastapi import FastAPI, Depends, Security, HTTPException
from fastapi.security import APIKeyQuery, APIKeyHeader
from starlette.middleware.cors import CORSMiddleware
from starlette.responses import RedirectResponse, JSONResponse
from starlette.responses import RedirectResponse
from starlette.staticfiles import StaticFiles
from mediaflow_proxy.configs import settings
from mediaflow_proxy.routes import proxy_router, extractor_router
from mediaflow_proxy.routes import proxy_router, extractor_router, speedtest_router
from mediaflow_proxy.schemas import GenerateUrlRequest
from mediaflow_proxy.utils.crypto_utils import EncryptionHandler, EncryptionMiddleware
from mediaflow_proxy.utils.http_utils import encode_mediaflow_proxy_url
from mediaflow_proxy.utils.rd_speedtest import run_speedtest, prune_task, results
logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s")
app = FastAPI()
@@ -51,31 +49,16 @@ async def health_check():
return {"status": "healthy"}
@app.get("/speedtest")
async def trigger_speedtest(background_tasks: BackgroundTasks, api_password: str = Depends(verify_api_key)):
# Generate a random UUID as task_id
task_id = str(uuid.uuid4()) # Generate unique task ID
background_tasks.add_task(run_speedtest, task_id)
# Schedule the task to be pruned after 1 hour
background_tasks.add_task(prune_task, task_id)
return RedirectResponse(url=f"/speedtest_progress.html?task_id={task_id}")
@app.get("/speedtest/results/{task_id}", response_class=JSONResponse)
async def get_speedtest_result(task_id: str):
if task_id in results:
return results[task_id]
else:
return {"message": "Speedtest is still running, please wait or the task may have expired."}
@app.get("/favicon.ico")
async def get_favicon():
return RedirectResponse(url="/logo.png")
@app.get("/speedtest")
async def show_speedtest_page():
return RedirectResponse(url="/speedtest.html")
@app.post("/generate_encrypted_or_encoded_url")
async def generate_encrypted_or_encoded_url(request: GenerateUrlRequest):
if "api_password" not in request.query_params:
@@ -97,6 +80,7 @@ async def generate_encrypted_or_encoded_url(request: GenerateUrlRequest):
app.include_router(proxy_router, prefix="/proxy", tags=["proxy"], dependencies=[Depends(verify_api_key)])
app.include_router(extractor_router, prefix="/extractor", tags=["extractors"], dependencies=[Depends(verify_api_key)])
app.include_router(speedtest_router, prefix="/speedtest", tags=["speedtest"], dependencies=[Depends(verify_api_key)])
static_path = resources.files("mediaflow_proxy").joinpath("static")
app.mount("/", StaticFiles(directory=str(static_path), html=True), name="static")
+3
View File
@@ -1,2 +1,5 @@
from .proxy import proxy_router
from .extractor import extractor_router
from .speedtest import speedtest_router
__all__ = ["proxy_router", "extractor_router", "speedtest_router"]
+43
View File
@@ -0,0 +1,43 @@
import uuid
from fastapi import APIRouter, BackgroundTasks, HTTPException, Request
from fastapi.responses import RedirectResponse
from mediaflow_proxy.speedtest.service import SpeedTestService, SpeedTestProvider
speedtest_router = APIRouter()
# Initialize service
speedtest_service = SpeedTestService()
@speedtest_router.get("/", summary="Show speed test interface")
async def show_speedtest_page():
"""Return the 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")
# Create and initialize the task
await speedtest_service.create_test(task_id, provider, api_key)
# 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()
+46
View File
@@ -0,0 +1,46 @@
from datetime import datetime
from enum import Enum
from typing import Dict, Optional
from pydantic import BaseModel, Field
class SpeedTestProvider(str, Enum):
REAL_DEBRID = "real_debrid"
ALL_DEBRID = "all_debrid"
class ServerInfo(BaseModel):
url: str
name: str
class UserInfo(BaseModel):
ip: Optional[str] = None
isp: Optional[str] = None
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 LocationResult(BaseModel):
result: Optional[SpeedTestResult] = None
error: Optional[str] = None
server_name: str
server_url: str
class SpeedTestTask(BaseModel):
task_id: str
provider: SpeedTestProvider
results: Dict[str, LocationResult] = {}
started_at: datetime
completed_at: Optional[datetime] = None
status: str = "running"
user_info: Optional[UserInfo] = None
current_location: Optional[str] = None
@@ -0,0 +1,50 @@
import random
from typing import Dict, Tuple, Optional
from mediaflow_proxy.configs import settings
from mediaflow_proxy.speedtest.models import ServerInfo, UserInfo
from mediaflow_proxy.speedtest.providers.base import BaseSpeedTestProvider, SpeedTestProviderConfig
from mediaflow_proxy.utils.http_utils import request_with_retry
class SpeedTestError(Exception):
pass
class AllDebridSpeedTest(BaseSpeedTestProvider):
"""AllDebrid speed test provider implementation."""
def __init__(self, api_key: str):
self.api_key = api_key
self.servers: Dict[str, ServerInfo] = {}
async def get_test_urls(self) -> Tuple[Dict[str, str], Optional[UserInfo]]:
response = await request_with_retry(
"GET",
"https://alldebrid.com/internalapi/v4/speedtest",
headers={"User-Agent": settings.user_agent},
params={"agent": "service", "version": "1.0-363869a7", "apikey": self.api_key},
)
if response.status_code != 200:
raise SpeedTestError("Failed to fetch AllDebrid servers")
data = response.json()
if data["status"] != "success":
raise SpeedTestError("AllDebrid API returned error")
# Create UserInfo
user_info = UserInfo(ip=data["data"]["ip"], isp=data["data"]["isp"], country=data["data"]["country"])
# Store server info
self.servers = {server["name"]: ServerInfo(**server) for server in data["data"]["servers"]}
# Generate URLs with random number
random_number = f"{random.uniform(1, 2):.24f}".replace(".", "")
urls = {name: f"{server.url}/speedtest/{random_number}" for name, server in self.servers.items()}
return urls, user_info
async def get_config(self) -> SpeedTestProviderConfig:
urls, _ = await self.get_test_urls()
return SpeedTestProviderConfig(test_duration=10, test_urls=urls)
@@ -0,0 +1,24 @@
from abc import ABC, abstractmethod
from typing import Dict, Tuple, Optional
from pydantic import BaseModel
from mediaflow_proxy.speedtest.models import UserInfo
class SpeedTestProviderConfig(BaseModel):
test_duration: int = 10 # seconds
test_urls: Dict[str, str]
class BaseSpeedTestProvider(ABC):
"""Base class for speed test providers."""
@abstractmethod
async def get_test_urls(self) -> Tuple[Dict[str, str], Optional[UserInfo]]:
"""Get list of test URLs for the provider and optional user info."""
pass
@abstractmethod
async def get_config(self) -> SpeedTestProviderConfig:
"""Get provider-specific configuration."""
pass
@@ -0,0 +1,32 @@
from typing import Dict, Tuple, Optional
import random
from mediaflow_proxy.speedtest.models import UserInfo
from mediaflow_proxy.speedtest.providers.base import BaseSpeedTestProvider, SpeedTestProviderConfig
class RealDebridSpeedTest(BaseSpeedTestProvider):
"""RealDebrid speed test provider implementation."""
async def get_test_urls(self) -> Tuple[Dict[str, str], Optional[UserInfo]]:
urls = {
"AMS": "https://45.download.real-debrid.com/speedtest/testDefault.rar/",
"RBX": "https://rbx.download.real-debrid.com/speedtest/test.rar/",
"LON1": "https://lon1.download.real-debrid.com/speedtest/test.rar/",
"HKG1": "https://hkg1.download.real-debrid.com/speedtest/test.rar/",
"SGP1": "https://sgp1.download.real-debrid.com/speedtest/test.rar/",
"SGPO1": "https://sgpo1.download.real-debrid.com/speedtest/test.rar/",
"TYO1": "https://tyo1.download.real-debrid.com/speedtest/test.rar/",
"LAX1": "https://lax1.download.real-debrid.com/speedtest/test.rar/",
"TLV1": "https://tlv1.download.real-debrid.com/speedtest/test.rar/",
"MUM1": "https://mum1.download.real-debrid.com/speedtest/test.rar/",
"JKT1": "https://jkt1.download.real-debrid.com/speedtest/test.rar/",
"Cloudflare": "https://45.download.real-debrid.cloud/speedtest/testCloudflare.rar/",
}
# Add random number to prevent caching
urls = {location: f"{base_url}{random.uniform(0, 1):.16f}" for location, base_url in urls.items()}
return urls, None
async def get_config(self) -> SpeedTestProviderConfig:
urls, _ = await self.get_test_urls()
return SpeedTestProviderConfig(test_duration=10, test_urls=urls)
+129
View File
@@ -0,0 +1,129 @@
import logging
import time
from datetime import datetime
from typing import Dict, Optional, Type
from cachetools import TTLCache
from httpx import AsyncClient
from mediaflow_proxy.utils.http_utils import Streamer
from .models import SpeedTestTask, LocationResult, SpeedTestResult, SpeedTestProvider
from .providers.all_debrid import AllDebridSpeedTest
from .providers.base import BaseSpeedTestProvider
from .providers.real_debrid import RealDebridSpeedTest
from ..configs import settings
logger = logging.getLogger(__name__)
class SpeedTestService:
"""Service for managing speed tests across different providers."""
def __init__(self):
# Cache for speed test results (1 hour TTL)
self._cache: TTLCache[str, SpeedTestTask] = TTLCache(maxsize=100, ttl=3600)
# Provider mapping
self._providers: Dict[SpeedTestProvider, Type[BaseSpeedTestProvider]] = {
SpeedTestProvider.REAL_DEBRID: RealDebridSpeedTest,
SpeedTestProvider.ALL_DEBRID: AllDebridSpeedTest,
}
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:
raise ValueError(f"Unsupported provider: {provider}")
if provider == SpeedTestProvider.ALL_DEBRID and not api_key:
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.utcnow(), user_info=user_info)
self._cache[task_id] = task
return task
async def get_test_results(self, task_id: str) -> Optional[SpeedTestTask]:
"""Get results for a specific task."""
return self._cache.get(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 = self._cache.get(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 AsyncClient(follow_redirects=True, timeout=10, proxy=settings.proxy_url) as client:
streamer = Streamer(client)
for location, url in config.test_urls.items():
try:
task.current_location = location
result = await self._test_location(location, url, streamer, config.test_duration, provider_impl)
task.results[location] = result
self._cache[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]
)
self._cache[task_id] = task
# Mark task as completed
task.completed_at = datetime.utcnow()
task.status = "completed"
task.current_location = None
self._cache[task_id] = task
except Exception as e:
logger.error(f"Error in speed test task {task_id}: {str(e)}")
if task := self._cache.get(task_id):
task.status = "failed"
self._cache[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
async for chunk in streamer.stream_content(url, headers={}):
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
+689
View File
@@ -0,0 +1,689 @@
<!DOCTYPE html>
<html lang="en" class="h-full">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Debrid Speed Test</title>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
darkMode: 'class',
theme: {
extend: {
animation: {
'progress': 'progress 180s linear forwards',
},
keyframes: {
progress: {
'0%': {width: '0%'},
'100%': {width: '100%'}
}
}
}
}
}
</script>
<style>
.provider-card {
transition: all 0.3s ease;
}
.provider-card:hover {
transform: translateY(-5px);
}
@keyframes slideIn {
from {
transform: translateY(20px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.slide-in {
animation: slideIn 0.3s ease-out forwards;
}
</style>
</head>
<body class="bg-gray-100 dark:bg-gray-900 min-h-full">
<!-- Theme Toggle -->
<div class="fixed top-4 right-4 z-50">
<button id="themeToggle" class="p-2 rounded-full bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors">
<svg id="sunIcon" class="w-6 h-6 text-yellow-500 hidden dark:block" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"/>
</svg>
<svg id="moonIcon" class="w-6 h-6 text-gray-700 block dark:hidden" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"/>
</svg>
</button>
</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>
<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>
</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">
AllDebrid API Key
</label>
<input
type="password"
id="adApiKey"
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
>
<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
</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"
>
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
</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>
<!-- Progress Section -->
<div class="space-y-4">
<div class="text-center text-gray-600 dark:text-gray-300" id="currentLocation">
Initializing test...
</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>
</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>
</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>
</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>
</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>
</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 handling
function setTheme(theme) {
document.documentElement.classList.toggle('dark', theme === 'dark');
localStorage.theme = theme;
}
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) {
try {
while (true) {
const response = await fetch(`/speedtest/results/${taskId}`, {
headers: {'api_password': STATE.apiPassword}
});
if (!response.ok) {
if (response.status === 403) {
handleAuthError();
return;
}
throw new Error('Failed to fetch results');
}
const data = await response.json();
if (data.status === 'failed') {
throw new Error('Speed test failed');
}
updateUI(data);
if (data.status === 'completed') {
showFinalResults(data);
break;
}
await new Promise(resolve => setTimeout(resolve, 2000));
}
} catch (error) {
showError(error.message);
}
}
function updateUI(data) {
if (data.user_info) {
updateUserInfo(data.user_info);
}
if (data.current_location) {
document.getElementById('currentLocation').textContent =
`Testing server ${data.current_location}...`;
}
updateResults(data.results);
}
function updateUserInfo(userInfo) {
const userInfoDiv = document.getElementById('userInfo');
userInfoDiv.innerHTML = `
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
<div class="space-y-1">
<div class="text-sm text-gray-500 dark:text-gray-400">IP Address</div>
<div class="font-medium text-gray-900 dark:text-white">${userInfo.ip}</div>
</div>
<div class="space-y-1">
<div class="text-sm text-gray-500 dark:text-gray-400">ISP</div>
<div class="font-medium text-gray-900 dark:text-white">${userInfo.isp}</div>
</div>
<div class="space-y-1">
<div class="text-sm text-gray-500 dark:text-gray-400">Country</div>
<div class="font-medium text-gray-900 dark:text-white">${userInfo.country?.toUpperCase()}</div>
</div>
</div>
`;
userInfoDiv.classList.remove('hidden');
}
function updateResults(results) {
const container = document.getElementById('resultsContainer');
const validResults = Object.entries(results)
.filter(([, data]) => data.result !== null && !data.error)
.sort(([, a], [, b]) => (b.result.speed_mbps) - (a.result.speed_mbps));
const failedResults = Object.entries(results)
.filter(([, data]) => data.error || data.result === null);
// Generate HTML for results
const resultsHTML = [
// Successful results
...validResults.map(([location, data]) => createSuccessResult(location, data)),
// Failed results
...failedResults.map(([location, data]) => createErrorResult(location, data))
].join('');
container.innerHTML = `
<div class="space-y-4">
<!-- Summary Stats -->
${createSummaryStats(validResults)}
<!-- Individual Results -->
<div class="mt-6 divide-y divide-gray-200 dark:divide-gray-700">
${resultsHTML}
</div>
</div>
`;
}
function createSummaryStats(validResults) {
if (validResults.length === 0) return '';
const speeds = validResults.map(([, data]) => data.result.speed_mbps);
const maxSpeed = Math.max(...speeds);
const avgSpeed = speeds.reduce((a, b) => a + b, 0) / speeds.length;
const fastestServer = validResults[0][0]; // First server after sorting
return `
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 bg-gray-50 dark:bg-gray-800 p-4 rounded-lg">
<div class="text-center">
<div class="text-sm text-gray-500 dark:text-gray-400">Fastest Server</div>
<div class="font-medium text-gray-900 dark:text-white">${fastestServer}</div>
</div>
<div class="text-center">
<div class="text-sm text-gray-500 dark:text-gray-400">Top Speed</div>
<div class="font-medium text-green-500">${maxSpeed.toFixed(2)} Mbps</div>
</div>
<div class="text-center">
<div class="text-sm text-gray-500 dark:text-gray-400">Average Speed</div>
<div class="font-medium text-blue-500">${avgSpeed.toFixed(2)} Mbps</div>
</div>
</div>
`;
}
function createSuccessResult(location, data) {
const speedClass = getSpeedClass(data.result.speed_mbps);
return `
<div class="py-4">
<div class="flex justify-between items-center">
<div>
<span class="font-medium text-gray-800 dark:text-white">${location}</span>
<span class="ml-2 text-sm text-gray-500 dark:text-gray-400">${data.server_name || ''}</span>
</div>
<span class="text-lg font-semibold ${speedClass}">${data.result.speed_mbps.toFixed(2)} Mbps</span>
</div>
<div class="mt-1 text-sm text-gray-500 dark:text-gray-400">
Duration: ${data.result.duration.toFixed(2)}s •
Data: ${formatBytes(data.result.data_transferred)}
</div>
<div class="mt-1 text-xs text-gray-400 dark:text-gray-500">
Server: ${data.server_url}
</div>
</div>
`;
}
function getSpeedClass(speed) {
if (speed >= 10) return 'text-green-500 dark:text-green-400';
if (speed >= 5) return 'text-blue-500 dark:text-blue-400';
if (speed >= 2) return 'text-yellow-500 dark:text-yellow-400';
return 'text-red-500 dark:text-red-400';
}
function showFinalResults(data) {
// Stop the progress animation
document.querySelector('#progressBar').style.animation = 'none';
// Update the final results view
const validResults = Object.entries(data.results)
.filter(([, data]) => data.result !== null && !data.error)
.sort(([, a], [, b]) => (b.result.speed_mbps) - (a.result.speed_mbps));
const failedResults = Object.entries(data.results)
.filter(([, data]) => data.error || data.result === null);
// Update summary stats
if (validResults.length > 0) {
const speeds = validResults.map(([, data]) => data.result.speed_mbps);
const maxSpeed = Math.max(...speeds);
const avgSpeed = speeds.reduce((a, b) => a + b, 0) / speeds.length;
const fastestServer = validResults[0][0];
document.getElementById('fastestServer').textContent = fastestServer;
document.getElementById('topSpeed').textContent = `${maxSpeed.toFixed(2)} Mbps`;
document.getElementById('avgSpeed').textContent = `${avgSpeed.toFixed(2)} Mbps`;
}
// Generate detailed results HTML
const finalResultsHTML = `
${validResults.map(([location, data]) => `
<div class="bg-white dark:bg-gray-800 rounded-lg p-4 shadow-sm">
<div class="flex justify-between items-center">
<div>
<h3 class="text-lg font-medium text-gray-900 dark:text-white">${location}</h3>
<p class="text-sm text-gray-500 dark:text-gray-400">${data.server_name || ''}</p>
</div>
<div class="text-right">
<p class="text-2xl font-bold ${getSpeedClass(data.result.speed_mbps)}">
${data.result.speed_mbps.toFixed(2)} Mbps
</p>
<p class="text-sm text-gray-500 dark:text-gray-400">
${data.result.duration.toFixed(2)}s • ${formatBytes(data.result.data_transferred)}
</p>
</div>
</div>
<div class="mt-2 text-xs text-gray-400 dark:text-gray-500">
${data.server_url}
</div>
</div>
`).join('')}
${failedResults.length > 0 ? `
<div class="mt-6">
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-4">Failed Tests</h3>
${failedResults.map(([location, data]) => `
<div class="bg-red-50 dark:bg-red-900/20 rounded-lg p-4 mb-4">
<div class="flex justify-between items-center">
<div>
<h4 class="font-medium text-red-800 dark:text-red-200">
${location} ${data.server_name ? `(${data.server_name})` : ''}
</h4>
<p class="text-sm text-red-700 dark:text-red-300">
${data.error || 'Test failed'}
</p>
<p class="text-xs text-red-600 dark:text-red-400 mt-1">
${data.server_url}
</p>
</div>
</div>
</div>
`).join('')}
</div>
` : ''}
`;
document.getElementById('finalResults').innerHTML = finalResultsHTML;
// If we have user info from AllDebrid, copy it to the final view
const userInfoDiv = document.getElementById('userInfo');
if (!userInfoDiv.classList.contains('hidden') && data.user_info) {
const userInfoContent = userInfoDiv.innerHTML;
document.getElementById('finalResults').insertAdjacentHTML('afterbegin', `
<div class="mb-6">
${userInfoContent}
</div>
`);
}
// Show the final results view
showView('resultsView');
}
function initializeView() {
initTheme();
showView(STATE.apiPassword ? 'selectionView' : 'passwordView');
}
function initializeFormHandlers() {
// Password form handler
document.getElementById('passwordForm').addEventListener('submit', (e) => {
e.preventDefault();
const password = document.getElementById('apiPassword').value;
const remember = document.getElementById('rememberPassword').checked;
if (remember) {
localStorage.setItem('speedtest_api_password', password);
}
STATE.apiPassword = password;
showView('selectionView');
});
// AllDebrid form handler
document.getElementById('allDebridForm').addEventListener('submit', async (e) => {
e.preventDefault();
const apiKey = document.getElementById('adApiKey').value;
const remember = document.getElementById('rememberAdKey').checked;
if (remember) {
localStorage.setItem('ad_api_key', apiKey);
}
STATE.adApiKey = apiKey;
await startTest('all_debrid');
});
}
document.addEventListener('DOMContentLoaded', () => {
initializeView();
initializeFormHandlers();
});
// Theme Toggle Event Listener
document.getElementById('themeToggle').addEventListener('click', () => {
setTheme(document.documentElement.classList.contains('dark') ? 'light' : 'dark');
});
</script>
</body>
</html>
@@ -1,140 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Speedtest</title>
<style>
body.light-mode {
font-family: Arial, sans-serif;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
margin: 0;
background-color: #f4f4f9;
color: #333;
}
body.dark-mode {
font-family: Arial, sans-serif;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
margin: 0;
background-color: #121212;
color: #ffffff;
}
.container {
text-align: center;
}
h1 {
font-size: 24px;
}
.progress-bar {
width: 80%;
margin: 20px auto;
height: 20px;
background-color: #e0e0e0;
border-radius: 10px;
overflow: hidden;
position: relative;
}
.progress-bar::after {
content: "";
position: absolute;
top: 0;
left: 0;
width: 0%;
height: 100%;
background-color: #3498db;
animation: progress 180s linear forwards;
}
@keyframes progress {
0% { width: 0%; }
100% { width: 100%; }
}
.toggle-switch {
position: absolute;
top: 10px;
right: 10px;
}
</style>
<script>
const urlParams = new URLSearchParams(window.location.search);
const taskId = urlParams.get("task_id");
if (!taskId || !/^[a-zA-Z0-9-_]+$/.test(taskId)) {
window.location.href = "/speedtest";
}
let statusCheckTimeout;
let retryCount = 0;
const MAX_RETRIES = 5;
async function checkStatus() {
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5000);
const response = await fetch(`/speedtest/results/${taskId}`, {
signal: controller.signal
});
clearTimeout(timeoutId);
if (!response.ok) {
throw new Error('Network response was not ok');
}
const data = await response.json();
console.log("Fetched data:", data);
retryCount = 0;
if (data && data.message && data.message.includes("still running")) {
console.log("Test still running, polling again...");
statusCheckTimeout = setTimeout(checkStatus, 5000);
} else {
console.log("Test complete, redirecting after a short delay...");
setTimeout(() => {
window.location.href = `/speedtest/results/${taskId}`;
}, 2000);
}
} catch (error) {
console.error("Error fetching status:", error);
retryCount++;
if (retryCount < MAX_RETRIES) {
statusCheckTimeout = setTimeout(checkStatus, 5000);
} else {
alert("Failed to check status after multiple attempts. Please refresh the page.");
}
}
}
// Cleanup on page unload
window.addEventListener('unload', () => {
if (statusCheckTimeout) {
clearTimeout(statusCheckTimeout);
}
});
// Start the first status check after 120 seconds (120000 milliseconds)
setTimeout(checkStatus, 120000);
// Toggle dark mode
function toggleDarkMode() {
const body = document.body;
body.classList.toggle('dark-mode');
body.classList.toggle('light-mode');
}
</script>
</head>
<body class="light-mode">
<div class="toggle-switch">
<label for="darkModeToggle" class="switch">
<input type="checkbox" id="darkModeToggle" onclick="toggleDarkMode()" aria-label="Toggle dark mode">
<span class="slider">Dark Mode</span>
</label>
</div>
<div class="container">
<h1>Speedtest in progress... Please wait up to 3 minutes.</h1>
<div class="progress-bar" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0"></div>
</div>
</body>
</html>
+1 -1
View File
@@ -245,7 +245,7 @@ async def download_file_with_retry(
async def request_with_retry(
method: str, url: str, headers: dict, timeout: float = 10.0, use_request_proxy: bool = True, **kwargs
):
) -> httpx.Response:
"""
Sends an HTTP request with retry logic.