mirror of
https://github.com/UrloMythus/UnHided.git
synced 2026-04-09 02:40:47 +00:00
new version
This commit is contained in:
@@ -19,11 +19,14 @@ class TransportConfig(BaseSettings):
|
||||
proxy_url: Optional[str] = Field(
|
||||
None, description="Primary proxy URL. Example: socks5://user:pass@proxy:1080 or http://proxy:8080"
|
||||
)
|
||||
disable_ssl_verification_globally: bool = Field(
|
||||
False, description="Disable SSL verification for all requests globally."
|
||||
)
|
||||
all_proxy: bool = Field(False, description="Enable proxy for all routes by default")
|
||||
transport_routes: Dict[str, RouteConfig] = Field(
|
||||
default_factory=dict, description="Pattern-based route configuration"
|
||||
)
|
||||
timeout: int = Field(30, description="Timeout for HTTP requests in seconds")
|
||||
timeout: int = Field(60, description="Timeout for HTTP requests in seconds")
|
||||
|
||||
def get_mounts(
|
||||
self, async_http: bool = True
|
||||
@@ -33,11 +36,13 @@ class TransportConfig(BaseSettings):
|
||||
"""
|
||||
mounts = {}
|
||||
transport_cls = httpx.AsyncHTTPTransport if async_http else httpx.HTTPTransport
|
||||
global_verify = not self.disable_ssl_verification_globally
|
||||
|
||||
# Configure specific routes
|
||||
for pattern, route in self.transport_routes.items():
|
||||
mounts[pattern] = transport_cls(
|
||||
verify=route.verify_ssl, proxy=route.proxy_url or self.proxy_url if route.proxy else None
|
||||
verify=route.verify_ssl if global_verify else False,
|
||||
proxy=route.proxy_url or self.proxy_url if route.proxy else None,
|
||||
)
|
||||
|
||||
# Hardcoded configuration for jxoplay.xyz domain - SSL verification disabled
|
||||
@@ -45,9 +50,23 @@ class TransportConfig(BaseSettings):
|
||||
verify=False, proxy=self.proxy_url if self.all_proxy else None
|
||||
)
|
||||
|
||||
mounts["all://dlhd.dad"] = transport_cls(
|
||||
verify=False, proxy=self.proxy_url if self.all_proxy else None
|
||||
)
|
||||
|
||||
mounts["all://*.newkso.ru"] = transport_cls(
|
||||
verify=False, proxy=self.proxy_url if self.all_proxy else None
|
||||
)
|
||||
|
||||
# Apply global settings for proxy and SSL
|
||||
default_proxy_url = self.proxy_url if self.all_proxy else None
|
||||
if default_proxy_url or not global_verify:
|
||||
mounts["all://"] = transport_cls(proxy=default_proxy_url, verify=global_verify)
|
||||
|
||||
# Set default proxy for all routes if enabled
|
||||
if self.all_proxy:
|
||||
mounts["all://"] = transport_cls(proxy=self.proxy_url)
|
||||
# This part is now handled above to combine proxy and SSL settings
|
||||
# if self.all_proxy:
|
||||
# mounts["all://"] = transport_cls(proxy=self.proxy_url)
|
||||
|
||||
return mounts
|
||||
|
||||
@@ -78,6 +97,8 @@ class Settings(BaseSettings):
|
||||
dash_prebuffer_cache_size: int = 50 # Maximum number of segments to cache in memory.
|
||||
dash_prebuffer_max_memory_percent: int = 80 # Maximum percentage of system memory to use for DASH pre-buffer cache.
|
||||
dash_prebuffer_emergency_threshold: int = 90 # Emergency threshold percentage to trigger aggressive cache cleanup.
|
||||
mpd_live_init_cache_ttl: int = 0 # TTL (seconds) for live init segment cache; 0 disables caching.
|
||||
mpd_live_playlist_depth: int = 8 # Number of recent segments to expose per live playlist variant.
|
||||
|
||||
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.
|
||||
|
||||
104
mediaflow_proxy/extractors/F16Px.py
Normal file
104
mediaflow_proxy/extractors/F16Px.py
Normal file
@@ -0,0 +1,104 @@
|
||||
# https://github.com/Gujal00/ResolveURL/blob/55c7f66524ebd65bc1f88650614e627b00167fa0/script.module.resolveurl/lib/resolveurl/plugins/f16px.py
|
||||
|
||||
import base64
|
||||
import json
|
||||
import re
|
||||
from typing import Dict, Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from mediaflow_proxy.extractors.base import BaseExtractor, ExtractorError
|
||||
from mediaflow_proxy.utils import python_aesgcm
|
||||
|
||||
|
||||
class F16PxExtractor(BaseExtractor):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.mediaflow_endpoint = "hls_manifest_proxy"
|
||||
|
||||
@staticmethod
|
||||
def _b64url_decode(value: str) -> bytes:
|
||||
# base64url -> base64
|
||||
value = value.replace("-", "+").replace("_", "/")
|
||||
padding = (-len(value)) % 4
|
||||
if padding:
|
||||
value += "=" * padding
|
||||
return base64.b64decode(value)
|
||||
|
||||
def _join_key_parts(self, parts) -> bytes:
|
||||
return b"".join(self._b64url_decode(p) for p in parts)
|
||||
|
||||
async def extract(self, url: str) -> Dict[str, Any]:
|
||||
parsed = urlparse(url)
|
||||
host = parsed.netloc
|
||||
origin = f"{parsed.scheme}://{parsed.netloc}"
|
||||
|
||||
match = re.search(r"/e/([A-Za-z0-9]+)", parsed.path or "")
|
||||
if not match:
|
||||
raise ExtractorError("F16PX: Invalid embed URL")
|
||||
|
||||
media_id = match.group(1)
|
||||
api_url = f"https://{host}/api/videos/{media_id}/embed/playback"
|
||||
|
||||
headers = self.base_headers.copy()
|
||||
headers["referer"] = f"https://{host}/"
|
||||
|
||||
resp = await self._make_request(api_url, headers=headers)
|
||||
try:
|
||||
data = resp.json()
|
||||
except Exception:
|
||||
raise ExtractorError("F16PX: Invalid JSON response")
|
||||
|
||||
# Case 1: plain sources
|
||||
if "sources" in data and data["sources"]:
|
||||
src = data["sources"][0].get("url")
|
||||
if not src:
|
||||
raise ExtractorError("F16PX: Empty source URL")
|
||||
return {
|
||||
"destination_url": src,
|
||||
"request_headers": headers,
|
||||
"mediaflow_endpoint": self.mediaflow_endpoint,
|
||||
}
|
||||
|
||||
# Case 2: encrypted playback
|
||||
pb = data.get("playback")
|
||||
if not pb:
|
||||
raise ExtractorError("F16PX: No playback data")
|
||||
|
||||
try:
|
||||
iv = self._b64url_decode(pb["iv"]) # nonce
|
||||
key = self._join_key_parts(pb["key_parts"]) # AES key
|
||||
payload = self._b64url_decode(pb["payload"]) # ciphertext + tag
|
||||
|
||||
cipher = python_aesgcm.new(key)
|
||||
decrypted = cipher.open(iv, payload) # AAD = '' like ResolveURL
|
||||
|
||||
if decrypted is None:
|
||||
raise ExtractorError("F16PX: GCM authentication failed")
|
||||
|
||||
decrypted_json = json.loads(decrypted.decode("utf-8", "ignore"))
|
||||
|
||||
except ExtractorError:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise ExtractorError(f"F16PX: Decryption failed ({e})")
|
||||
|
||||
sources = decrypted_json.get("sources") or []
|
||||
if not sources:
|
||||
raise ExtractorError("F16PX: No sources after decryption")
|
||||
|
||||
best = sources[0].get("url")
|
||||
if not best:
|
||||
raise ExtractorError("F16PX: Empty source URL after decryption")
|
||||
|
||||
self.base_headers.clear()
|
||||
self.base_headers["referer"] = f"{origin}/"
|
||||
self.base_headers["origin"] = origin
|
||||
self.base_headers["Accept-Language"] = "en-US,en;q=0.5"
|
||||
self.base_headers["Accept"] = "*/*"
|
||||
self.base_headers['user-agent'] = 'Mozilla/5.0 (X11; Linux x86_64; rv:138.0) Gecko/20100101 Firefox/138.0'
|
||||
|
||||
return {
|
||||
"destination_url": best,
|
||||
"request_headers": self.base_headers,
|
||||
"mediaflow_endpoint": self.mediaflow_endpoint,
|
||||
}
|
||||
@@ -1,48 +1,121 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Dict, Optional, Any
|
||||
|
||||
import asyncio
|
||||
import httpx
|
||||
import logging
|
||||
|
||||
from mediaflow_proxy.configs import settings
|
||||
from mediaflow_proxy.utils.http_utils import create_httpx_client
|
||||
from mediaflow_proxy.utils.http_utils import create_httpx_client, DownloadError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ExtractorError(Exception):
|
||||
"""Base exception for all extractors."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class BaseExtractor(ABC):
|
||||
"""Base class for all URL extractors."""
|
||||
"""Base class for all URL extractors.
|
||||
|
||||
Improvements:
|
||||
- Built-in retry/backoff for transient network errors
|
||||
- Configurable timeouts and per-request overrides
|
||||
- Better logging of non-200 responses and body previews for debugging
|
||||
"""
|
||||
|
||||
def __init__(self, request_headers: dict):
|
||||
self.base_headers = {
|
||||
"user-agent": settings.user_agent,
|
||||
}
|
||||
self.mediaflow_endpoint = "proxy_stream_endpoint"
|
||||
self.base_headers.update(request_headers)
|
||||
# merge incoming headers (e.g. Accept-Language / Referer) with default base headers
|
||||
self.base_headers.update(request_headers or {})
|
||||
|
||||
async def _make_request(
|
||||
self, url: str, method: str = "GET", headers: Optional[Dict] = None, **kwargs
|
||||
self,
|
||||
url: str,
|
||||
method: str = "GET",
|
||||
headers: Optional[Dict] = None,
|
||||
timeout: Optional[float] = None,
|
||||
retries: int = 3,
|
||||
backoff_factor: float = 0.5,
|
||||
raise_on_status: bool = True,
|
||||
**kwargs,
|
||||
) -> httpx.Response:
|
||||
"""Make HTTP request with error handling."""
|
||||
try:
|
||||
async with create_httpx_client() as client:
|
||||
request_headers = self.base_headers.copy()
|
||||
request_headers.update(headers or {})
|
||||
response = await client.request(
|
||||
method,
|
||||
url,
|
||||
headers=request_headers,
|
||||
**kwargs,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response
|
||||
except httpx.HTTPError as e:
|
||||
raise ExtractorError(f"HTTP request failed for URL {url}: {str(e)}")
|
||||
except Exception as e:
|
||||
raise ExtractorError(f"Request failed for URL {url}: {str(e)}")
|
||||
"""
|
||||
Make HTTP request with retry and timeout support.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
timeout : float | None
|
||||
Seconds to wait for the request (applied to httpx.Timeout). Defaults to 15s.
|
||||
retries : int
|
||||
Number of attempts for transient errors.
|
||||
backoff_factor : float
|
||||
Base for exponential backoff between retries.
|
||||
raise_on_status : bool
|
||||
If True, HTTP non-2xx raises DownloadError (preserves status code).
|
||||
"""
|
||||
attempt = 0
|
||||
last_exc = None
|
||||
|
||||
# build request headers merging base and per-request
|
||||
request_headers = self.base_headers.copy()
|
||||
if headers:
|
||||
request_headers.update(headers)
|
||||
|
||||
timeout_cfg = httpx.Timeout(timeout or 15.0)
|
||||
|
||||
while attempt < retries:
|
||||
try:
|
||||
async with create_httpx_client(timeout=timeout_cfg) as client:
|
||||
response = await client.request(
|
||||
method,
|
||||
url,
|
||||
headers=request_headers,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
if raise_on_status:
|
||||
try:
|
||||
response.raise_for_status()
|
||||
except httpx.HTTPStatusError as e:
|
||||
# Provide a short body preview for debugging
|
||||
body_preview = ""
|
||||
try:
|
||||
body_preview = e.response.text[:500]
|
||||
except Exception:
|
||||
body_preview = "<unreadable body>"
|
||||
logger.debug(
|
||||
"HTTPStatusError for %s (status=%s) -- body preview: %s",
|
||||
url,
|
||||
e.response.status_code,
|
||||
body_preview,
|
||||
)
|
||||
raise DownloadError(e.response.status_code, f"HTTP error {e.response.status_code} while requesting {url}")
|
||||
return response
|
||||
|
||||
except DownloadError:
|
||||
# Do not retry on explicit HTTP status errors (they are intentional)
|
||||
raise
|
||||
except (httpx.ReadTimeout, httpx.ConnectTimeout, httpx.NetworkError, httpx.TransportError) as e:
|
||||
# Transient network error — retry with backoff
|
||||
last_exc = e
|
||||
attempt += 1
|
||||
sleep_for = backoff_factor * (2 ** (attempt - 1))
|
||||
logger.warning("Transient network error (attempt %s/%s) for %s: %s — retrying in %.1fs",
|
||||
attempt, retries, url, e, sleep_for)
|
||||
await asyncio.sleep(sleep_for)
|
||||
continue
|
||||
except Exception as e:
|
||||
# Unexpected exception — wrap as ExtractorError to keep interface consistent
|
||||
logger.exception("Unhandled exception while requesting %s: %s", url, e)
|
||||
raise ExtractorError(f"Request failed for URL {url}: {str(e)}")
|
||||
|
||||
logger.error("All retries failed for %s: %s", url, last_exc)
|
||||
raise ExtractorError(f"Request failed for URL {url}: {str(last_exc)}")
|
||||
|
||||
@abstractmethod
|
||||
async def extract(self, url: str, **kwargs) -> Dict[str, Any]:
|
||||
|
||||
@@ -1,543 +1,332 @@
|
||||
import re
|
||||
import base64
|
||||
import logging
|
||||
from typing import Any, Dict, Optional
|
||||
from urllib.parse import urlparse, quote, urlunparse
|
||||
|
||||
from typing import Any, Dict, Optional, List
|
||||
from urllib.parse import urlparse, quote_plus, urljoin
|
||||
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
from mediaflow_proxy.extractors.base import BaseExtractor, ExtractorError
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Silenzia l'errore ConnectionResetError su Windows
|
||||
logging.getLogger('asyncio').setLevel(logging.CRITICAL)
|
||||
|
||||
|
||||
class DLHDExtractor(BaseExtractor):
|
||||
"""DLHD (DaddyLive) URL extractor for M3U8 streams."""
|
||||
"""DLHD (DaddyLive) URL extractor for M3U8 streams.
|
||||
|
||||
|
||||
Notes:
|
||||
- Multi-domain support for daddylive.sx / dlhd.dad
|
||||
- Robust extraction of auth parameters and server lookup
|
||||
- Uses retries/timeouts via BaseExtractor where possible
|
||||
- Multi-iframe fallback for resilience
|
||||
"""
|
||||
|
||||
|
||||
def __init__(self, request_headers: dict):
|
||||
super().__init__(request_headers)
|
||||
# Default to HLS proxy endpoint
|
||||
self.mediaflow_endpoint = "hls_manifest_proxy"
|
||||
# Cache for the resolved base URL to avoid repeated network calls
|
||||
self._cached_base_url = None
|
||||
# Store iframe context for newkso.ru requests
|
||||
self._iframe_context = None
|
||||
self._iframe_context: Optional[str] = None
|
||||
|
||||
def _get_headers_for_url(self, url: str, base_headers: dict) -> dict:
|
||||
"""Get appropriate headers for the given URL, applying newkso.ru specific headers if needed."""
|
||||
headers = base_headers.copy()
|
||||
|
||||
# Check if URL contains newkso.ru domain
|
||||
parsed_url = urlparse(url)
|
||||
if "newkso.ru" in parsed_url.netloc:
|
||||
# Use iframe URL as referer if available, otherwise use the newkso domain itself
|
||||
if self._iframe_context:
|
||||
iframe_origin = f"https://{urlparse(self._iframe_context).netloc}"
|
||||
newkso_headers = {
|
||||
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36',
|
||||
'Referer': self._iframe_context,
|
||||
'Origin': iframe_origin
|
||||
}
|
||||
logger.info(f"Applied newkso.ru specific headers with iframe context for URL: {url}")
|
||||
logger.debug(f"Headers applied: {newkso_headers}")
|
||||
else:
|
||||
# Fallback to newkso domain itself
|
||||
newkso_origin = f"{parsed_url.scheme}://{parsed_url.netloc}"
|
||||
newkso_headers = {
|
||||
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36',
|
||||
'Referer': newkso_origin,
|
||||
'Origin': newkso_origin
|
||||
}
|
||||
logger.info(f"Applied newkso.ru specific headers (fallback) for URL: {url}")
|
||||
logger.debug(f"Headers applied: {newkso_headers}")
|
||||
|
||||
|
||||
async def _make_request(self, url: str, method: str = "GET", headers: Optional[Dict] = None, **kwargs) -> Any:
|
||||
"""Override to disable SSL verification for this extractor and use fetch_with_retry if available."""
|
||||
from mediaflow_proxy.utils.http_utils import create_httpx_client, fetch_with_retry
|
||||
|
||||
|
||||
timeout = kwargs.pop("timeout", 15)
|
||||
retries = kwargs.pop("retries", 3)
|
||||
backoff_factor = kwargs.pop("backoff_factor", 0.5)
|
||||
|
||||
|
||||
async with create_httpx_client(verify=False, timeout=httpx.Timeout(timeout)) as client:
|
||||
try:
|
||||
return await fetch_with_retry(client, method, url, headers or {}, timeout=timeout)
|
||||
except Exception:
|
||||
logger.debug("fetch_with_retry failed or unavailable; falling back to direct request for %s", url)
|
||||
response = await client.request(method, url, headers=headers or {}, timeout=timeout)
|
||||
response.raise_for_status()
|
||||
return response
|
||||
|
||||
|
||||
async def _extract_lovecdn_stream(self, iframe_url: str, iframe_content: str, headers: dict) -> Dict[str, Any]:
|
||||
"""
|
||||
Estrattore alternativo per iframe lovecdn.ru che usa un formato diverso.
|
||||
"""
|
||||
try:
|
||||
# Cerca pattern di stream URL diretto
|
||||
m3u8_patterns = [
|
||||
r'["\']([^"\']*\.m3u8[^"\']*)["\']',
|
||||
r'source[:\s]+["\']([^"\']+)["\']',
|
||||
r'file[:\s]+["\']([^"\']+\.m3u8[^"\']*)["\']',
|
||||
r'hlsManifestUrl[:\s]*["\']([^"\']+)["\']',
|
||||
]
|
||||
|
||||
headers.update(newkso_headers)
|
||||
|
||||
return headers
|
||||
stream_url = None
|
||||
for pattern in m3u8_patterns:
|
||||
matches = re.findall(pattern, iframe_content)
|
||||
for match in matches:
|
||||
if '.m3u8' in match and match.startswith('http'):
|
||||
stream_url = match
|
||||
logger.info(f"Found direct m3u8 URL: {stream_url}")
|
||||
break
|
||||
if stream_url:
|
||||
break
|
||||
|
||||
# Pattern 2: Cerca costruzione dinamica URL
|
||||
if not stream_url:
|
||||
channel_match = re.search(r'(?:stream|channel)["\s:=]+["\']([^"\']+)["\']', iframe_content)
|
||||
server_match = re.search(r'(?:server|domain|host)["\s:=]+["\']([^"\']+)["\']', iframe_content)
|
||||
|
||||
if channel_match:
|
||||
channel_name = channel_match.group(1)
|
||||
server = server_match.group(1) if server_match else 'newkso.ru'
|
||||
stream_url = f"https://{server}/{channel_name}/mono.m3u8"
|
||||
logger.info(f"Constructed stream URL: {stream_url}")
|
||||
|
||||
if not stream_url:
|
||||
# Fallback: cerca qualsiasi URL che sembri uno stream
|
||||
url_pattern = r'https?://[^\s"\'<>]+\.m3u8[^\s"\'<>]*'
|
||||
matches = re.findall(url_pattern, iframe_content)
|
||||
if matches:
|
||||
stream_url = matches[0]
|
||||
logger.info(f"Found fallback stream URL: {stream_url}")
|
||||
|
||||
if not stream_url:
|
||||
raise ExtractorError(f"Could not find stream URL in lovecdn.ru iframe")
|
||||
|
||||
# Usa iframe URL come referer
|
||||
iframe_origin = f"https://{urlparse(iframe_url).netloc}"
|
||||
stream_headers = {
|
||||
'User-Agent': headers['User-Agent'],
|
||||
'Referer': iframe_url,
|
||||
'Origin': iframe_origin
|
||||
}
|
||||
|
||||
# Determina endpoint in base al dominio dello stream
|
||||
endpoint = "hls_key_proxy"
|
||||
|
||||
async def _make_request(self, url: str, method: str = "GET", headers: dict = None, **kwargs):
|
||||
"""Override _make_request to apply newkso.ru specific headers when needed."""
|
||||
request_headers = headers or {}
|
||||
logger.info(f"Using lovecdn.ru stream with endpoint: {endpoint}")
|
||||
|
||||
return {
|
||||
"destination_url": stream_url,
|
||||
"request_headers": stream_headers,
|
||||
"mediaflow_endpoint": endpoint,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise ExtractorError(f"Failed to extract lovecdn.ru stream: {e}")
|
||||
|
||||
async def _extract_new_auth_flow(self, iframe_url: str, iframe_content: str, headers: dict) -> Dict[str, Any]:
|
||||
"""Handles the new authentication flow found in recent updates."""
|
||||
|
||||
# Apply newkso.ru specific headers if the URL contains newkso.ru
|
||||
final_headers = self._get_headers_for_url(url, request_headers)
|
||||
def _extract_params(js: str) -> Dict[str, Optional[str]]:
|
||||
params = {}
|
||||
patterns = {
|
||||
"channel_key": r'(?:const|var|let)\s+(?:CHANNEL_KEY|channelKey)\s*=\s*["\']([^"\']+)["\']',
|
||||
"auth_token": r'(?:const|var|let)\s+AUTH_TOKEN\s*=\s*["\']([^"\']+)["\']',
|
||||
"auth_country": r'(?:const|var|let)\s+AUTH_COUNTRY\s*=\s*["\']([^"\']+)["\']',
|
||||
"auth_ts": r'(?:const|var|let)\s+AUTH_TS\s*=\s*["\']([^"\']+)["\']',
|
||||
"auth_expiry": r'(?:const|var|let)\s+AUTH_EXPIRY\s*=\s*["\']([^"\']+)["\']',
|
||||
}
|
||||
for key, pattern in patterns.items():
|
||||
match = re.search(pattern, js)
|
||||
params[key] = match.group(1) if match else None
|
||||
return params
|
||||
|
||||
params = _extract_params(iframe_content)
|
||||
|
||||
return await super()._make_request(url, method, final_headers, **kwargs)
|
||||
missing_params = [k for k, v in params.items() if not v]
|
||||
if missing_params:
|
||||
# This is not an error, just means it's not the new flow
|
||||
raise ExtractorError(f"Not the new auth flow: missing params {missing_params}")
|
||||
|
||||
logger.info("New auth flow detected. Proceeding with POST auth.")
|
||||
|
||||
# 1. Initial Auth POST
|
||||
auth_url = 'https://security.newkso.ru/auth2.php'
|
||||
# Use files parameter to force multipart/form-data which is required by the server
|
||||
# (None, value) tells httpx to send it as a form field, not a file upload
|
||||
multipart_data = {
|
||||
'channelKey': (None, params["channel_key"]),
|
||||
'country': (None, params["auth_country"]),
|
||||
'timestamp': (None, params["auth_ts"]),
|
||||
'expiry': (None, params["auth_expiry"]),
|
||||
'token': (None, params["auth_token"]),
|
||||
}
|
||||
|
||||
iframe_origin = f"https://{urlparse(iframe_url).netloc}"
|
||||
auth_headers = headers.copy()
|
||||
auth_headers.update({
|
||||
'Accept': '*/*',
|
||||
'Accept-Language': 'en-US,en;q=0.9',
|
||||
'Origin': iframe_origin,
|
||||
'Referer': iframe_url,
|
||||
'Sec-Fetch-Dest': 'empty',
|
||||
'Sec-Fetch-Mode': 'cors',
|
||||
'Sec-Fetch-Site': 'cross-site',
|
||||
'Priority': 'u=1, i',
|
||||
})
|
||||
|
||||
from mediaflow_proxy.utils.http_utils import create_httpx_client
|
||||
try:
|
||||
async with create_httpx_client(verify=False) as client:
|
||||
# Note: using 'files' instead of 'data' to ensure multipart/form-data Content-Type
|
||||
auth_resp = await client.post(auth_url, files=multipart_data, headers=auth_headers, timeout=12)
|
||||
auth_resp.raise_for_status()
|
||||
auth_data = auth_resp.json()
|
||||
if not (auth_data.get("valid") or auth_data.get("success")):
|
||||
raise ExtractorError(f"Initial auth failed with response: {auth_data}")
|
||||
logger.info("New auth flow: Initial auth successful.")
|
||||
except Exception as e:
|
||||
raise ExtractorError(f"New auth flow failed during initial auth POST: {e}")
|
||||
|
||||
# 2. Server Lookup
|
||||
server_lookup_url = f"https://{urlparse(iframe_url).netloc}/server_lookup.js?channel_id={params['channel_key']}"
|
||||
try:
|
||||
# Use _make_request as it handles retries and expects JSON
|
||||
lookup_resp = await self._make_request(server_lookup_url, headers=headers, timeout=10)
|
||||
server_data = lookup_resp.json()
|
||||
server_key = server_data.get('server_key')
|
||||
if not server_key:
|
||||
raise ExtractorError(f"No server_key in lookup response: {server_data}")
|
||||
logger.info(f"New auth flow: Server lookup successful - Server key: {server_key}")
|
||||
except Exception as e:
|
||||
raise ExtractorError(f"New auth flow failed during server lookup: {e}")
|
||||
|
||||
# 3. Build final stream URL
|
||||
channel_key = params['channel_key']
|
||||
auth_token = params['auth_token']
|
||||
# The JS logic uses .css, not .m3u8
|
||||
if server_key == 'top1/cdn':
|
||||
stream_url = f'https://top1.newkso.ru/top1/cdn/{channel_key}/mono.css'
|
||||
else:
|
||||
stream_url = f'https://{server_key}new.newkso.ru/{server_key}/{channel_key}/mono.css'
|
||||
|
||||
logger.info(f'New auth flow: Constructed stream URL: {stream_url}')
|
||||
|
||||
stream_headers = {
|
||||
'User-Agent': headers['User-Agent'],
|
||||
'Referer': iframe_url,
|
||||
'Origin': iframe_origin,
|
||||
'Authorization': f'Bearer {auth_token}',
|
||||
'X-Channel-Key': channel_key
|
||||
}
|
||||
|
||||
return {
|
||||
"destination_url": stream_url,
|
||||
"request_headers": stream_headers,
|
||||
"mediaflow_endpoint": "hls_manifest_proxy",
|
||||
}
|
||||
|
||||
async def extract(self, url: str, **kwargs) -> Dict[str, Any]:
|
||||
"""Extract DLHD stream URL and required headers (logica tvproxy adattata async, con fallback su endpoint alternativi)."""
|
||||
from urllib.parse import urlparse, quote_plus
|
||||
"""Main extraction flow: resolve base, fetch players, extract iframe, auth and final m3u8."""
|
||||
baseurl = "https://dlhd.dad/"
|
||||
|
||||
async def get_daddylive_base_url():
|
||||
if self._cached_base_url:
|
||||
return self._cached_base_url
|
||||
try:
|
||||
resp = await self._make_request("https://daddylive.sx/")
|
||||
# resp.url is the final URL after redirects
|
||||
base_url = str(resp.url)
|
||||
if not base_url.endswith('/'):
|
||||
base_url += '/'
|
||||
self._cached_base_url = base_url
|
||||
return base_url
|
||||
except Exception:
|
||||
# Fallback to default if request fails
|
||||
return "https://daddylive.sx/"
|
||||
|
||||
def extract_channel_id(url):
|
||||
match_premium = re.search(r'/premium(\d+)/mono\.m3u8$', url)
|
||||
if match_premium:
|
||||
return match_premium.group(1)
|
||||
# Handle both normal and URL-encoded patterns
|
||||
match_player = re.search(r'/(?:watch|stream|cast|player)/stream-(\d+)\.php', url)
|
||||
if match_player:
|
||||
return match_player.group(1)
|
||||
# Handle URL-encoded patterns like %2Fstream%2Fstream-123.php or just stream-123.php
|
||||
match_encoded = re.search(r'(?:%2F|/)stream-(\d+)\.php', url, re.IGNORECASE)
|
||||
if match_encoded:
|
||||
return match_encoded.group(1)
|
||||
# Handle direct stream- pattern without path
|
||||
match_direct = re.search(r'stream-(\d+)\.php', url)
|
||||
if match_direct:
|
||||
return match_direct.group(1)
|
||||
def extract_channel_id(u: str) -> Optional[str]:
|
||||
match_watch_id = re.search(r'watch\.php\?id=(\d+)', u)
|
||||
if match_watch_id:
|
||||
return match_watch_id.group(1)
|
||||
return None
|
||||
|
||||
async def try_endpoint(baseurl, endpoint, channel_id):
|
||||
stream_url = f"{baseurl}{endpoint}stream-{channel_id}.php"
|
||||
|
||||
async def get_stream_data(initial_url: str):
|
||||
daddy_origin = urlparse(baseurl).scheme + "://" + urlparse(baseurl).netloc
|
||||
daddylive_headers = {
|
||||
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36',
|
||||
'Referer': baseurl,
|
||||
'Origin': daddy_origin
|
||||
}
|
||||
# 1. Richiesta alla pagina stream/cast/player/watch
|
||||
resp1 = await self._make_request(stream_url, headers=daddylive_headers)
|
||||
# 2. Estrai link Player 2
|
||||
iframes = re.findall(r'<a[^>]*href="([^"]+)"[^>]*>\s*<button[^>]*>\s*Player\s*2\s*</button>', resp1.text)
|
||||
if not iframes:
|
||||
raise ExtractorError("No Player 2 link found")
|
||||
url2 = iframes[0]
|
||||
url2 = baseurl + url2
|
||||
url2 = url2.replace('//cast', '/cast')
|
||||
daddylive_headers['Referer'] = url2
|
||||
daddylive_headers['Origin'] = url2
|
||||
# 3. Richiesta alla pagina Player 2
|
||||
resp2 = await self._make_request(url2, headers=daddylive_headers)
|
||||
# 4. Estrai iframe
|
||||
iframes2 = re.findall(r'iframe src="([^"]*)', resp2.text)
|
||||
if not iframes2:
|
||||
raise ExtractorError("No iframe found in Player 2 page")
|
||||
iframe_url = iframes2[0]
|
||||
# Store iframe context for newkso.ru requests
|
||||
self._iframe_context = iframe_url
|
||||
resp3 = await self._make_request(iframe_url, headers=daddylive_headers)
|
||||
iframe_content = resp3.text
|
||||
# 5. Estrai parametri auth (robusto) - Handle both old and new formats
|
||||
def extract_var_old_format(js, name):
|
||||
# Try multiple patterns for variable extraction (old format)
|
||||
patterns = [
|
||||
rf'var (?:__)?{name}\s*=\s*atob\("([^"]+)"\)',
|
||||
rf'var (?:__)?{name}\s*=\s*atob\(\'([^\']+)\'\)',
|
||||
rf'(?:var\s+)?(?:__)?{name}\s*=\s*atob\s*\(\s*["\']([^"\']+)["\']\s*\)',
|
||||
rf'(?:let|const)\s+(?:__)?{name}\s*=\s*atob\s*\(\s*["\']([^"\']+)["\']\s*\)'
|
||||
]
|
||||
for pattern in patterns:
|
||||
m = re.search(pattern, js)
|
||||
if m:
|
||||
try:
|
||||
return base64.b64decode(m.group(1)).decode('utf-8')
|
||||
except Exception as decode_error:
|
||||
logger.warning(f"Failed to decode base64 for variable {name}: {decode_error}")
|
||||
continue
|
||||
return None
|
||||
|
||||
def extract_xjz_format(js):
|
||||
"""Extract parameters from the new XJZ base64-encoded JSON format."""
|
||||
|
||||
|
||||
# 1. Request initial page
|
||||
resp1 = await self._make_request(initial_url, headers=daddylive_headers, timeout=15)
|
||||
player_links = re.findall(r'<button[^>]*data-url="([^"]+)"[^>]*>Player\s*\d+</button>', resp1.text)
|
||||
if not player_links:
|
||||
raise ExtractorError("No player links found on the page.")
|
||||
|
||||
|
||||
# Prova tutti i player e raccogli tutti gli iframe validi
|
||||
last_player_error = None
|
||||
iframe_candidates = []
|
||||
|
||||
for player_url in player_links:
|
||||
try:
|
||||
# Look for the XJZ variable assignment
|
||||
xjz_pattern = r'const\s+XJZ\s*=\s*["\']([^"\']+)["\']'
|
||||
match = re.search(xjz_pattern, js)
|
||||
if not match:
|
||||
return None
|
||||
xjz_b64 = match.group(1)
|
||||
import json
|
||||
# Decode the first base64 layer (JSON)
|
||||
xjz_json = base64.b64decode(xjz_b64).decode('utf-8')
|
||||
xjz_obj = json.loads(xjz_json)
|
||||
# Each value is also base64-encoded, decode each
|
||||
decoded = {}
|
||||
for k, v in xjz_obj.items():
|
||||
try:
|
||||
decoded[k] = base64.b64decode(v).decode('utf-8')
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to decode XJZ field {k}: {e}")
|
||||
decoded[k] = v
|
||||
return decoded
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to extract XJZ format: {e}")
|
||||
return None
|
||||
if not player_url.startswith('http'):
|
||||
player_url = baseurl + player_url.lstrip('/')
|
||||
|
||||
def extract_bundle_format(js):
|
||||
"""Extract parameters from new BUNDLE format (legacy fallback)."""
|
||||
|
||||
daddylive_headers['Referer'] = player_url
|
||||
daddylive_headers['Origin'] = player_url
|
||||
resp2 = await self._make_request(player_url, headers=daddylive_headers, timeout=12)
|
||||
iframes2 = re.findall(r'<iframe.*?src="([^"]*)"', resp2.text)
|
||||
|
||||
# Raccogli tutti gli iframe trovati
|
||||
for iframe in iframes2:
|
||||
if iframe not in iframe_candidates:
|
||||
iframe_candidates.append(iframe)
|
||||
logger.info(f"Found iframe candidate: {iframe}")
|
||||
|
||||
except Exception as e:
|
||||
last_player_error = e
|
||||
logger.warning(f"Failed to process player link {player_url}: {e}")
|
||||
continue
|
||||
|
||||
|
||||
if not iframe_candidates:
|
||||
if last_player_error:
|
||||
raise ExtractorError(f"All player links failed. Last error: {last_player_error}")
|
||||
raise ExtractorError("No valid iframe found in any player page")
|
||||
|
||||
|
||||
# Prova ogni iframe finché uno non funziona
|
||||
last_iframe_error = None
|
||||
|
||||
for iframe_candidate in iframe_candidates:
|
||||
try:
|
||||
bundle_patterns = [
|
||||
r'const\s+BUNDLE\s*=\s*["\']([^"\']+)["\']',
|
||||
r'var\s+BUNDLE\s*=\s*["\']([^"\']+)["\']',
|
||||
r'let\s+BUNDLE\s*=\s*["\']([^"\']+)["\']'
|
||||
]
|
||||
bundle_data = None
|
||||
for pattern in bundle_patterns:
|
||||
match = re.search(pattern, js)
|
||||
if match:
|
||||
bundle_data = match.group(1)
|
||||
break
|
||||
if not bundle_data:
|
||||
return None
|
||||
import json
|
||||
bundle_json = base64.b64decode(bundle_data).decode('utf-8')
|
||||
bundle_obj = json.loads(bundle_json)
|
||||
decoded_bundle = {}
|
||||
for key, value in bundle_obj.items():
|
||||
try:
|
||||
decoded_bundle[key] = base64.b64decode(value).decode('utf-8')
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to decode bundle field {key}: {e}")
|
||||
decoded_bundle[key] = value
|
||||
return decoded_bundle
|
||||
logger.info(f"Trying iframe: {iframe_candidate}")
|
||||
|
||||
iframe_domain = urlparse(iframe_candidate).netloc
|
||||
if not iframe_domain:
|
||||
logger.warning(f"Invalid iframe URL format: {iframe_candidate}")
|
||||
continue
|
||||
|
||||
self._iframe_context = iframe_candidate
|
||||
resp3 = await self._make_request(iframe_candidate, headers=daddylive_headers, timeout=12)
|
||||
iframe_content = resp3.text
|
||||
logger.info(f"Successfully loaded iframe from: {iframe_domain}")
|
||||
|
||||
if 'lovecdn.ru' in iframe_domain:
|
||||
logger.info("Detected lovecdn.ru iframe - using alternative extraction")
|
||||
return await self._extract_lovecdn_stream(iframe_candidate, iframe_content, daddylive_headers)
|
||||
else:
|
||||
logger.info("Attempting new auth flow extraction.")
|
||||
return await self._extract_new_auth_flow(iframe_candidate, iframe_content, daddylive_headers)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to extract bundle format: {e}")
|
||||
return None
|
||||
|
||||
# Try multiple patterns for channel key extraction
|
||||
channel_key = None
|
||||
channel_key_patterns = [
|
||||
r'const\s+CHANNEL_KEY\s*=\s*["\']([^"\']+)["\']',
|
||||
r'var\s+CHANNEL_KEY\s*=\s*["\']([^"\']+)["\']',
|
||||
r'let\s+CHANNEL_KEY\s*=\s*["\']([^"\']+)["\']',
|
||||
r'channelKey\s*=\s*["\']([^"\']+)["\']',
|
||||
r'var\s+channelKey\s*=\s*["\']([^"\']+)["\']',
|
||||
r'(?:let|const)\s+channelKey\s*=\s*["\']([^"\']+)["\']'
|
||||
]
|
||||
for pattern in channel_key_patterns:
|
||||
match = re.search(pattern, iframe_content)
|
||||
if match:
|
||||
channel_key = match.group(1)
|
||||
break
|
||||
|
||||
# Try new XJZ format first
|
||||
xjz_data = extract_xjz_format(iframe_content)
|
||||
if xjz_data:
|
||||
logger.info("Using new XJZ format for parameter extraction")
|
||||
auth_host = xjz_data.get('b_host')
|
||||
auth_php = xjz_data.get('b_script')
|
||||
auth_ts = xjz_data.get('b_ts')
|
||||
auth_rnd = xjz_data.get('b_rnd')
|
||||
auth_sig = xjz_data.get('b_sig')
|
||||
logger.debug(f"XJZ data extracted: {xjz_data}")
|
||||
else:
|
||||
# Try bundle format (legacy fallback)
|
||||
bundle_data = extract_bundle_format(iframe_content)
|
||||
if bundle_data:
|
||||
logger.info("Using BUNDLE format for parameter extraction")
|
||||
auth_host = bundle_data.get('b_host')
|
||||
auth_php = bundle_data.get('b_script')
|
||||
auth_ts = bundle_data.get('b_ts')
|
||||
auth_rnd = bundle_data.get('b_rnd')
|
||||
auth_sig = bundle_data.get('b_sig')
|
||||
logger.debug(f"Bundle data extracted: {bundle_data}")
|
||||
else:
|
||||
logger.info("Falling back to old format for parameter extraction")
|
||||
# Fall back to old format
|
||||
auth_ts = extract_var_old_format(iframe_content, 'c')
|
||||
auth_rnd = extract_var_old_format(iframe_content, 'd')
|
||||
auth_sig = extract_var_old_format(iframe_content, 'e')
|
||||
auth_host = extract_var_old_format(iframe_content, 'a')
|
||||
auth_php = extract_var_old_format(iframe_content, 'b')
|
||||
logger.warning(f"Failed to process iframe {iframe_candidate}: {e}")
|
||||
last_iframe_error = e
|
||||
continue
|
||||
|
||||
# Log what we found for debugging
|
||||
logger.debug(f"Extracted parameters: channel_key={channel_key}, auth_ts={auth_ts}, auth_rnd={auth_rnd}, auth_sig={auth_sig}, auth_host={auth_host}, auth_php={auth_php}")
|
||||
raise ExtractorError(f"All iframe candidates failed. Last error: {last_iframe_error}")
|
||||
|
||||
# Check which parameters are missing
|
||||
missing_params = []
|
||||
if not channel_key:
|
||||
missing_params.append('channel_key/CHANNEL_KEY')
|
||||
if not auth_ts:
|
||||
missing_params.append('auth_ts (var c / b_ts)')
|
||||
if not auth_rnd:
|
||||
missing_params.append('auth_rnd (var d / b_rnd)')
|
||||
if not auth_sig:
|
||||
missing_params.append('auth_sig (var e / b_sig)')
|
||||
if not auth_host:
|
||||
missing_params.append('auth_host (var a / b_host)')
|
||||
if not auth_php:
|
||||
missing_params.append('auth_php (var b / b_script)')
|
||||
|
||||
if missing_params:
|
||||
logger.error(f"Missing parameters: {', '.join(missing_params)}")
|
||||
# Log a portion of the iframe content for debugging (first 2000 chars)
|
||||
logger.debug(f"Iframe content sample: {iframe_content[:2000]}")
|
||||
raise ExtractorError(f"Error extracting parameters: missing {', '.join(missing_params)}")
|
||||
auth_sig = quote_plus(auth_sig)
|
||||
# 6. Richiesta auth
|
||||
# Se il sito fornisce ancora /a.php ma ora serve /auth.php, sostituisci
|
||||
# Normalize and robustly replace any variant of a.php with /auth.php
|
||||
if auth_php:
|
||||
normalized_auth_php = auth_php.strip().lstrip('/')
|
||||
if normalized_auth_php == 'a.php':
|
||||
logger.info("Sostituisco qualunque variante di a.php con /auth.php per compatibilità.")
|
||||
auth_php = '/auth.php'
|
||||
# Unisci host e script senza doppio slash
|
||||
if auth_host.endswith('/') and auth_php.startswith('/'):
|
||||
auth_url = f'{auth_host[:-1]}{auth_php}'
|
||||
elif not auth_host.endswith('/') and not auth_php.startswith('/'):
|
||||
auth_url = f'{auth_host}/{auth_php}'
|
||||
else:
|
||||
auth_url = f'{auth_host}{auth_php}'
|
||||
auth_url = f'{auth_url}?channel_id={channel_key}&ts={auth_ts}&rnd={auth_rnd}&sig={auth_sig}'
|
||||
auth_resp = await self._make_request(auth_url, headers=daddylive_headers)
|
||||
# 7. Lookup server - Extract host parameter
|
||||
host = None
|
||||
host_patterns = [
|
||||
r'(?s)m3u8 =.*?:.*?:.*?".*?".*?"([^"]*)', # Original pattern
|
||||
r'm3u8\s*=.*?"([^"]*)"', # Simplified m3u8 pattern
|
||||
r'host["\']?\s*[:=]\s*["\']([^"\']*)', # host: or host= pattern
|
||||
r'["\']([^"\']*\.newkso\.ru[^"\']*)', # Direct newkso.ru pattern
|
||||
r'["\']([^"\']*\/premium\d+[^"\']*)', # premium path pattern
|
||||
r'url.*?["\']([^"\']*newkso[^"\']*)', # URL with newkso
|
||||
]
|
||||
|
||||
for pattern in host_patterns:
|
||||
matches = re.findall(pattern, iframe_content)
|
||||
if matches:
|
||||
host = matches[0]
|
||||
logger.debug(f"Found host with pattern '{pattern}': {host}")
|
||||
break
|
||||
|
||||
if not host:
|
||||
logger.error("Failed to extract host from iframe content")
|
||||
logger.debug(f"Iframe content for host extraction: {iframe_content[:2000]}")
|
||||
# Try to find any newkso.ru related URLs
|
||||
potential_hosts = re.findall(r'["\']([^"\']*newkso[^"\']*)', iframe_content)
|
||||
if potential_hosts:
|
||||
logger.debug(f"Potential host URLs found: {potential_hosts}")
|
||||
raise ExtractorError("Failed to extract host parameter")
|
||||
|
||||
# Extract server lookup URL from fetchWithRetry call (dynamic extraction)
|
||||
server_lookup = None
|
||||
|
||||
# Look for the server_lookup.php pattern in JavaScript
|
||||
if "fetchWithRetry('/server_lookup.php?channel_id='" in iframe_content:
|
||||
server_lookup = '/server_lookup.php?channel_id='
|
||||
logger.debug('Found server lookup URL: /server_lookup.php?channel_id=')
|
||||
elif '/server_lookup.php' in iframe_content:
|
||||
# Try to extract the full path
|
||||
js_lines = iframe_content.split('\n')
|
||||
for js_line in js_lines:
|
||||
if 'server_lookup.php' in js_line and 'fetchWithRetry' in js_line:
|
||||
# Extract the URL from the fetchWithRetry call
|
||||
start = js_line.find("'")
|
||||
if start != -1:
|
||||
end = js_line.find("'", start + 1)
|
||||
if end != -1:
|
||||
potential_url = js_line[start+1:end]
|
||||
if 'server_lookup' in potential_url:
|
||||
server_lookup = potential_url
|
||||
logger.debug(f'Extracted server lookup URL: {server_lookup}')
|
||||
break
|
||||
|
||||
if not server_lookup:
|
||||
logger.error('Failed to extract server lookup URL from iframe content')
|
||||
logger.debug(f'Iframe content sample: {iframe_content[:2000]}')
|
||||
raise ExtractorError('Failed to extract server lookup URL')
|
||||
|
||||
server_lookup_url = f"https://{urlparse(iframe_url).netloc}{server_lookup}{channel_key}"
|
||||
logger.debug(f"Server lookup URL: {server_lookup_url}")
|
||||
|
||||
try:
|
||||
lookup_resp = await self._make_request(server_lookup_url, headers=daddylive_headers)
|
||||
server_data = lookup_resp.json()
|
||||
server_key = server_data.get('server_key')
|
||||
if not server_key:
|
||||
logger.error(f"No server_key in response: {server_data}")
|
||||
raise ExtractorError("Failed to get server key from lookup response")
|
||||
|
||||
logger.info(f"Server lookup successful - Server key: {server_key}")
|
||||
except Exception as lookup_error:
|
||||
logger.error(f"Server lookup request failed: {lookup_error}")
|
||||
raise ExtractorError(f"Server lookup failed: {str(lookup_error)}")
|
||||
|
||||
referer_raw = f'https://{urlparse(iframe_url).netloc}'
|
||||
|
||||
# Extract URL construction logic dynamically from JavaScript
|
||||
# Simple approach: look for newkso.ru URLs and construct based on server_key
|
||||
|
||||
# Check if we have the special case server_key
|
||||
if server_key == 'top1/cdn':
|
||||
clean_m3u8_url = f'https://top1.newkso.ru/top1/cdn/{channel_key}/mono.m3u8'
|
||||
logger.info(f'Using special case URL for server_key \'top1/cdn\': {clean_m3u8_url}')
|
||||
else:
|
||||
clean_m3u8_url = f'https://{server_key}new.newkso.ru/{server_key}/{channel_key}/mono.m3u8'
|
||||
logger.info(f'Using general case URL for server_key \'{server_key}\': {clean_m3u8_url}')
|
||||
|
||||
logger.info(f'Generated stream URL: {clean_m3u8_url}')
|
||||
logger.debug(f'Server key: {server_key}, Channel key: {channel_key}')
|
||||
|
||||
# Check if the final stream URL is on newkso.ru domain
|
||||
if "newkso.ru" in clean_m3u8_url:
|
||||
# For newkso.ru streams, use iframe URL as referer
|
||||
stream_headers = {
|
||||
'User-Agent': daddylive_headers['User-Agent'],
|
||||
'Referer': iframe_url,
|
||||
'Origin': referer_raw
|
||||
}
|
||||
logger.info(f"Applied iframe-specific headers for newkso.ru stream URL: {clean_m3u8_url}")
|
||||
logger.debug(f"Stream headers for newkso.ru: {stream_headers}")
|
||||
else:
|
||||
# For other domains, use the original logic
|
||||
stream_headers = {
|
||||
'User-Agent': daddylive_headers['User-Agent'],
|
||||
'Referer': referer_raw,
|
||||
'Origin': referer_raw
|
||||
}
|
||||
return {
|
||||
"destination_url": clean_m3u8_url,
|
||||
"request_headers": stream_headers,
|
||||
"mediaflow_endpoint": self.mediaflow_endpoint,
|
||||
}
|
||||
|
||||
try:
|
||||
clean_url = url
|
||||
channel_id = extract_channel_id(clean_url)
|
||||
channel_id = extract_channel_id(url)
|
||||
if not channel_id:
|
||||
raise ExtractorError(f"Unable to extract channel ID from {clean_url}")
|
||||
raise ExtractorError(f"Unable to extract channel ID from {url}")
|
||||
|
||||
logger.info(f"Using base domain: {baseurl}")
|
||||
return await get_stream_data(url)
|
||||
|
||||
|
||||
baseurl = await get_daddylive_base_url()
|
||||
endpoints = ["stream/", "cast/", "player/", "watch/"]
|
||||
last_exc = None
|
||||
for endpoint in endpoints:
|
||||
try:
|
||||
return await try_endpoint(baseurl, endpoint, channel_id)
|
||||
except Exception as exc:
|
||||
last_exc = exc
|
||||
continue
|
||||
raise ExtractorError(f"Extraction failed: {str(last_exc)}")
|
||||
except Exception as e:
|
||||
raise ExtractorError(f"Extraction failed: {str(e)}")
|
||||
|
||||
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_data = server_response.json()
|
||||
server_key = server_data.get("server_key")
|
||||
|
||||
if not server_key:
|
||||
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]
|
||||
|
||||
# Generate the m3u8 URL based on server response pattern
|
||||
if "/" in server_key:
|
||||
# Handle special case like "top1/cdn"
|
||||
parts = server_key.split("/")
|
||||
return f"https://{parts[0]}.{domain_suffix}/{server_key}/{auth_data['channel_key']}/mono.m3u8"
|
||||
else:
|
||||
# Handle normal case
|
||||
return f"https://{server_key}new.{domain_suffix}/{server_key}/{auth_data['channel_key']}/mono.m3u8"
|
||||
|
||||
except Exception as e:
|
||||
raise ExtractorError(f"Server lookup failed: {str(e)}")
|
||||
|
||||
def _extract_auth_data(self, html_content: str) -> Dict[str, str]:
|
||||
"""Extract authentication data from player page."""
|
||||
try:
|
||||
channel_key_match = re.search(r'var\s+channelKey\s*=\s*["\']([^"\']+)["\']', html_content)
|
||||
if not channel_key_match:
|
||||
return {}
|
||||
channel_key = channel_key_match.group(1)
|
||||
|
||||
# New pattern with atob
|
||||
auth_ts_match = re.search(r'var\s+__c\s*=\s*atob\([\'"]([^\'"]+)[\'"]\)', html_content)
|
||||
auth_rnd_match = re.search(r'var\s+__d\s*=\s*atob\([\'"]([^\'"]+)[\'"]\)', html_content)
|
||||
auth_sig_match = re.search(r'var\s+__e\s*=\s*atob\([\'"]([^\'"]+)[\'"]\)', html_content)
|
||||
|
||||
if auth_ts_match and auth_rnd_match and auth_sig_match:
|
||||
return {
|
||||
"channel_key": channel_key,
|
||||
"auth_ts": base64.b64decode(auth_ts_match.group(1)).decode("utf-8"),
|
||||
"auth_rnd": base64.b64decode(auth_rnd_match.group(1)).decode("utf-8"),
|
||||
"auth_sig": base64.b64decode(auth_sig_match.group(1)).decode("utf-8"),
|
||||
}
|
||||
|
||||
# Original pattern
|
||||
auth_ts_match = re.search(r'var\s+authTs\s*=\s*["\']([^"\']+)["\']', html_content)
|
||||
auth_rnd_match = re.search(r'var\s+authRnd\s*=\s*["\']([^"\']+)["\']', html_content)
|
||||
auth_sig_match = re.search(r'var\s+authSig\s*=\s*["\']([^"\']+)["\']', html_content)
|
||||
|
||||
if auth_ts_match and auth_rnd_match and auth_sig_match:
|
||||
return {
|
||||
"channel_key": channel_key,
|
||||
"auth_ts": auth_ts_match.group(1),
|
||||
"auth_rnd": auth_rnd_match.group(1),
|
||||
"auth_sig": auth_sig_match.group(1),
|
||||
}
|
||||
return {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
def _extract_auth_url_base(self, html_content: str) -> Optional[str]:
|
||||
"""Extract auth URL base from player page script content."""
|
||||
try:
|
||||
# New atob pattern for auth base URL
|
||||
auth_url_base_match = re.search(r'var\s+__a\s*=\s*atob\([\'"]([^\'"]+)[\'"]\)', html_content)
|
||||
if auth_url_base_match:
|
||||
decoded_url = base64.b64decode(auth_url_base_match.group(1)).decode("utf-8")
|
||||
return decoded_url.strip().rstrip("/")
|
||||
|
||||
# Look for auth URL or domain in fetchWithRetry call or similar patterns
|
||||
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]
|
||||
|
||||
# Try finding domain directly
|
||||
domain_match = re.search(r'[\'"]https://([^/\'\"]+)(?:/[^\'\"]*)?/auth\.php', html_content)
|
||||
|
||||
if domain_match:
|
||||
return f"https://{domain_match.group(1)}"
|
||||
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _get_origin(self, url: str) -> str:
|
||||
"""Extract origin from URL."""
|
||||
parsed = urlparse(url)
|
||||
return f"{parsed.scheme}://{parsed.netloc}"
|
||||
|
||||
def _derive_auth_url_base(self, player_domain: str) -> Optional[str]:
|
||||
"""Attempt to derive auth URL base from player domain."""
|
||||
try:
|
||||
# Typical pattern is to use a subdomain for auth domain
|
||||
parsed = urlparse(player_domain)
|
||||
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:])
|
||||
# Try common subdomains for auth
|
||||
for prefix in ["auth", "api", "cdn"]:
|
||||
potential_auth_domain = f"https://{prefix}.{base_domain}"
|
||||
return potential_auth_domain
|
||||
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
@@ -3,17 +3,27 @@ from typing import Dict, Type
|
||||
from mediaflow_proxy.extractors.base import BaseExtractor, ExtractorError
|
||||
from mediaflow_proxy.extractors.dlhd import DLHDExtractor
|
||||
from mediaflow_proxy.extractors.doodstream import DoodStreamExtractor
|
||||
from mediaflow_proxy.extractors.sportsonline import SportsonlineExtractor
|
||||
from mediaflow_proxy.extractors.filelions import FileLionsExtractor
|
||||
from mediaflow_proxy.extractors.filemoon import FileMoonExtractor
|
||||
from mediaflow_proxy.extractors.F16Px import F16PxExtractor
|
||||
from mediaflow_proxy.extractors.livetv import LiveTVExtractor
|
||||
from mediaflow_proxy.extractors.lulustream import LuluStreamExtractor
|
||||
from mediaflow_proxy.extractors.maxstream import MaxstreamExtractor
|
||||
from mediaflow_proxy.extractors.mixdrop import MixdropExtractor
|
||||
from mediaflow_proxy.extractors.okru import OkruExtractor
|
||||
from mediaflow_proxy.extractors.streamtape import StreamtapeExtractor
|
||||
from mediaflow_proxy.extractors.streamwish import StreamWishExtractor
|
||||
from mediaflow_proxy.extractors.supervideo import SupervideoExtractor
|
||||
from mediaflow_proxy.extractors.turbovidplay import TurboVidPlayExtractor
|
||||
from mediaflow_proxy.extractors.uqload import UqloadExtractor
|
||||
from mediaflow_proxy.extractors.vavoo import VavooExtractor
|
||||
from mediaflow_proxy.extractors.vidmoly import VidmolyExtractor
|
||||
from mediaflow_proxy.extractors.vidoza import VidozaExtractor
|
||||
from mediaflow_proxy.extractors.vixcloud import VixCloudExtractor
|
||||
from mediaflow_proxy.extractors.fastream import FastreamExtractor
|
||||
from mediaflow_proxy.extractors.voe import VoeExtractor
|
||||
|
||||
|
||||
class ExtractorFactory:
|
||||
"""Factory for creating URL extractors."""
|
||||
@@ -21,17 +31,26 @@ class ExtractorFactory:
|
||||
_extractors: Dict[str, Type[BaseExtractor]] = {
|
||||
"Doodstream": DoodStreamExtractor,
|
||||
"FileLions": FileLionsExtractor,
|
||||
"FileMoon": FileMoonExtractor,
|
||||
"F16Px": F16PxExtractor,
|
||||
"Uqload": UqloadExtractor,
|
||||
"Mixdrop": MixdropExtractor,
|
||||
"Streamtape": StreamtapeExtractor,
|
||||
"StreamWish": StreamWishExtractor,
|
||||
"Supervideo": SupervideoExtractor,
|
||||
"TurboVidPlay": TurboVidPlayExtractor,
|
||||
"VixCloud": VixCloudExtractor,
|
||||
"Okru": OkruExtractor,
|
||||
"Maxstream": MaxstreamExtractor,
|
||||
"LiveTV": LiveTVExtractor,
|
||||
"LuluStream": LuluStreamExtractor,
|
||||
"DLHD": DLHDExtractor,
|
||||
"Vavoo": VavooExtractor,
|
||||
"Fastream": FastreamExtractor
|
||||
"Vidmoly": VidmolyExtractor,
|
||||
"Vidoza": VidozaExtractor,
|
||||
"Fastream": FastreamExtractor,
|
||||
"Voe": VoeExtractor,
|
||||
"Sportsonline": SportsonlineExtractor,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
|
||||
@@ -12,7 +12,8 @@ class FileLionsExtractor(BaseExtractor):
|
||||
headers = {}
|
||||
patterns = [ # See https://github.com/Gujal00/ResolveURL/blob/master/script.module.resolveurl/lib/resolveurl/plugins/filelions.py
|
||||
r'''sources:\s*\[{file:\s*["'](?P<url>[^"']+)''',
|
||||
r'''["']hls[24]["']:\s*["'](?P<url>[^"']+)'''
|
||||
r'''["']hls4["']:\s*["'](?P<url>[^"']+)''',
|
||||
r'''["']hls2["']:\s*["'](?P<url>[^"']+)'''
|
||||
]
|
||||
|
||||
final_url = await eval_solver(self, url, headers, patterns)
|
||||
|
||||
52
mediaflow_proxy/extractors/filemoon.py
Normal file
52
mediaflow_proxy/extractors/filemoon.py
Normal file
@@ -0,0 +1,52 @@
|
||||
import re
|
||||
from typing import Dict, Any
|
||||
from urllib.parse import urlparse, urljoin
|
||||
|
||||
from mediaflow_proxy.extractors.base import BaseExtractor, ExtractorError
|
||||
from mediaflow_proxy.utils.packed import eval_solver
|
||||
|
||||
|
||||
class FileMoonExtractor(BaseExtractor):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.mediaflow_endpoint = "hls_manifest_proxy"
|
||||
|
||||
async def extract(self, url: str, **kwargs) -> Dict[str, Any]:
|
||||
response = await self._make_request(url)
|
||||
|
||||
pattern = r'iframe.*?src=["\'](.*?)["\']'
|
||||
match = re.search(pattern, response.text, re.DOTALL)
|
||||
if not match:
|
||||
raise ExtractorError("Failed to extract iframe URL")
|
||||
|
||||
iframe_url = match.group(1)
|
||||
|
||||
parsed = urlparse(str(response.url))
|
||||
base_url = f"{parsed.scheme}://{parsed.netloc}"
|
||||
|
||||
if iframe_url.startswith("//"):
|
||||
iframe_url = f"{parsed.scheme}:{iframe_url}"
|
||||
elif not urlparse(iframe_url).scheme:
|
||||
iframe_url = urljoin(base_url, iframe_url)
|
||||
|
||||
headers = {"Referer": url}
|
||||
patterns = [r'file:"(.*?)"']
|
||||
|
||||
final_url = await eval_solver(
|
||||
self,
|
||||
iframe_url,
|
||||
headers,
|
||||
patterns,
|
||||
)
|
||||
|
||||
test_resp = await self._make_request(final_url, headers=headers)
|
||||
if test_resp.status_code == 404:
|
||||
raise ExtractorError("Stream not found (404)")
|
||||
|
||||
self.base_headers["referer"] = url
|
||||
|
||||
return {
|
||||
"destination_url": final_url,
|
||||
"request_headers": self.base_headers,
|
||||
"mediaflow_endpoint": self.mediaflow_endpoint,
|
||||
}
|
||||
27
mediaflow_proxy/extractors/lulustream.py
Normal file
27
mediaflow_proxy/extractors/lulustream.py
Normal file
@@ -0,0 +1,27 @@
|
||||
import re
|
||||
from typing import Dict, Any
|
||||
|
||||
from mediaflow_proxy.extractors.base import BaseExtractor, ExtractorError
|
||||
|
||||
|
||||
class LuluStreamExtractor(BaseExtractor):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.mediaflow_endpoint = "hls_manifest_proxy"
|
||||
|
||||
async def extract(self, url: str, **kwargs) -> Dict[str, Any]:
|
||||
response = await self._make_request(url)
|
||||
|
||||
# See https://github.com/Gujal00/ResolveURL/blob/master/script.module.resolveurl/lib/resolveurl/plugins/lulustream.py
|
||||
pattern = r'''sources:\s*\[{file:\s*["'](?P<url>[^"']+)'''
|
||||
match = re.search(pattern, response.text, re.DOTALL)
|
||||
if not match:
|
||||
raise ExtractorError("Failed to extract source URL")
|
||||
final_url = match.group(1)
|
||||
|
||||
self.base_headers["referer"] = url
|
||||
return {
|
||||
"destination_url": final_url,
|
||||
"request_headers": self.base_headers,
|
||||
"mediaflow_endpoint": self.mediaflow_endpoint,
|
||||
}
|
||||
195
mediaflow_proxy/extractors/sportsonline.py
Normal file
195
mediaflow_proxy/extractors/sportsonline.py
Normal file
@@ -0,0 +1,195 @@
|
||||
import re
|
||||
import logging
|
||||
from typing import Any, Dict, Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from mediaflow_proxy.extractors.base import BaseExtractor, ExtractorError
|
||||
from mediaflow_proxy.utils.packed import detect, unpack
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SportsonlineExtractor(BaseExtractor):
|
||||
"""Sportsonline/Sportzonline URL extractor for M3U8 streams.
|
||||
|
||||
Strategy:
|
||||
1. Fetch page -> find first <iframe src="...">
|
||||
2. Fetch iframe with Referer=https://sportzonline.st/
|
||||
3. Collect packed eval blocks; if >=2 use second (index 1) else first.
|
||||
4. Unpack P.A.C.K.E.R. and search var src="...m3u8".
|
||||
5. Return final m3u8 with referer header.
|
||||
|
||||
Notes:
|
||||
- Multi-domain support for sportzonline.(st|bz|cc|top) and sportsonline.(si|sn)
|
||||
- Uses P.A.C.K.E.R. unpacking from utils.packed module
|
||||
- Returns streams suitable for hls_manifest_proxy endpoint
|
||||
"""
|
||||
|
||||
def __init__(self, request_headers: dict):
|
||||
super().__init__(request_headers)
|
||||
self.mediaflow_endpoint = "hls_manifest_proxy"
|
||||
|
||||
def _detect_packed_blocks(self, html: str) -> list[str]:
|
||||
"""
|
||||
Detect and extract packed eval blocks from HTML.
|
||||
Replicates the TypeScript logic: /eval\(function(.+?.+)/g
|
||||
"""
|
||||
# Find all eval(function...) blocks - more greedy to capture full packed code
|
||||
pattern = re.compile(r"eval\(function\(p,a,c,k,e,.*?\)\)(?:\s*;|\s*<)", re.DOTALL)
|
||||
raw_matches = pattern.findall(html)
|
||||
|
||||
# If no matches with the strict pattern, try a more relaxed one
|
||||
if not raw_matches:
|
||||
# Try to find eval(function and capture until we find the closing ))
|
||||
pattern = re.compile(r"eval\(function\(p,a,c,k,e,[dr]\).*?\}\(.*?\)\)", re.DOTALL)
|
||||
raw_matches = pattern.findall(html)
|
||||
|
||||
return raw_matches
|
||||
|
||||
async def extract(self, url: str, **kwargs) -> Dict[str, Any]:
|
||||
"""Main extraction flow: fetch page, extract iframe, unpack and find m3u8."""
|
||||
try:
|
||||
# Step 1: Fetch main page
|
||||
logger.info(f"Fetching main page: {url}")
|
||||
main_response = await self._make_request(url, timeout=15)
|
||||
main_html = main_response.text
|
||||
|
||||
# Extract first iframe
|
||||
iframe_match = re.search(r'<iframe\s+src=["\']([^"\']+)["\']', main_html, re.IGNORECASE)
|
||||
if not iframe_match:
|
||||
raise ExtractorError("No iframe found on the page")
|
||||
|
||||
iframe_url = iframe_match.group(1)
|
||||
|
||||
# Normalize iframe URL
|
||||
if iframe_url.startswith('//'):
|
||||
iframe_url = 'https:' + iframe_url
|
||||
elif iframe_url.startswith('/'):
|
||||
parsed_main = urlparse(url)
|
||||
iframe_url = f"{parsed_main.scheme}://{parsed_main.netloc}{iframe_url}"
|
||||
|
||||
logger.info(f"Found iframe URL: {iframe_url}")
|
||||
|
||||
# Step 2: Fetch iframe with Referer
|
||||
iframe_headers = {
|
||||
'Referer': 'https://sportzonline.st/',
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36',
|
||||
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
||||
'Accept-Language': 'en-US,en;q=0.9,it;q=0.8',
|
||||
'Cache-Control': 'no-cache'
|
||||
}
|
||||
|
||||
iframe_response = await self._make_request(iframe_url, headers=iframe_headers, timeout=15)
|
||||
iframe_html = iframe_response.text
|
||||
|
||||
logger.debug(f"Iframe HTML length: {len(iframe_html)}")
|
||||
|
||||
# Step 3: Detect packed blocks
|
||||
packed_blocks = self._detect_packed_blocks(iframe_html)
|
||||
|
||||
logger.info(f"Found {len(packed_blocks)} packed blocks")
|
||||
|
||||
if not packed_blocks:
|
||||
logger.warning("No packed blocks found, trying direct m3u8 search")
|
||||
# Fallback: try direct m3u8 search
|
||||
direct_match = re.search(r'(https?://[^\s"\'>]+\.m3u8[^\s"\'>]*)', iframe_html)
|
||||
if direct_match:
|
||||
m3u8_url = direct_match.group(1)
|
||||
logger.info(f"Found direct m3u8 URL: {m3u8_url}")
|
||||
|
||||
return {
|
||||
"destination_url": m3u8_url,
|
||||
"request_headers": {
|
||||
'Referer': iframe_url,
|
||||
'User-Agent': iframe_headers['User-Agent']
|
||||
},
|
||||
"mediaflow_endpoint": self.mediaflow_endpoint,
|
||||
}
|
||||
else:
|
||||
raise ExtractorError("No packed blocks or direct m3u8 URL found")
|
||||
|
||||
logger.info(f"Found {len(packed_blocks)} packed blocks")
|
||||
|
||||
# Choose block: if >=2 use second (index 1), else first (index 0)
|
||||
chosen_idx = 1 if len(packed_blocks) > 1 else 0
|
||||
m3u8_url = None
|
||||
unpacked_code = None
|
||||
|
||||
logger.info(f"Chosen packed block index: {chosen_idx}")
|
||||
|
||||
# Try to unpack chosen block
|
||||
try:
|
||||
unpacked_code = unpack(packed_blocks[chosen_idx])
|
||||
logger.info(f"Successfully unpacked block {chosen_idx}")
|
||||
logger.debug(f"Unpacked code preview: {unpacked_code[:500] if unpacked_code else 'empty'}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to unpack block {chosen_idx}: {e}")
|
||||
|
||||
# Search for var src="...m3u8" with multiple patterns
|
||||
if unpacked_code:
|
||||
# Try multiple patterns as in the TypeScript version
|
||||
patterns = [
|
||||
r'var\s+src\s*=\s*["\']([^"\']+)["\']', # var src="..."
|
||||
r'src\s*=\s*["\']([^"\']+\.m3u8[^"\']*)["\']', # src="...m3u8"
|
||||
r'file\s*:\s*["\']([^"\']+\.m3u8[^"\']*)["\']', # file: "...m3u8"
|
||||
r'["\']([^"\']*https?://[^"\']+\.m3u8[^"\']*)["\']', # any m3u8 URL
|
||||
]
|
||||
|
||||
for pattern in patterns:
|
||||
src_match = re.search(pattern, unpacked_code)
|
||||
if src_match:
|
||||
m3u8_url = src_match.group(1)
|
||||
# Verify it looks like a valid m3u8 URL
|
||||
if '.m3u8' in m3u8_url or 'http' in m3u8_url:
|
||||
break
|
||||
m3u8_url = None
|
||||
|
||||
# If not found, try all other blocks
|
||||
if not m3u8_url:
|
||||
logger.info("m3u8 not found in chosen block, trying all blocks")
|
||||
for i, block in enumerate(packed_blocks):
|
||||
if i == chosen_idx:
|
||||
continue
|
||||
try:
|
||||
unpacked_code = unpack(block)
|
||||
# Use the same patterns as above
|
||||
for pattern in [
|
||||
r'var\s+src\s*=\s*["\']([^"\']+)["\']',
|
||||
r'src\s*=\s*["\']([^"\']+\.m3u8[^"\']*)["\']',
|
||||
r'file\s*:\s*["\']([^"\']+\.m3u8[^"\']*)["\']',
|
||||
r'["\']([^"\']*https?://[^"\']+\.m3u8[^"\']*)["\']',
|
||||
]:
|
||||
src_match = re.search(pattern, unpacked_code)
|
||||
if src_match:
|
||||
test_url = src_match.group(1)
|
||||
if '.m3u8' in test_url or 'http' in test_url:
|
||||
m3u8_url = test_url
|
||||
logger.info(f"Found m3u8 in block {i}")
|
||||
break
|
||||
|
||||
if m3u8_url:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to process block {i}: {e}")
|
||||
continue
|
||||
|
||||
if not m3u8_url:
|
||||
raise ExtractorError("Could not extract m3u8 URL from packed code")
|
||||
|
||||
logger.info(f"Successfully extracted m3u8 URL: {m3u8_url}")
|
||||
|
||||
# Return stream configuration
|
||||
return {
|
||||
"destination_url": m3u8_url,
|
||||
"request_headers": {
|
||||
'Referer': iframe_url,
|
||||
'User-Agent': iframe_headers['User-Agent']
|
||||
},
|
||||
"mediaflow_endpoint": self.mediaflow_endpoint,
|
||||
}
|
||||
|
||||
except ExtractorError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception(f"Sportsonline extraction failed for {url}")
|
||||
raise ExtractorError(f"Extraction failed: {str(e)}")
|
||||
81
mediaflow_proxy/extractors/streamwish.py
Normal file
81
mediaflow_proxy/extractors/streamwish.py
Normal file
@@ -0,0 +1,81 @@
|
||||
import re
|
||||
from typing import Dict, Any
|
||||
from urllib.parse import urljoin, urlparse
|
||||
|
||||
from mediaflow_proxy.extractors.base import BaseExtractor, ExtractorError
|
||||
from mediaflow_proxy.utils.packed import eval_solver
|
||||
|
||||
|
||||
class StreamWishExtractor(BaseExtractor):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.mediaflow_endpoint = "hls_manifest_proxy"
|
||||
|
||||
async def extract(self, url: str, **_kwargs: Any) -> Dict[str, Any]:
|
||||
referer = self.base_headers.get("Referer")
|
||||
if not referer:
|
||||
parsed = urlparse(url)
|
||||
referer = f"{parsed.scheme}://{parsed.netloc}/"
|
||||
|
||||
headers = {"Referer": referer}
|
||||
response = await self._make_request(url, headers=headers)
|
||||
|
||||
iframe_match = re.search(
|
||||
r'<iframe[^>]+src=["\']([^"\']+)["\']',
|
||||
response.text,
|
||||
re.DOTALL
|
||||
)
|
||||
iframe_url = urljoin(url, iframe_match.group(1)) if iframe_match else url
|
||||
|
||||
iframe_response = await self._make_request(
|
||||
iframe_url,
|
||||
headers=headers
|
||||
)
|
||||
html = iframe_response.text
|
||||
|
||||
final_url = self._extract_m3u8(html)
|
||||
|
||||
if not final_url and "eval(function(p,a,c,k,e,d)" in html:
|
||||
try:
|
||||
final_url = await eval_solver(
|
||||
self,
|
||||
iframe_url,
|
||||
headers,
|
||||
[
|
||||
# absolute m3u8
|
||||
r'(https?://[^"\']+\.m3u8[^"\']*)',
|
||||
# relative stream paths
|
||||
r'(\/stream\/[^"\']+\.m3u8[^"\']*)',
|
||||
],
|
||||
)
|
||||
except Exception:
|
||||
final_url = None
|
||||
|
||||
if not final_url:
|
||||
raise ExtractorError("StreamWish: Failed to extract m3u8")
|
||||
|
||||
if final_url.startswith("/"):
|
||||
final_url = urljoin(iframe_url, final_url)
|
||||
|
||||
origin = f"{urlparse(referer).scheme}://{urlparse(referer).netloc}"
|
||||
self.base_headers.update({
|
||||
"Referer": referer,
|
||||
"Origin": origin,
|
||||
})
|
||||
|
||||
return {
|
||||
"destination_url": final_url,
|
||||
"request_headers": self.base_headers,
|
||||
"mediaflow_endpoint": self.mediaflow_endpoint,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _extract_m3u8(text: str) -> str | None:
|
||||
"""
|
||||
Extract first absolute m3u8 URL from text
|
||||
"""
|
||||
match = re.search(
|
||||
r'https?://[^"\']+\.m3u8[^"\']*',
|
||||
text
|
||||
)
|
||||
return match.group(0) if match else None
|
||||
68
mediaflow_proxy/extractors/turbovidplay.py
Normal file
68
mediaflow_proxy/extractors/turbovidplay.py
Normal file
@@ -0,0 +1,68 @@
|
||||
import re
|
||||
from typing import Dict, Any
|
||||
|
||||
from mediaflow_proxy.extractors.base import BaseExtractor, ExtractorError
|
||||
|
||||
|
||||
class TurboVidPlayExtractor(BaseExtractor):
|
||||
domains = [
|
||||
"turboviplay.com",
|
||||
"emturbovid.com",
|
||||
"tuborstb.co",
|
||||
"javggvideo.xyz",
|
||||
"stbturbo.xyz",
|
||||
"turbovidhls.com",
|
||||
]
|
||||
|
||||
mediaflow_endpoint = "hls_manifest_proxy"
|
||||
|
||||
async def extract(self, url: str, **kwargs):
|
||||
#
|
||||
# 1. Load embed
|
||||
#
|
||||
response = await self._make_request(url)
|
||||
html = response.text
|
||||
|
||||
#
|
||||
# 2. Extract urlPlay or data-hash
|
||||
#
|
||||
m = re.search(r'(?:urlPlay|data-hash)\s*=\s*[\'"]([^\'"]+)', html)
|
||||
if not m:
|
||||
raise ExtractorError("TurboViPlay: No media URL found")
|
||||
|
||||
media_url = m.group(1)
|
||||
|
||||
# Normalize protocol
|
||||
if media_url.startswith("//"):
|
||||
media_url = "https:" + media_url
|
||||
elif media_url.startswith("/"):
|
||||
media_url = response.url.origin + media_url
|
||||
|
||||
#
|
||||
# 3. Fetch the intermediate playlist
|
||||
#
|
||||
data_resp = await self._make_request(media_url, headers={"Referer": url})
|
||||
playlist = data_resp.text
|
||||
|
||||
#
|
||||
# 4. Extract real m3u8 URL
|
||||
#
|
||||
m2 = re.search(r'https?://[^\'"\s]+\.m3u8', playlist)
|
||||
if not m2:
|
||||
raise ExtractorError("TurboViPlay: Unable to extract playlist URL")
|
||||
|
||||
real_m3u8 = m2.group(0)
|
||||
|
||||
#
|
||||
# 5. Final headers
|
||||
#
|
||||
self.base_headers["referer"] = url
|
||||
|
||||
#
|
||||
# 6. Always return master proxy (your MediaFlow only supports this)
|
||||
#
|
||||
return {
|
||||
"destination_url": real_m3u8,
|
||||
"request_headers": self.base_headers,
|
||||
"mediaflow_endpoint": "hls_manifest_proxy",
|
||||
}
|
||||
@@ -4,24 +4,31 @@ from mediaflow_proxy.extractors.base import BaseExtractor, ExtractorError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class VavooExtractor(BaseExtractor):
|
||||
"""Vavoo URL extractor for resolving vavoo.to links (solo httpx, async)."""
|
||||
"""Vavoo URL extractor for resolving vavoo.to links.
|
||||
|
||||
Features:
|
||||
- Uses BaseExtractor's retry/timeouts
|
||||
- Improved headers to mimic Android okhttp client
|
||||
- Robust JSON handling and logging
|
||||
"""
|
||||
|
||||
def __init__(self, request_headers: dict):
|
||||
super().__init__(request_headers)
|
||||
self.mediaflow_endpoint = "proxy_stream_endpoint"
|
||||
|
||||
async def get_auth_signature(self) -> Optional[str]:
|
||||
"""Get authentication signature for Vavoo API (async, httpx, pulito)."""
|
||||
"""Get authentication signature for Vavoo API (async)."""
|
||||
headers = {
|
||||
"user-agent": "okhttp/4.11.0",
|
||||
"accept": "application/json",
|
||||
"accept": "application/json",
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
"accept-encoding": "gzip"
|
||||
"accept-encoding": "gzip",
|
||||
}
|
||||
import time
|
||||
current_time = int(time.time() * 1000)
|
||||
|
||||
|
||||
data = {
|
||||
"token": "tosFwQCJMS8qrW_AjLoHPQ41646J5dRNha6ZWHnijoYQQQoADQoXYSo7ki7O5-CsgN4CH0uRk6EEoJ0728ar9scCRQW3ZkbfrPfeCXW2VgopSW2FWDqPOoVYIuVPAOnXCZ5g",
|
||||
"reason": "app-blur",
|
||||
@@ -37,23 +44,17 @@ class VavooExtractor(BaseExtractor):
|
||||
},
|
||||
"os": {
|
||||
"name": "android",
|
||||
"version": "13",
|
||||
"abis": ["arm64-v8a", "armeabi-v7a", "armeabi"],
|
||||
"host": "android"
|
||||
"version": "13"
|
||||
},
|
||||
"app": {
|
||||
"platform": "android",
|
||||
"version": "3.1.21",
|
||||
"buildId": "289515000",
|
||||
"engine": "hbc85",
|
||||
"signatures": ["6e8a975e3cbf07d5de823a760d4c2547f86c1403105020adee5de67ac510999e"],
|
||||
"installer": "app.revanced.manager.flutter"
|
||||
"version": "3.1.21"
|
||||
},
|
||||
"version": {
|
||||
"package": "tv.vavoo.app",
|
||||
"binary": "3.1.21",
|
||||
"js": "3.1.21"
|
||||
}
|
||||
},
|
||||
},
|
||||
"appFocusTime": 0,
|
||||
"playerActive": False,
|
||||
@@ -70,7 +71,7 @@ class VavooExtractor(BaseExtractor):
|
||||
"adblockEnabled": True,
|
||||
"proxy": {
|
||||
"supported": ["ss", "openvpn"],
|
||||
"engine": "ss",
|
||||
"engine": "ss",
|
||||
"ssVersion": 1,
|
||||
"enabled": True,
|
||||
"autoServer": True,
|
||||
@@ -80,44 +81,48 @@ class VavooExtractor(BaseExtractor):
|
||||
"supported": False
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
try:
|
||||
resp = await self._make_request(
|
||||
"https://www.vavoo.tv/api/app/ping",
|
||||
method="POST",
|
||||
json=data,
|
||||
headers=headers
|
||||
headers=headers,
|
||||
timeout=10,
|
||||
retries=2,
|
||||
)
|
||||
result = resp.json()
|
||||
addon_sig = result.get("addonSig")
|
||||
try:
|
||||
result = resp.json()
|
||||
except Exception:
|
||||
logger.warning("Vavoo ping returned non-json response (status=%s).", resp.status_code)
|
||||
return None
|
||||
|
||||
addon_sig = result.get("addonSig") if isinstance(result, dict) else None
|
||||
if addon_sig:
|
||||
logger.info("Successfully obtained Vavoo authentication signature")
|
||||
return addon_sig
|
||||
else:
|
||||
logger.warning("No addonSig in Vavoo API response")
|
||||
logger.warning("No addonSig in Vavoo API response: %s", result)
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.exception(f"Failed to get Vavoo authentication signature: {str(e)}")
|
||||
except ExtractorError as e:
|
||||
logger.warning("Failed to get Vavoo auth signature: %s", e)
|
||||
return None
|
||||
|
||||
async def extract(self, url: str, **kwargs) -> Dict[str, Any]:
|
||||
"""Extract Vavoo stream URL (async, httpx)."""
|
||||
"""Extract Vavoo stream URL (async)."""
|
||||
if "vavoo.to" not in url:
|
||||
raise ExtractorError("Not a valid Vavoo URL")
|
||||
|
||||
# Get authentication signature
|
||||
signature = await self.get_auth_signature()
|
||||
if not signature:
|
||||
raise ExtractorError("Failed to get Vavoo authentication signature")
|
||||
|
||||
# Resolve the URL
|
||||
resolved_url = await self._resolve_vavoo_link(url, signature)
|
||||
if not resolved_url:
|
||||
raise ExtractorError("Failed to resolve Vavoo URL")
|
||||
|
||||
# Set up headers for the resolved stream
|
||||
stream_headers = {
|
||||
"user-agent": self.base_headers["user-agent"],
|
||||
"user-agent": self.base_headers.get("user-agent", "okhttp/4.11.0"),
|
||||
"referer": "https://vavoo.to/",
|
||||
}
|
||||
|
||||
@@ -128,17 +133,17 @@ class VavooExtractor(BaseExtractor):
|
||||
}
|
||||
|
||||
async def _resolve_vavoo_link(self, link: str, signature: str) -> Optional[str]:
|
||||
"""Resolve a Vavoo link using the MediaHubMX API (async, httpx)."""
|
||||
"""Resolve a Vavoo link using the MediaHubMX API (async)."""
|
||||
headers = {
|
||||
"user-agent": "MediaHubMX/2",
|
||||
"user-agent": "okhttp/4.11.0",
|
||||
"accept": "application/json",
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
"accept-encoding": "gzip",
|
||||
"mediahubmx-signature": signature
|
||||
}
|
||||
data = {
|
||||
"language": "de",
|
||||
"region": "AT",
|
||||
"region": "AT",
|
||||
"url": link,
|
||||
"clientVersion": "3.1.21"
|
||||
}
|
||||
@@ -148,22 +153,34 @@ class VavooExtractor(BaseExtractor):
|
||||
"https://vavoo.to/mediahubmx-resolve.json",
|
||||
method="POST",
|
||||
json=data,
|
||||
headers=headers
|
||||
headers=headers,
|
||||
timeout=12,
|
||||
retries=3,
|
||||
backoff_factor=0.6,
|
||||
)
|
||||
result = resp.json()
|
||||
logger.info(f"Vavoo API response: {result}")
|
||||
|
||||
if isinstance(result, list) and result and result[0].get("url"):
|
||||
try:
|
||||
result = resp.json()
|
||||
except Exception:
|
||||
logger.warning("Vavoo resolve returned non-json response (status=%s). Body preview: %s", resp.status_code, getattr(resp, "text", "")[:500])
|
||||
return None
|
||||
|
||||
logger.debug("Vavoo API response: %s", result)
|
||||
|
||||
# Accept either list or dict with 'url'
|
||||
if isinstance(result, list) and result and isinstance(result[0], dict) and result[0].get("url"):
|
||||
resolved_url = result[0]["url"]
|
||||
logger.info(f"Successfully resolved Vavoo URL to: {resolved_url}")
|
||||
logger.info("Successfully resolved Vavoo URL to: %s", resolved_url)
|
||||
return resolved_url
|
||||
elif isinstance(result, dict) and result.get("url"):
|
||||
resolved_url = result["url"]
|
||||
logger.info(f"Successfully resolved Vavoo URL to: {resolved_url}")
|
||||
logger.info("Successfully resolved Vavoo URL to: %s", resolved_url)
|
||||
return resolved_url
|
||||
else:
|
||||
logger.warning(f"No URL found in Vavoo API response: {result}")
|
||||
logger.warning("No URL found in Vavoo API response: %s", result)
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.exception(f"Vavoo resolution failed for URL {link}: {str(e)}")
|
||||
except ExtractorError as e:
|
||||
logger.error(f"Vavoo resolution failed for URL {link}: {e}")
|
||||
raise ExtractorError(f"Vavoo resolution failed: {str(e)}") from e
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error while resolving Vavoo URL {link}: {e}")
|
||||
raise ExtractorError(f"Vavoo resolution failed: {str(e)}") from e
|
||||
|
||||
63
mediaflow_proxy/extractors/vidmoly.py
Normal file
63
mediaflow_proxy/extractors/vidmoly.py
Normal file
@@ -0,0 +1,63 @@
|
||||
import re
|
||||
from typing import Dict, Any
|
||||
from urllib.parse import urljoin, urlparse
|
||||
|
||||
from mediaflow_proxy.extractors.base import BaseExtractor, ExtractorError
|
||||
|
||||
|
||||
class VidmolyExtractor(BaseExtractor):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.mediaflow_endpoint = "hls_manifest_proxy"
|
||||
|
||||
async def extract(self, url: str) -> Dict[str, Any]:
|
||||
parsed = urlparse(url)
|
||||
if not parsed.hostname or "vidmoly" not in parsed.hostname:
|
||||
raise ExtractorError("VIDMOLY: Invalid domain")
|
||||
|
||||
headers = {
|
||||
"User-Agent":
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
||||
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||
"Chrome/120 Safari/537.36",
|
||||
"Referer": url,
|
||||
"Sec-Fetch-Dest": "iframe",
|
||||
}
|
||||
|
||||
# --- Fetch embed page ---
|
||||
response = await self._make_request(url, headers=headers)
|
||||
html = response.text
|
||||
|
||||
# --- Extract master m3u8 ---
|
||||
match = re.search(
|
||||
r'sources:\s*\[\{file:"([^"]+)',
|
||||
html
|
||||
)
|
||||
if not match:
|
||||
raise ExtractorError("VIDMOLY: Stream URL not found")
|
||||
|
||||
master_url = match.group(1)
|
||||
|
||||
if not master_url.startswith("http"):
|
||||
master_url = urljoin(url, master_url)
|
||||
|
||||
# --- Validate stream (prevents Stremio timeout) ---
|
||||
try:
|
||||
test = await self._make_request(master_url, headers=headers)
|
||||
except Exception as e:
|
||||
if "timeout" in str(e).lower():
|
||||
raise ExtractorError("VIDMOLY: Request timed out")
|
||||
raise
|
||||
|
||||
if test.status_code >= 400:
|
||||
raise ExtractorError(
|
||||
f"VIDMOLY: Stream unavailable ({test.status_code})"
|
||||
)
|
||||
|
||||
# Return MASTER playlist, not variant
|
||||
# Let MediaFlow Proxy handle variants
|
||||
return {
|
||||
"destination_url": master_url,
|
||||
"request_headers": headers,
|
||||
"mediaflow_endpoint": self.mediaflow_endpoint,
|
||||
}
|
||||
73
mediaflow_proxy/extractors/vidoza.py
Normal file
73
mediaflow_proxy/extractors/vidoza.py
Normal file
@@ -0,0 +1,73 @@
|
||||
import re
|
||||
from typing import Dict, Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from mediaflow_proxy.extractors.base import BaseExtractor, ExtractorError
|
||||
|
||||
|
||||
class VidozaExtractor(BaseExtractor):
|
||||
def __init__(self, request_headers: dict):
|
||||
super().__init__(request_headers)
|
||||
# if your base doesn’t set this, keep it; otherwise you can remove:
|
||||
self.mediaflow_endpoint = "proxy_stream_endpoint"
|
||||
|
||||
async def extract(self, url: str, **kwargs) -> Dict[str, Any]:
|
||||
parsed = urlparse(url)
|
||||
|
||||
# Accept vidoza + videzz
|
||||
if not parsed.hostname or not (
|
||||
parsed.hostname.endswith("vidoza.net")
|
||||
or parsed.hostname.endswith("videzz.net")
|
||||
):
|
||||
raise ExtractorError("VIDOZA: Invalid domain")
|
||||
|
||||
headers = self.base_headers.copy()
|
||||
headers.update(
|
||||
{
|
||||
"referer": "https://vidoza.net/",
|
||||
"user-agent": (
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
||||
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||
"Chrome/120.0.0.0 Safari/537.36"
|
||||
),
|
||||
"accept": "*/*",
|
||||
"accept-language": "en-US,en;q=0.9",
|
||||
}
|
||||
)
|
||||
|
||||
# 1) Fetch the embed page (or whatever URL you pass in)
|
||||
response = await self._make_request(url, headers=headers)
|
||||
html = response.text or ""
|
||||
|
||||
if not html:
|
||||
raise ExtractorError("VIDOZA: Empty HTML from Vidoza")
|
||||
|
||||
cookies = response.cookies or {}
|
||||
|
||||
# 2) Extract final link with REGEX
|
||||
pattern = re.compile(
|
||||
r"""["']?\s*(?:file|src)\s*["']?\s*[:=,]?\s*["'](?P<url>[^"']+)"""
|
||||
r"""(?:[^}>\]]+)["']?\s*res\s*["']?\s*[:=]\s*["']?(?P<label>[^"',]+)""",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
match = pattern.search(html)
|
||||
if not match:
|
||||
raise ExtractorError("VIDOZA: Unable to extract video + label from JS")
|
||||
|
||||
mp4_url = match.group("url")
|
||||
label = match.group("label").strip()
|
||||
|
||||
# Fix URLs like //str38.vidoza.net/...
|
||||
if mp4_url.startswith("//"):
|
||||
mp4_url = "https:" + mp4_url
|
||||
|
||||
# 3) Attach cookies (token may depend on these)
|
||||
if cookies:
|
||||
headers["cookie"] = "; ".join(f"{k}={v}" for k, v in cookies.items())
|
||||
|
||||
return {
|
||||
"destination_url": mp4_url,
|
||||
"request_headers": headers,
|
||||
"mediaflow_endpoint": self.mediaflow_endpoint,
|
||||
}
|
||||
68
mediaflow_proxy/extractors/voe.py
Normal file
68
mediaflow_proxy/extractors/voe.py
Normal file
@@ -0,0 +1,68 @@
|
||||
import base64
|
||||
import re
|
||||
from typing import Dict, Any
|
||||
from urllib.parse import urljoin
|
||||
|
||||
from mediaflow_proxy.extractors.base import BaseExtractor, ExtractorError
|
||||
|
||||
|
||||
class VoeExtractor(BaseExtractor):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.mediaflow_endpoint = "hls_manifest_proxy"
|
||||
|
||||
async def extract(self, url: str, redirected: bool = False, **kwargs) -> Dict[str, Any]:
|
||||
response = await self._make_request(url)
|
||||
|
||||
# See https://github.com/Gujal00/ResolveURL/blob/master/script.module.resolveurl/lib/resolveurl/plugins/voesx.py
|
||||
redirect_pattern = r'''window\.location\.href\s*=\s*'([^']+)'''
|
||||
redirect_match = re.search(redirect_pattern, response.text, re.DOTALL)
|
||||
if redirect_match:
|
||||
if redirected:
|
||||
raise ExtractorError("VOE: too many redirects")
|
||||
|
||||
return await self.extract(redirect_match.group(1))
|
||||
|
||||
code_and_script_pattern = r'json">\["([^"]+)"]</script>\s*<script\s*src="([^"]+)'
|
||||
code_and_script_match = re.search(code_and_script_pattern, response.text, re.DOTALL)
|
||||
if not code_and_script_match:
|
||||
raise ExtractorError("VOE: unable to locate obfuscated payload or external script URL")
|
||||
|
||||
script_response = await self._make_request(urljoin(url, code_and_script_match.group(2)))
|
||||
|
||||
luts_pattern = r"(\[(?:'\W{2}'[,\]]){1,9})"
|
||||
luts_match = re.search(luts_pattern, script_response.text, re.DOTALL)
|
||||
if not luts_match:
|
||||
raise ExtractorError("VOE: unable to locate LUTs in external script")
|
||||
|
||||
data = self.voe_decode(code_and_script_match.group(1), luts_match.group(1))
|
||||
|
||||
final_url = data.get('source')
|
||||
if not final_url:
|
||||
raise ExtractorError("VOE: failed to extract video URL")
|
||||
|
||||
self.base_headers["referer"] = url
|
||||
return {
|
||||
"destination_url": final_url,
|
||||
"request_headers": self.base_headers,
|
||||
"mediaflow_endpoint": self.mediaflow_endpoint,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def voe_decode(ct: str, luts: str) -> Dict[str, Any]:
|
||||
import json
|
||||
lut = [''.join([('\\' + x) if x in '.*+?^${}()|[]\\' else x for x in i]) for i in luts[2:-2].split("','")]
|
||||
txt = ''
|
||||
for i in ct:
|
||||
x = ord(i)
|
||||
if 64 < x < 91:
|
||||
x = (x - 52) % 26 + 65
|
||||
elif 96 < x < 123:
|
||||
x = (x - 84) % 26 + 97
|
||||
txt += chr(x)
|
||||
for i in lut:
|
||||
txt = re.sub(i, '', txt)
|
||||
ct = base64.b64decode(txt).decode('utf-8')
|
||||
txt = ''.join([chr(ord(i) - 3) for i in ct])
|
||||
txt = base64.b64decode(txt[::-1]).decode('utf-8')
|
||||
return json.loads(txt)
|
||||
@@ -22,6 +22,7 @@ from .utils.http_utils import (
|
||||
)
|
||||
from .utils.m3u8_processor import M3U8Processor
|
||||
from .utils.mpd_utils import pad_base64
|
||||
from .configs import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -102,7 +103,8 @@ async def handle_hls_stream_proxy(
|
||||
# If force_playlist_proxy is enabled, skip detection and directly process as m3u8
|
||||
if hls_params.force_playlist_proxy:
|
||||
return await fetch_and_process_m3u8(
|
||||
streamer, hls_params.destination, proxy_headers, request, hls_params.key_url, hls_params.force_playlist_proxy
|
||||
streamer, hls_params.destination, proxy_headers, request,
|
||||
hls_params.key_url, hls_params.force_playlist_proxy, hls_params.key_only_proxy, hls_params.no_proxy
|
||||
)
|
||||
|
||||
parsed_url = urlparse(hls_params.destination)
|
||||
@@ -111,7 +113,8 @@ async def handle_hls_stream_proxy(
|
||||
0
|
||||
] in ["m3u", "m3u8", "m3u_plus"]:
|
||||
return await fetch_and_process_m3u8(
|
||||
streamer, hls_params.destination, proxy_headers, request, hls_params.key_url, hls_params.force_playlist_proxy
|
||||
streamer, hls_params.destination, proxy_headers, request,
|
||||
hls_params.key_url, hls_params.force_playlist_proxy, hls_params.key_only_proxy, hls_params.no_proxy
|
||||
)
|
||||
|
||||
# Create initial streaming response to check content type
|
||||
@@ -120,7 +123,8 @@ async def handle_hls_stream_proxy(
|
||||
|
||||
if "mpegurl" in response_headers.get("content-type", "").lower():
|
||||
return await fetch_and_process_m3u8(
|
||||
streamer, hls_params.destination, proxy_headers, request, hls_params.key_url, hls_params.force_playlist_proxy
|
||||
streamer, hls_params.destination, proxy_headers, request,
|
||||
hls_params.key_url, hls_params.force_playlist_proxy, hls_params.key_only_proxy, hls_params.no_proxy
|
||||
)
|
||||
|
||||
return EnhancedStreamingResponse(
|
||||
@@ -224,7 +228,14 @@ async def proxy_stream(method: str, destination: str, proxy_headers: ProxyReques
|
||||
|
||||
|
||||
async def fetch_and_process_m3u8(
|
||||
streamer: Streamer, url: str, proxy_headers: ProxyRequestHeaders, request: Request, key_url: str = None, force_playlist_proxy: bool = None
|
||||
streamer: Streamer,
|
||||
url: str,
|
||||
proxy_headers: ProxyRequestHeaders,
|
||||
request: Request,
|
||||
key_url: str = None,
|
||||
force_playlist_proxy: bool = None,
|
||||
key_only_proxy: bool = False,
|
||||
no_proxy: bool = False
|
||||
):
|
||||
"""
|
||||
Fetches and processes the m3u8 playlist on-the-fly, converting it to an HLS playlist.
|
||||
@@ -236,6 +247,8 @@ async def fetch_and_process_m3u8(
|
||||
request (Request): The incoming HTTP request.
|
||||
key_url (str, optional): The HLS Key URL to replace the original key URL. Defaults to None.
|
||||
force_playlist_proxy (bool, optional): Force all playlist URLs to be proxied through MediaFlow. Defaults to None.
|
||||
key_only_proxy (bool, optional): Only proxy the key URL, leaving segment URLs direct. Defaults to False.
|
||||
no_proxy (bool, optional): If True, returns the manifest without proxying any URLs. Defaults to False.
|
||||
|
||||
Returns:
|
||||
Response: The HTTP response with the processed m3u8 playlist.
|
||||
@@ -246,7 +259,7 @@ async def fetch_and_process_m3u8(
|
||||
await streamer.create_streaming_response(url, proxy_headers.request)
|
||||
|
||||
# Initialize processor and response headers
|
||||
processor = M3U8Processor(request, key_url, force_playlist_proxy)
|
||||
processor = M3U8Processor(request, key_url, force_playlist_proxy, key_only_proxy, no_proxy)
|
||||
response_headers = {
|
||||
"content-disposition": "inline",
|
||||
"accept-ranges": "none",
|
||||
@@ -378,7 +391,13 @@ async def get_segment(
|
||||
Response: The HTTP response with the processed segment.
|
||||
"""
|
||||
try:
|
||||
init_content = await get_cached_init_segment(segment_params.init_url, proxy_headers.request)
|
||||
live_cache_ttl = settings.mpd_live_init_cache_ttl if segment_params.is_live else None
|
||||
init_content = await get_cached_init_segment(
|
||||
segment_params.init_url,
|
||||
proxy_headers.request,
|
||||
cache_token=segment_params.key_id,
|
||||
ttl=live_cache_ttl,
|
||||
)
|
||||
segment_content = await download_file_with_retry(segment_params.segment_url, proxy_headers.request)
|
||||
except Exception as e:
|
||||
return handle_exceptions(e)
|
||||
|
||||
@@ -192,30 +192,34 @@ def build_hls_playlist(mpd_dict: dict, profiles: list[dict], request: Request) -
|
||||
logger.warning(f"No segments found for profile {profile['id']}")
|
||||
continue
|
||||
|
||||
if mpd_dict["isLive"]:
|
||||
depth = max(settings.mpd_live_playlist_depth, 1)
|
||||
trimmed_segments = segments[-depth:]
|
||||
else:
|
||||
trimmed_segments = segments
|
||||
|
||||
# Add headers for only the first profile
|
||||
if index == 0:
|
||||
first_segment = segments[0]
|
||||
extinf_values = [f["extinf"] for f in segments if "extinf" in f]
|
||||
first_segment = trimmed_segments[0]
|
||||
extinf_values = [f["extinf"] for f in trimmed_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
|
||||
# Align HLS media sequence with MPD-provided numbering when available
|
||||
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
|
||||
sequence = first_segment.get("number")
|
||||
|
||||
if sequence is None:
|
||||
# Fallback to MPD template start number
|
||||
if mpd_start_number is not None:
|
||||
sequence = mpd_start_number
|
||||
else:
|
||||
sequence = first_segment.get("number", 1)
|
||||
# As a last resort, derive from timeline information
|
||||
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:
|
||||
sequence = math.floor(time_val / duration_val)
|
||||
else:
|
||||
sequence = 1
|
||||
|
||||
hls.extend(
|
||||
[
|
||||
@@ -235,10 +239,18 @@ def build_hls_playlist(mpd_dict: dict, profiles: list[dict], request: Request) -
|
||||
query_params.pop("d", None)
|
||||
has_encrypted = query_params.pop("has_encrypted", False)
|
||||
|
||||
for segment in segments:
|
||||
for segment in trimmed_segments:
|
||||
program_date_time = segment.get("program_date_time")
|
||||
if program_date_time:
|
||||
hls.append(f"#EXT-X-PROGRAM-DATE-TIME:{program_date_time}")
|
||||
hls.append(f'#EXTINF:{segment["extinf"]:.3f},')
|
||||
query_params.update(
|
||||
{"init_url": init_url, "segment_url": segment["media"], "mime_type": profile["mimeType"]}
|
||||
{
|
||||
"init_url": init_url,
|
||||
"segment_url": segment["media"],
|
||||
"mime_type": profile["mimeType"],
|
||||
"is_live": "true" if mpd_dict.get("isLive") else "false",
|
||||
}
|
||||
)
|
||||
hls.append(
|
||||
encode_mediaflow_proxy_url(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import logging
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Query, HTTPException, Request, Depends
|
||||
from fastapi import APIRouter, Query, HTTPException, Request, Depends, BackgroundTasks
|
||||
from fastapi.responses import RedirectResponse
|
||||
|
||||
from mediaflow_proxy.extractors.base import ExtractorError
|
||||
@@ -9,6 +9,7 @@ from mediaflow_proxy.extractors.factory import ExtractorFactory
|
||||
from mediaflow_proxy.schemas import ExtractorURLParams
|
||||
from mediaflow_proxy.utils.cache_utils import get_cached_extractor_result, set_cache_extractor_result
|
||||
from mediaflow_proxy.utils.http_utils import (
|
||||
DownloadError,
|
||||
encode_mediaflow_proxy_url,
|
||||
get_original_scheme,
|
||||
ProxyRequestHeaders,
|
||||
@@ -19,12 +20,24 @@ from mediaflow_proxy.utils.base64_utils import process_potential_base64_url
|
||||
extractor_router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
async def refresh_extractor_cache(cache_key: str, extractor_params: ExtractorURLParams, proxy_headers: ProxyRequestHeaders):
|
||||
"""Asynchronously refreshes the extractor cache in the background."""
|
||||
try:
|
||||
logger.info(f"Background cache refresh started for key: {cache_key}")
|
||||
extractor = ExtractorFactory.get_extractor(extractor_params.host, proxy_headers.request)
|
||||
response = await extractor.extract(extractor_params.destination, **extractor_params.extra_params)
|
||||
await set_cache_extractor_result(cache_key, response)
|
||||
logger.info(f"Background cache refresh completed for key: {cache_key}")
|
||||
except Exception as e:
|
||||
logger.error(f"Background cache refresh failed for key {cache_key}: {e}")
|
||||
|
||||
|
||||
@extractor_router.head("/video")
|
||||
@extractor_router.get("/video")
|
||||
async def extract_url(
|
||||
extractor_params: Annotated[ExtractorURLParams, Query()],
|
||||
request: Request,
|
||||
background_tasks: BackgroundTasks,
|
||||
proxy_headers: Annotated[ProxyRequestHeaders, Depends(get_proxy_headers)],
|
||||
):
|
||||
"""Extract clean links from various video hosting services."""
|
||||
@@ -35,13 +48,21 @@ async def extract_url(
|
||||
|
||||
cache_key = f"{extractor_params.host}_{extractor_params.model_dump_json()}"
|
||||
response = await get_cached_extractor_result(cache_key)
|
||||
if not response:
|
||||
|
||||
if response:
|
||||
logger.info(f"Serving from cache for key: {cache_key}")
|
||||
# Schedule a background task to refresh the cache without blocking the user
|
||||
background_tasks.add_task(refresh_extractor_cache, cache_key, extractor_params, proxy_headers)
|
||||
else:
|
||||
logger.info(f"Cache miss for key: {cache_key}. Fetching fresh data.")
|
||||
extractor = ExtractorFactory.get_extractor(extractor_params.host, proxy_headers.request)
|
||||
response = await extractor.extract(extractor_params.destination, **extractor_params.extra_params)
|
||||
await set_cache_extractor_result(cache_key, response)
|
||||
else:
|
||||
response["request_headers"].update(proxy_headers.request)
|
||||
|
||||
# Ensure the latest request headers are used, even with cached data
|
||||
if "request_headers" not in response:
|
||||
response["request_headers"] = {}
|
||||
response["request_headers"].update(proxy_headers.request)
|
||||
response["mediaflow_proxy_url"] = str(
|
||||
request.url_for(response.pop("mediaflow_endpoint")).replace(scheme=get_original_scheme(request))
|
||||
)
|
||||
@@ -49,6 +70,12 @@ async def extract_url(
|
||||
# Add API password to query params
|
||||
response["query_params"]["api_password"] = request.query_params.get("api_password")
|
||||
|
||||
if "max_res" in request.query_params:
|
||||
response["query_params"]["max_res"] = request.query_params.get("max_res")
|
||||
|
||||
if "no_proxy" in request.query_params:
|
||||
response["query_params"]["no_proxy"] = request.query_params.get("no_proxy")
|
||||
|
||||
if extractor_params.redirect_stream:
|
||||
stream_url = encode_mediaflow_proxy_url(
|
||||
**response,
|
||||
@@ -58,6 +85,9 @@ async def extract_url(
|
||||
|
||||
return response
|
||||
|
||||
except DownloadError as e:
|
||||
logger.error(f"Extraction failed: {str(e)}")
|
||||
raise HTTPException(status_code=e.status_code, detail=str(e))
|
||||
except ExtractorError as e:
|
||||
logger.error(f"Extraction failed: {str(e)}")
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
@@ -20,6 +20,7 @@ def rewrite_m3u_links_streaming(m3u_lines_iterator: Iterator[str], base_url: str
|
||||
includendo gli headers da #EXTVLCOPT e #EXTHTTP. Yields rewritten lines.
|
||||
"""
|
||||
current_ext_headers: Dict[str, str] = {} # Dizionario per conservare gli headers dalle direttive
|
||||
current_kodi_props: Dict[str, str] = {} # Dizionario per conservare le proprietà KODI
|
||||
|
||||
for line_with_newline in m3u_lines_iterator:
|
||||
line_content = line_with_newline.rstrip('\n')
|
||||
@@ -27,6 +28,9 @@ def rewrite_m3u_links_streaming(m3u_lines_iterator: Iterator[str], base_url: str
|
||||
|
||||
is_header_tag = False
|
||||
if logical_line.startswith('#EXTVLCOPT:'):
|
||||
# Yield the original line to preserve it
|
||||
yield line_with_newline
|
||||
|
||||
is_header_tag = True
|
||||
try:
|
||||
option_str = logical_line.split(':', 1)[1]
|
||||
@@ -43,12 +47,15 @@ def rewrite_m3u_links_streaming(m3u_lines_iterator: Iterator[str], base_url: str
|
||||
current_ext_headers[header_key] = header_value
|
||||
elif key_vlc.startswith('http-'):
|
||||
# Gestisce http-user-agent, http-referer etc.
|
||||
header_key = '-'.join(word.capitalize() for word in key_vlc[len('http-'):].split('-'))
|
||||
header_key = key_vlc[len('http-'):]
|
||||
current_ext_headers[header_key] = value_vlc
|
||||
except Exception as e:
|
||||
logger.error(f"⚠️ Error parsing #EXTVLCOPT '{logical_line}': {e}")
|
||||
|
||||
elif logical_line.startswith('#EXTHTTP:'):
|
||||
# Yield the original line to preserve it
|
||||
yield line_with_newline
|
||||
|
||||
is_header_tag = True
|
||||
try:
|
||||
json_str = logical_line.split(':', 1)[1]
|
||||
@@ -57,9 +64,22 @@ def rewrite_m3u_links_streaming(m3u_lines_iterator: Iterator[str], base_url: str
|
||||
except Exception as e:
|
||||
logger.error(f"⚠️ Error parsing #EXTHTTP '{logical_line}': {e}")
|
||||
current_ext_headers = {} # Resetta in caso di errore
|
||||
|
||||
elif logical_line.startswith('#KODIPROP:'):
|
||||
# Yield the original line to preserve it
|
||||
yield line_with_newline
|
||||
|
||||
is_header_tag = True
|
||||
try:
|
||||
prop_str = logical_line.split(':', 1)[1]
|
||||
if '=' in prop_str:
|
||||
key_kodi, value_kodi = prop_str.split('=', 1)
|
||||
current_kodi_props[key_kodi.strip()] = value_kodi.strip()
|
||||
except Exception as e:
|
||||
logger.error(f"⚠️ Error parsing #KODIPROP '{logical_line}': {e}")
|
||||
|
||||
|
||||
if is_header_tag:
|
||||
yield line_with_newline
|
||||
continue
|
||||
|
||||
if logical_line and not logical_line.startswith('#') and \
|
||||
@@ -75,47 +95,55 @@ def rewrite_m3u_links_streaming(m3u_lines_iterator: Iterator[str], base_url: str
|
||||
processed_url_content = f"{base_url}/proxy/hls/manifest.m3u8?d={encoded_url}"
|
||||
elif 'vixsrc.to' in logical_line:
|
||||
encoded_url = urllib.parse.quote(logical_line, safe='')
|
||||
processed_url_content = f"{base_url}/extractor/video?host=VixCloud&redirect_stream=true&d={encoded_url}"
|
||||
processed_url_content = f"{base_url}/extractor/video?host=VixCloud&redirect_stream=true&d={encoded_url}&max_res=true&no_proxy=true"
|
||||
elif '.m3u8' in logical_line:
|
||||
encoded_url = urllib.parse.quote(logical_line, safe='')
|
||||
processed_url_content = f"{base_url}/proxy/hls/manifest.m3u8?d={encoded_url}"
|
||||
elif '.mpd' in logical_line:
|
||||
# Estrai parametri DRM dall'URL MPD se presenti
|
||||
# Estrai parametri DRM dall'URL MPD se presenti (es. &key_id=...&key=...)
|
||||
from urllib.parse import urlparse, parse_qs, urlencode, urlunparse
|
||||
|
||||
# Parse dell'URL per estrarre parametri
|
||||
parsed_url = urlparse(logical_line)
|
||||
query_params = parse_qs(parsed_url.query)
|
||||
|
||||
# Estrai key_id e key se presenti
|
||||
# Estrai key_id e key se presenti nei parametri della query
|
||||
key_id = query_params.get('key_id', [None])[0]
|
||||
key = query_params.get('key', [None])[0]
|
||||
|
||||
# Rimuovi key_id e key dai parametri originali
|
||||
clean_params = {k: v for k, v in query_params.items() if k not in ['key_id', 'key']}
|
||||
clean_query_params = {k: v for k, v in query_params.items() if k not in ['key_id', 'key']}
|
||||
|
||||
# Ricostruisci l'URL senza i parametri DRM
|
||||
clean_query = urlencode(clean_params, doseq=True) if clean_params else ''
|
||||
clean_query = urlencode(clean_query_params, doseq=True)
|
||||
clean_url = urlunparse((
|
||||
parsed_url.scheme,
|
||||
parsed_url.netloc,
|
||||
parsed_url.path,
|
||||
parsed_url.params,
|
||||
clean_query,
|
||||
parsed_url.fragment
|
||||
'' # Rimuovi il frammento per evitare problemi
|
||||
))
|
||||
|
||||
# Encode the MPD URL like other URL types
|
||||
clean_url_for_param = urllib.parse.quote(clean_url, safe='')
|
||||
|
||||
# Costruisci l'URL MediaFlow con parametri DRM separati
|
||||
processed_url_content = f"{base_url}/proxy/mpd/manifest.m3u8?d={clean_url_for_param}"
|
||||
# Codifica l'URL pulito per il parametro 'd'
|
||||
encoded_clean_url = urllib.parse.quote(clean_url, safe='')
|
||||
|
||||
# Aggiungi parametri DRM se presenti
|
||||
# Costruisci l'URL MediaFlow con parametri DRM separati
|
||||
processed_url_content = f"{base_url}/proxy/mpd/manifest.m3u8?d={encoded_clean_url}"
|
||||
|
||||
# Aggiungi i parametri DRM all'URL di MediaFlow se sono stati trovati
|
||||
if key_id:
|
||||
processed_url_content += f"&key_id={key_id}"
|
||||
if key:
|
||||
processed_url_content += f"&key={key}"
|
||||
|
||||
# Aggiungi chiavi da #KODIPROP se presenti
|
||||
license_key = current_kodi_props.get('inputstream.adaptive.license_key')
|
||||
if license_key and ':' in license_key:
|
||||
key_id_kodi, key_kodi = license_key.split(':', 1)
|
||||
processed_url_content += f"&key_id={key_id_kodi}"
|
||||
processed_url_content += f"&key={key_kodi}"
|
||||
|
||||
elif '.php' in logical_line:
|
||||
encoded_url = urllib.parse.quote(logical_line, safe='')
|
||||
processed_url_content = f"{base_url}/proxy/hls/manifest.m3u8?d={encoded_url}"
|
||||
@@ -130,6 +158,9 @@ def rewrite_m3u_links_streaming(m3u_lines_iterator: Iterator[str], base_url: str
|
||||
processed_url_content += header_params_str
|
||||
current_ext_headers = {}
|
||||
|
||||
# Resetta le proprietà KODI dopo averle usate
|
||||
current_kodi_props = {}
|
||||
|
||||
# Aggiungi api_password sempre alla fine
|
||||
if api_password:
|
||||
processed_url_content += f"&api_password={api_password}"
|
||||
@@ -150,7 +181,7 @@ async def async_download_m3u_playlist(url: str) -> list[str]:
|
||||
}
|
||||
lines = []
|
||||
try:
|
||||
async with httpx.AsyncClient(verify=True, timeout=30) as client:
|
||||
async with httpx.AsyncClient(verify=True, timeout=30, follow_redirects=True) as client:
|
||||
async with client.stream('GET', url, headers=headers) as response:
|
||||
response.raise_for_status()
|
||||
async for line_bytes in response.aiter_lines():
|
||||
@@ -164,47 +195,135 @@ async def async_download_m3u_playlist(url: str) -> list[str]:
|
||||
raise
|
||||
return lines
|
||||
|
||||
def parse_channel_entries(lines: list[str]) -> list[list[str]]:
|
||||
"""
|
||||
Analizza le linee di una playlist M3U e le raggruppa in entry di canali.
|
||||
Ogni entry è una lista di linee che compongono un singolo canale
|
||||
(da #EXTINF fino all'URL, incluse le righe intermedie).
|
||||
"""
|
||||
entries = []
|
||||
current_entry = []
|
||||
for line in lines:
|
||||
stripped_line = line.strip()
|
||||
if stripped_line.startswith('#EXTINF:'):
|
||||
if current_entry: # In caso di #EXTINF senza URL precedente
|
||||
logger.warning(f"Found a new #EXTINF tag before a URL was found for the previous entry. Discarding: {current_entry}")
|
||||
current_entry = [line]
|
||||
elif current_entry:
|
||||
current_entry.append(line)
|
||||
if stripped_line and not stripped_line.startswith('#'):
|
||||
entries.append(current_entry)
|
||||
current_entry = []
|
||||
return entries
|
||||
|
||||
|
||||
async def async_generate_combined_playlist(playlist_definitions: list[str], base_url: str, api_password: Optional[str]):
|
||||
"""Genera una playlist combinata da multiple definizioni, scaricando in parallelo."""
|
||||
# Prepara gli URL
|
||||
playlist_urls = []
|
||||
# Prepara i task di download
|
||||
download_tasks = []
|
||||
for definition in playlist_definitions:
|
||||
if '&' in definition:
|
||||
parts = definition.split('&', 1)
|
||||
playlist_url_str = parts[1] if len(parts) > 1 else parts[0]
|
||||
should_proxy = True
|
||||
playlist_url_str = definition
|
||||
should_sort = False
|
||||
|
||||
if definition.startswith('sort:'):
|
||||
should_sort = True
|
||||
definition = definition[len('sort:'):]
|
||||
|
||||
if definition.startswith('no_proxy:'): # Può essere combinato con sort:
|
||||
should_proxy = False
|
||||
playlist_url_str = definition[len('no_proxy:'):]
|
||||
else:
|
||||
playlist_url_str = definition
|
||||
playlist_urls.append(playlist_url_str)
|
||||
|
||||
download_tasks.append({
|
||||
"url": playlist_url_str,
|
||||
"proxy": should_proxy,
|
||||
"sort": should_sort
|
||||
})
|
||||
|
||||
# Scarica tutte le playlist in parallelo
|
||||
results = await asyncio.gather(*[async_download_m3u_playlist(url) for url in playlist_urls], return_exceptions=True)
|
||||
|
||||
first_playlist_header_handled = False
|
||||
for idx, lines in enumerate(results):
|
||||
if isinstance(lines, Exception):
|
||||
yield f"# ERROR processing playlist {playlist_urls[idx]}: {str(lines)}\n"
|
||||
results = await asyncio.gather(*[async_download_m3u_playlist(task["url"]) for task in download_tasks], return_exceptions=True)
|
||||
|
||||
# Raggruppa le playlist da ordinare e quelle da non ordinare
|
||||
sorted_playlist_lines = []
|
||||
unsorted_playlists_data = []
|
||||
|
||||
for idx, result in enumerate(results):
|
||||
task_info = download_tasks[idx]
|
||||
if isinstance(result, Exception):
|
||||
# Aggiungi errore come playlist non ordinata
|
||||
unsorted_playlists_data.append({'lines': [f"# ERROR processing playlist {task_info['url']}: {str(result)}\n"], 'proxy': False})
|
||||
continue
|
||||
playlist_lines: list[str] = lines # type: ignore
|
||||
current_playlist_had_lines = False
|
||||
first_line_of_this_segment = True
|
||||
lines_processed_for_current_playlist = 0
|
||||
rewritten_lines_iter = rewrite_m3u_links_streaming(iter(playlist_lines), base_url, api_password)
|
||||
for line in rewritten_lines_iter:
|
||||
current_playlist_had_lines = True
|
||||
is_extm3u_line = line.strip().startswith('#EXTM3U')
|
||||
lines_processed_for_current_playlist += 1
|
||||
if not first_playlist_header_handled:
|
||||
yield line
|
||||
if is_extm3u_line:
|
||||
|
||||
if task_info.get("sort", False):
|
||||
sorted_playlist_lines.extend(result)
|
||||
else:
|
||||
unsorted_playlists_data.append({'lines': result, 'proxy': task_info['proxy']})
|
||||
|
||||
# Gestione dell'header #EXTM3U
|
||||
first_playlist_header_handled = False
|
||||
def yield_header_once(lines_iter):
|
||||
nonlocal first_playlist_header_handled
|
||||
has_header = False
|
||||
for line in lines_iter:
|
||||
is_extm3u = line.strip().startswith('#EXTM3U')
|
||||
if is_extm3u:
|
||||
has_header = True
|
||||
if not first_playlist_header_handled:
|
||||
first_playlist_header_handled = True
|
||||
else:
|
||||
if first_line_of_this_segment and is_extm3u_line:
|
||||
pass
|
||||
else:
|
||||
yield line
|
||||
first_line_of_this_segment = False
|
||||
if current_playlist_had_lines and not first_playlist_header_handled:
|
||||
else:
|
||||
yield line
|
||||
if has_header and not first_playlist_header_handled:
|
||||
first_playlist_header_handled = True
|
||||
|
||||
# 1. Processa e ordina le playlist marcate con 'sort'
|
||||
if sorted_playlist_lines:
|
||||
# Estrai le entry dei canali
|
||||
# Modifica: Estrai le entry e mantieni l'informazione sul proxy
|
||||
channel_entries_with_proxy_info = []
|
||||
for idx, result in enumerate(results):
|
||||
task_info = download_tasks[idx]
|
||||
if task_info.get("sort") and isinstance(result, list):
|
||||
entries = parse_channel_entries(result) # result è la lista di linee della playlist
|
||||
for entry_lines in entries:
|
||||
# L'opzione proxy si applica a tutto il blocco del canale
|
||||
channel_entries_with_proxy_info.append((entry_lines, task_info["proxy"]))
|
||||
|
||||
# Ordina le entry in base al nome del canale (da #EXTINF)
|
||||
# La prima riga di ogni entry è sempre #EXTINF
|
||||
channel_entries_with_proxy_info.sort(key=lambda x: x[0][0].split(',')[-1].strip())
|
||||
|
||||
# Gestisci l'header una sola volta per il blocco ordinato
|
||||
if not first_playlist_header_handled:
|
||||
yield "#EXTM3U\n"
|
||||
first_playlist_header_handled = True
|
||||
|
||||
# Applica la riscrittura dei link in modo selettivo
|
||||
for entry_lines, should_proxy in channel_entries_with_proxy_info:
|
||||
# L'URL è l'ultima riga dell'entry
|
||||
url = entry_lines[-1]
|
||||
# Yield tutte le righe prima dell'URL
|
||||
for line in entry_lines[:-1]:
|
||||
yield line
|
||||
|
||||
if should_proxy:
|
||||
# Usa un iteratore fittizio per processare una sola linea
|
||||
rewritten_url_iter = rewrite_m3u_links_streaming(iter([url]), base_url, api_password)
|
||||
yield next(rewritten_url_iter, url) # Prende l'URL riscritto, con fallback all'originale
|
||||
else:
|
||||
yield url # Lascia l'URL invariato
|
||||
|
||||
|
||||
# 2. Accoda le playlist non ordinate
|
||||
for playlist_data in unsorted_playlists_data:
|
||||
lines_iterator = iter(playlist_data['lines'])
|
||||
if playlist_data['proxy']:
|
||||
lines_iterator = rewrite_m3u_links_streaming(lines_iterator, base_url, api_password)
|
||||
|
||||
for line in yield_header_once(lines_iterator):
|
||||
yield line
|
||||
|
||||
|
||||
@playlist_builder_router.get("/playlist")
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import asyncio
|
||||
from typing import Annotated
|
||||
from urllib.parse import quote, unquote
|
||||
import re
|
||||
import logging
|
||||
import httpx
|
||||
import time
|
||||
|
||||
from fastapi import Request, Depends, APIRouter, Query, HTTPException
|
||||
from fastapi.responses import Response, RedirectResponse
|
||||
from fastapi.responses import Response
|
||||
|
||||
from mediaflow_proxy.handlers import (
|
||||
handle_hls_stream_proxy,
|
||||
@@ -21,11 +24,22 @@ from mediaflow_proxy.schemas import (
|
||||
HLSManifestParams,
|
||||
MPDManifestParams,
|
||||
)
|
||||
from mediaflow_proxy.utils.http_utils import get_proxy_headers, ProxyRequestHeaders
|
||||
from mediaflow_proxy.utils.http_utils import (
|
||||
get_proxy_headers,
|
||||
ProxyRequestHeaders,
|
||||
create_httpx_client,
|
||||
)
|
||||
from mediaflow_proxy.utils.base64_utils import process_potential_base64_url
|
||||
|
||||
proxy_router = APIRouter()
|
||||
|
||||
# DLHD extraction cache: {original_url: {"data": extraction_result, "timestamp": time.time()}}
|
||||
_dlhd_extraction_cache = {}
|
||||
_dlhd_cache_duration = 600 # 10 minutes in seconds
|
||||
|
||||
_sportsonline_extraction_cache = {}
|
||||
_sportsonline_cache_duration = 600 # 10 minutes in seconds
|
||||
|
||||
|
||||
def sanitize_url(url: str) -> str:
|
||||
"""
|
||||
@@ -122,41 +136,146 @@ def extract_drm_params_from_url(url: str) -> tuple[str, str, str]:
|
||||
return clean_url, key_id, key
|
||||
|
||||
|
||||
def _check_and_redirect_dlhd_stream(request: Request, destination: str) -> RedirectResponse | None:
|
||||
def _invalidate_dlhd_cache(destination: str):
|
||||
"""Invalidate DLHD cache for a specific destination URL."""
|
||||
if destination in _dlhd_extraction_cache:
|
||||
del _dlhd_extraction_cache[destination]
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.info(f"DLHD cache invalidated for: {destination}")
|
||||
|
||||
|
||||
async def _check_and_extract_dlhd_stream(
|
||||
request: Request,
|
||||
destination: str,
|
||||
proxy_headers: ProxyRequestHeaders,
|
||||
force_refresh: bool = False
|
||||
) -> dict | None:
|
||||
"""
|
||||
Check if destination contains stream-{numero} pattern and redirect to extractor if needed.
|
||||
Check if destination contains DLHD/DaddyLive patterns and extract stream directly.
|
||||
Uses caching to avoid repeated extractions (10 minute cache).
|
||||
|
||||
Args:
|
||||
request (Request): The incoming HTTP request.
|
||||
destination (str): The destination URL to check.
|
||||
proxy_headers (ProxyRequestHeaders): The headers to include in the request.
|
||||
force_refresh (bool): Force re-extraction even if cached data exists.
|
||||
|
||||
Returns:
|
||||
RedirectResponse | None: RedirectResponse if redirect is needed, None otherwise.
|
||||
dict | None: Extracted stream data if DLHD link detected, None otherwise.
|
||||
"""
|
||||
import re
|
||||
from urllib.parse import urlparse
|
||||
from mediaflow_proxy.extractors.factory import ExtractorFactory
|
||||
from mediaflow_proxy.extractors.base import ExtractorError
|
||||
from mediaflow_proxy.utils.http_utils import DownloadError
|
||||
|
||||
# Check for stream-{numero} pattern (e.g., stream-1, stream-123, etc.)
|
||||
if re.search(r'stream-\d+', destination):
|
||||
from urllib.parse import urlencode
|
||||
# Check for common DLHD/DaddyLive patterns in the URL
|
||||
# This includes stream-XXX pattern and domain names like dlhd.dad or daddylive.sx
|
||||
is_dlhd_link = (
|
||||
re.search(r'stream-\d+', destination) or
|
||||
"dlhd.dad" in urlparse(destination).netloc or
|
||||
"daddylive.sx" in urlparse(destination).netloc
|
||||
)
|
||||
|
||||
if not is_dlhd_link:
|
||||
return None
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.info(f"DLHD link detected: {destination}")
|
||||
|
||||
# Check cache first (unless force_refresh is True)
|
||||
current_time = time.time()
|
||||
if not force_refresh and destination in _dlhd_extraction_cache:
|
||||
cached_entry = _dlhd_extraction_cache[destination]
|
||||
cache_age = current_time - cached_entry["timestamp"]
|
||||
|
||||
# Build redirect URL to extractor
|
||||
redirect_params = {
|
||||
"host": "DLHD",
|
||||
"redirect_stream": "true",
|
||||
"d": destination
|
||||
if cache_age < _dlhd_cache_duration:
|
||||
logger.info(f"Using cached DLHD data (age: {cache_age:.1f}s)")
|
||||
return cached_entry["data"]
|
||||
else:
|
||||
logger.info(f"DLHD cache expired (age: {cache_age:.1f}s), re-extracting...")
|
||||
del _dlhd_extraction_cache[destination]
|
||||
|
||||
# Extract stream data
|
||||
try:
|
||||
logger.info(f"Extracting DLHD stream data from: {destination}")
|
||||
extractor = ExtractorFactory.get_extractor("DLHD", proxy_headers.request)
|
||||
result = await extractor.extract(destination)
|
||||
|
||||
logger.info(f"DLHD extraction successful. Stream URL: {result.get('destination_url')}")
|
||||
|
||||
# Cache the result
|
||||
_dlhd_extraction_cache[destination] = {
|
||||
"data": result,
|
||||
"timestamp": current_time
|
||||
}
|
||||
logger.info(f"DLHD data cached for {_dlhd_cache_duration}s")
|
||||
|
||||
# Preserve api_password if present
|
||||
if "api_password" in request.query_params:
|
||||
redirect_params["api_password"] = request.query_params["api_password"]
|
||||
return result
|
||||
|
||||
# Build the redirect URL
|
||||
base_url = str(request.url_for("extract_url"))
|
||||
redirect_url = f"{base_url}?{urlencode(redirect_params)}"
|
||||
|
||||
return RedirectResponse(url=redirect_url, status_code=302)
|
||||
|
||||
return None
|
||||
except (ExtractorError, DownloadError) as e:
|
||||
logger.error(f"DLHD extraction failed: {str(e)}")
|
||||
raise HTTPException(status_code=400, detail=f"DLHD extraction failed: {str(e)}")
|
||||
except Exception as e:
|
||||
logger.exception(f"Unexpected error during DLHD extraction: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=f"DLHD extraction failed: {str(e)}")
|
||||
|
||||
|
||||
async def _check_and_extract_sportsonline_stream(
|
||||
request: Request,
|
||||
destination: str,
|
||||
proxy_headers: ProxyRequestHeaders,
|
||||
force_refresh: bool = False
|
||||
) -> dict | None:
|
||||
"""
|
||||
Check if destination contains Sportsonline/Sportzonline patterns and extract stream directly.
|
||||
Uses caching to avoid repeated extractions (10 minute cache).
|
||||
|
||||
Args:
|
||||
request (Request): The incoming HTTP request.
|
||||
destination (str): The destination URL to check.
|
||||
proxy_headers (ProxyRequestHeaders): The headers to include in the request.
|
||||
force_refresh (bool): Force re-extraction even if cached data exists.
|
||||
|
||||
Returns:
|
||||
dict | None: Extracted stream data if Sportsonline link detected, None otherwise.
|
||||
"""
|
||||
import re
|
||||
from urllib.parse import urlparse
|
||||
from mediaflow_proxy.extractors.factory import ExtractorFactory
|
||||
from mediaflow_proxy.extractors.base import ExtractorError
|
||||
from mediaflow_proxy.utils.http_utils import DownloadError
|
||||
|
||||
parsed_netloc = urlparse(destination).netloc
|
||||
is_sportsonline_link = "sportzonline." in parsed_netloc or "sportsonline." in parsed_netloc
|
||||
|
||||
if not is_sportsonline_link:
|
||||
return None
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.info(f"Sportsonline link detected: {destination}")
|
||||
|
||||
current_time = time.time()
|
||||
if not force_refresh and destination in _sportsonline_extraction_cache:
|
||||
cached_entry = _sportsonline_extraction_cache[destination]
|
||||
if current_time - cached_entry["timestamp"] < _sportsonline_cache_duration:
|
||||
logger.info(f"Using cached Sportsonline data (age: {current_time - cached_entry['timestamp']:.1f}s)")
|
||||
return cached_entry["data"]
|
||||
else:
|
||||
logger.info("Sportsonline cache expired, re-extracting...")
|
||||
del _sportsonline_extraction_cache[destination]
|
||||
|
||||
try:
|
||||
logger.info(f"Extracting Sportsonline stream data from: {destination}")
|
||||
extractor = ExtractorFactory.get_extractor("Sportsonline", proxy_headers.request)
|
||||
result = await extractor.extract(destination)
|
||||
logger.info(f"Sportsonline extraction successful. Stream URL: {result.get('destination_url')}")
|
||||
_sportsonline_extraction_cache[destination] = {"data": result, "timestamp": current_time}
|
||||
logger.info(f"Sportsonline data cached for {_sportsonline_cache_duration}s")
|
||||
return result
|
||||
except (ExtractorError, DownloadError, Exception) as e:
|
||||
logger.error(f"Sportsonline extraction failed: {str(e)}")
|
||||
raise HTTPException(status_code=400, detail=f"Sportsonline extraction failed: {str(e)}")
|
||||
|
||||
|
||||
@proxy_router.head("/hls/manifest.m3u8")
|
||||
@@ -179,12 +298,197 @@ async def hls_manifest_proxy(
|
||||
Response: The HTTP response with the processed m3u8 playlist or streamed content.
|
||||
"""
|
||||
# Sanitize destination URL to fix common encoding issues
|
||||
original_destination = hls_params.destination
|
||||
hls_params.destination = sanitize_url(hls_params.destination)
|
||||
|
||||
# Check if destination contains stream-{numero} pattern and redirect to extractor
|
||||
redirect_response = _check_and_redirect_dlhd_stream(request, hls_params.destination)
|
||||
if redirect_response:
|
||||
return redirect_response
|
||||
# Check if this is a retry after 403 error (dlhd_retry parameter)
|
||||
force_refresh = request.query_params.get("dlhd_retry") == "1"
|
||||
|
||||
# Check if destination contains DLHD pattern and extract stream directly
|
||||
dlhd_result = await _check_and_extract_dlhd_stream(
|
||||
request, hls_params.destination, proxy_headers, force_refresh=force_refresh
|
||||
)
|
||||
dlhd_original_url = None
|
||||
if dlhd_result:
|
||||
# Store original DLHD URL for cache invalidation on 403 errors
|
||||
dlhd_original_url = hls_params.destination
|
||||
|
||||
# Update destination and headers with extracted stream data
|
||||
hls_params.destination = dlhd_result["destination_url"]
|
||||
extracted_headers = dlhd_result.get("request_headers", {})
|
||||
proxy_headers.request.update(extracted_headers)
|
||||
|
||||
# Check if extractor wants key-only proxy (DLHD uses hls_key_proxy endpoint)
|
||||
if dlhd_result.get("mediaflow_endpoint") == "hls_key_proxy":
|
||||
hls_params.key_only_proxy = True
|
||||
|
||||
# Also add headers to query params so they propagate to key/segment requests
|
||||
# This is necessary because M3U8Processor encodes headers as h_* query params
|
||||
from fastapi.datastructures import QueryParams
|
||||
query_dict = dict(request.query_params)
|
||||
for header_name, header_value in extracted_headers.items():
|
||||
# Add header with h_ prefix to query params
|
||||
query_dict[f"h_{header_name}"] = header_value
|
||||
# Add DLHD original URL to track for cache invalidation
|
||||
if dlhd_original_url:
|
||||
query_dict["dlhd_original"] = dlhd_original_url
|
||||
# Remove retry flag from subsequent requests
|
||||
query_dict.pop("dlhd_retry", None)
|
||||
# Update request query params
|
||||
request._query_params = QueryParams(query_dict)
|
||||
|
||||
# Check if destination contains Sportsonline pattern and extract stream directly
|
||||
sportsonline_result = await _check_and_extract_sportsonline_stream(
|
||||
request, hls_params.destination, proxy_headers
|
||||
)
|
||||
if sportsonline_result:
|
||||
# Update destination and headers with extracted stream data
|
||||
hls_params.destination = sportsonline_result["destination_url"]
|
||||
extracted_headers = sportsonline_result.get("request_headers", {})
|
||||
proxy_headers.request.update(extracted_headers)
|
||||
|
||||
# Check if extractor wants key-only proxy
|
||||
if sportsonline_result.get("mediaflow_endpoint") == "hls_key_proxy":
|
||||
hls_params.key_only_proxy = True
|
||||
|
||||
# Also add headers to query params so they propagate to key/segment requests
|
||||
from fastapi.datastructures import QueryParams
|
||||
query_dict = dict(request.query_params)
|
||||
for header_name, header_value in extracted_headers.items():
|
||||
# Add header with h_ prefix to query params
|
||||
query_dict[f"h_{header_name}"] = header_value
|
||||
# Remove retry flag from subsequent requests
|
||||
query_dict.pop("dlhd_retry", None)
|
||||
# Update request query params
|
||||
request._query_params = QueryParams(query_dict)
|
||||
|
||||
# Wrap the handler to catch 403 errors and retry with cache invalidation
|
||||
try:
|
||||
result = await _handle_hls_with_dlhd_retry(request, hls_params, proxy_headers, dlhd_original_url)
|
||||
return result
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.exception(f"Unexpected error in hls_manifest_proxy: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
async def _handle_hls_with_dlhd_retry(
|
||||
request: Request,
|
||||
hls_params: HLSManifestParams,
|
||||
proxy_headers: ProxyRequestHeaders,
|
||||
dlhd_original_url: str | None
|
||||
):
|
||||
"""
|
||||
Handle HLS request with automatic retry on 403 errors for DLHD streams.
|
||||
"""
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
if hls_params.max_res:
|
||||
from mediaflow_proxy.utils.hls_utils import parse_hls_playlist
|
||||
from mediaflow_proxy.utils.m3u8_processor import M3U8Processor
|
||||
|
||||
async with create_httpx_client(
|
||||
headers=proxy_headers.request,
|
||||
follow_redirects=True,
|
||||
) as client:
|
||||
try:
|
||||
response = await client.get(hls_params.destination)
|
||||
response.raise_for_status()
|
||||
playlist_content = response.text
|
||||
except httpx.HTTPStatusError as e:
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail=f"Failed to fetch HLS manifest from origin: {e.response.status_code} {e.response.reason_phrase}",
|
||||
) from e
|
||||
except httpx.TimeoutException as e:
|
||||
raise HTTPException(
|
||||
status_code=504,
|
||||
detail=f"Timeout while fetching HLS manifest: {e}",
|
||||
) from e
|
||||
except httpx.RequestError as e:
|
||||
raise HTTPException(status_code=502, detail=f"Network error fetching HLS manifest: {e}") from e
|
||||
|
||||
streams = parse_hls_playlist(playlist_content, base_url=hls_params.destination)
|
||||
if not streams:
|
||||
raise HTTPException(
|
||||
status_code=404, detail="No streams found in the manifest."
|
||||
)
|
||||
|
||||
highest_res_stream = max(
|
||||
streams,
|
||||
key=lambda s: s.get("resolution", (0, 0))[0]
|
||||
* s.get("resolution", (0, 0))[1],
|
||||
)
|
||||
|
||||
if highest_res_stream.get("resolution", (0, 0)) == (0, 0):
|
||||
logging.warning("Selected stream has resolution (0, 0); resolution parsing may have failed or not be available in the manifest.")
|
||||
|
||||
# Rebuild the manifest preserving master-level directives
|
||||
# but removing non-selected variant blocks
|
||||
lines = playlist_content.splitlines()
|
||||
highest_variant_index = streams.index(highest_res_stream)
|
||||
|
||||
variant_index = -1
|
||||
new_manifest_lines = []
|
||||
i = 0
|
||||
while i < len(lines):
|
||||
line = lines[i]
|
||||
if line.startswith("#EXT-X-STREAM-INF"):
|
||||
variant_index += 1
|
||||
next_line = ""
|
||||
if i + 1 < len(lines) and not lines[i + 1].startswith("#"):
|
||||
next_line = lines[i + 1]
|
||||
|
||||
# Only keep the selected variant
|
||||
if variant_index == highest_variant_index:
|
||||
new_manifest_lines.append(line)
|
||||
if next_line:
|
||||
new_manifest_lines.append(next_line)
|
||||
|
||||
# Skip variant block (stream-inf + optional url)
|
||||
i += 2 if next_line else 1
|
||||
continue
|
||||
|
||||
# Preserve all other lines (master directives, media tags, etc.)
|
||||
new_manifest_lines.append(line)
|
||||
i += 1
|
||||
|
||||
new_manifest = "\n".join(new_manifest_lines)
|
||||
|
||||
# Process the new manifest to proxy all URLs within it
|
||||
processor = M3U8Processor(request, hls_params.key_url, hls_params.force_playlist_proxy, hls_params.key_only_proxy, hls_params.no_proxy)
|
||||
processed_manifest = await processor.process_m3u8(new_manifest, base_url=hls_params.destination)
|
||||
|
||||
return Response(content=processed_manifest, media_type="application/vnd.apple.mpegurl")
|
||||
|
||||
return await handle_hls_stream_proxy(request, hls_params, proxy_headers)
|
||||
|
||||
|
||||
@proxy_router.head("/hls/key_proxy/manifest.m3u8", name="hls_key_proxy")
|
||||
@proxy_router.get("/hls/key_proxy/manifest.m3u8", name="hls_key_proxy")
|
||||
async def hls_key_proxy(
|
||||
request: Request,
|
||||
hls_params: Annotated[HLSManifestParams, Query()],
|
||||
proxy_headers: Annotated[ProxyRequestHeaders, Depends(get_proxy_headers)],
|
||||
):
|
||||
"""
|
||||
Proxify HLS stream requests, but only proxy the key URL, leaving segment URLs direct.
|
||||
|
||||
Args:
|
||||
request (Request): The incoming HTTP request.
|
||||
hls_params (HLSManifestParams): The parameters for the HLS stream request.
|
||||
proxy_headers (ProxyRequestHeaders): The headers to include in the request.
|
||||
|
||||
Returns:
|
||||
Response: The HTTP response with the processed m3u8 playlist.
|
||||
"""
|
||||
# Sanitize destination URL to fix common encoding issues
|
||||
hls_params.destination = sanitize_url(hls_params.destination)
|
||||
|
||||
# Set the key_only_proxy flag to True
|
||||
hls_params.key_only_proxy = True
|
||||
|
||||
return await handle_hls_stream_proxy(request, hls_params, proxy_headers)
|
||||
|
||||
@@ -208,7 +512,7 @@ async def hls_segment_proxy(
|
||||
"""
|
||||
from mediaflow_proxy.utils.hls_prebuffer import hls_prebuffer
|
||||
from mediaflow_proxy.configs import settings
|
||||
|
||||
|
||||
# Sanitize segment URL to fix common encoding issues
|
||||
segment_url = sanitize_url(segment_url)
|
||||
|
||||
@@ -217,11 +521,13 @@ async def hls_segment_proxy(
|
||||
for key, value in request.query_params.items():
|
||||
if key.startswith("h_"):
|
||||
headers[key[2:]] = value
|
||||
|
||||
|
||||
# Try to get segment from pre-buffer cache first
|
||||
if settings.enable_hls_prebuffer:
|
||||
cached_segment = await hls_prebuffer.get_segment(segment_url, headers)
|
||||
if cached_segment:
|
||||
# Avvia prebuffer dei successivi in background
|
||||
asyncio.create_task(hls_prebuffer.prebuffer_from_segment(segment_url, headers))
|
||||
return Response(
|
||||
content=cached_segment,
|
||||
media_type="video/mp2t",
|
||||
@@ -231,8 +537,11 @@ async def hls_segment_proxy(
|
||||
"Access-Control-Allow-Origin": "*"
|
||||
}
|
||||
)
|
||||
|
||||
# Fallback to direct streaming if not in cache
|
||||
|
||||
# Fallback to direct streaming se non in cache:
|
||||
# prima di restituire, prova comunque a far partire il prebuffer dei successivi
|
||||
if settings.enable_hls_prebuffer:
|
||||
asyncio.create_task(hls_prebuffer.prebuffer_from_segment(segment_url, headers))
|
||||
return await handle_stream_request("GET", segment_url, proxy_headers)
|
||||
|
||||
|
||||
@@ -308,16 +617,21 @@ async def proxy_stream_endpoint(
|
||||
# Sanitize destination URL to fix common encoding issues
|
||||
destination = sanitize_url(destination)
|
||||
|
||||
# Check if destination contains stream-{numero} pattern and redirect to extractor
|
||||
redirect_response = _check_and_redirect_dlhd_stream(request, destination)
|
||||
if redirect_response:
|
||||
return redirect_response
|
||||
# Check if destination contains DLHD pattern and extract stream directly
|
||||
dlhd_result = await _check_and_extract_dlhd_stream(request, destination, proxy_headers)
|
||||
if dlhd_result:
|
||||
# Update destination and headers with extracted stream data
|
||||
destination = dlhd_result["destination_url"]
|
||||
proxy_headers.request.update(dlhd_result.get("request_headers", {}))
|
||||
if proxy_headers.request.get("range", "").strip() == "":
|
||||
proxy_headers.request.pop("range", None)
|
||||
|
||||
if proxy_headers.request.get("if-range", "").strip() == "":
|
||||
proxy_headers.request.pop("if-range", None)
|
||||
|
||||
if "range" not in proxy_headers.request:
|
||||
proxy_headers.request["range"] = "bytes=0-"
|
||||
|
||||
content_range = proxy_headers.request.get("range", "bytes=0-")
|
||||
if "nan" in content_range.casefold():
|
||||
# Handle invalid range requests "bytes=NaN-NaN"
|
||||
raise HTTPException(status_code=416, detail="Invalid Range Header")
|
||||
proxy_headers.request.update({"range": content_range})
|
||||
if filename:
|
||||
# If a filename is provided, set it in the headers using RFC 6266 format
|
||||
try:
|
||||
@@ -430,4 +744,4 @@ async def get_mediaflow_proxy_public_ip():
|
||||
Returns:
|
||||
Response: The HTTP response with the public IP address in the form of a JSON object. {"ip": "xxx.xxx.xxx.xxx"}
|
||||
"""
|
||||
return await get_public_ip()
|
||||
return await get_public_ip()
|
||||
|
||||
@@ -71,6 +71,18 @@ class HLSManifestParams(GenericParams):
|
||||
None,
|
||||
description="Force all playlist URLs to be proxied through MediaFlow regardless of m3u8_content_routing setting. Useful for IPTV m3u/m3u_plus formats that don't have clear URL indicators.",
|
||||
)
|
||||
key_only_proxy: Optional[bool] = Field(
|
||||
False,
|
||||
description="Only proxy the key URL, leaving segment URLs direct.",
|
||||
)
|
||||
no_proxy: bool = Field(
|
||||
False,
|
||||
description="If true, returns the manifest content without proxying any internal URLs (segments, keys, playlists).",
|
||||
)
|
||||
max_res: bool = Field(
|
||||
False,
|
||||
description="If true, redirects to the highest resolution stream in the manifest.",
|
||||
)
|
||||
|
||||
|
||||
class MPDManifestParams(GenericParams):
|
||||
@@ -92,11 +104,12 @@ class MPDSegmentParams(GenericParams):
|
||||
mime_type: str = Field(..., description="The MIME type of the segment.")
|
||||
key_id: Optional[str] = Field(None, description="The DRM key ID (optional).")
|
||||
key: Optional[str] = Field(None, description="The DRM key (optional).")
|
||||
is_live: Optional[bool] = Field(None, alias="is_live", description="Whether the parent MPD is live.")
|
||||
|
||||
|
||||
class ExtractorURLParams(GenericParams):
|
||||
host: Literal[
|
||||
"Doodstream", "FileLions", "Mixdrop", "Uqload", "Streamtape", "Supervideo", "VixCloud", "Okru", "Maxstream", "LiveTV", "DLHD", "Fastream"
|
||||
"Doodstream", "FileLions", "FileMoon", "F16Px", "Mixdrop", "Uqload", "Streamtape", "StreamWish", "Supervideo", "VixCloud", "Okru", "Maxstream", "LiveTV", "LuluStream", "DLHD", "Fastream", "TurboVidPlay", "Vidmoly", "Vidoza", "Voe", "Sportsonline"
|
||||
] = Field(..., description="The host to extract the URL from.")
|
||||
destination: str = Field(..., description="The URL of the stream.", alias="d")
|
||||
redirect_stream: bool = Field(False, description="Whether to redirect to the stream endpoint automatically.")
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,11 +1,8 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
|
||||
<meta charset="UTF-8">
|
||||
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
|
||||
|
||||
<title>App Status</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Your App is running</h1>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
h2 { color: #2c5aa0; border-bottom: 2px solid #2c5aa0; padding-bottom: 5px; text-align: left; margin-top: 30px; }
|
||||
.form-group { margin-bottom: 15px; }
|
||||
label { display: block; margin-bottom: 5px; font-weight: bold; color: #555; }
|
||||
.proxy-label { display: inline-block; margin-left: 5px; font-weight: normal; }
|
||||
input[type="text"], input[type="url"] { width: 100%; padding: 10px; border: 1px solid #ccc; border-radius: 4px; box-sizing: border-box; }
|
||||
.btn { display: inline-block; padding: 10px 20px; background: #2c5aa0; color: white; text-decoration: none; border-radius: 5px; margin: 5px; cursor: pointer; border: none; font-size: 16px; }
|
||||
.btn:hover { background: #1e3d6f; }
|
||||
@@ -60,6 +61,14 @@
|
||||
<label>M3U Playlist URL</label>
|
||||
<input type="url" class="playlist-url" placeholder="Ex: http://provider.com/playlist.m3u">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<input type="checkbox" class="proxy-playlist" checked>
|
||||
<label class="proxy-label">Proxy this playlist</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<input type="checkbox" class="sort-playlist">
|
||||
<label class="proxy-label">Sort this playlist</label>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -94,8 +103,14 @@
|
||||
for (const entry of entries) {
|
||||
const playlistUrl = entry.querySelector('.playlist-url').value.trim();
|
||||
if (playlistUrl) {
|
||||
const shouldProxy = entry.querySelector('.proxy-playlist').checked;
|
||||
const shouldSort = entry.querySelector('.sort-playlist').checked;
|
||||
|
||||
let definition = (shouldSort ? 'sort:' : '') + (shouldProxy ? '' : 'no_proxy:') + playlistUrl;
|
||||
|
||||
if (playlistUrl.startsWith('http://') || playlistUrl.startsWith('https://')) {
|
||||
definitions.push(playlistUrl);
|
||||
// Se l'URL non ha proxy, ma ha sort, il prefisso sarà 'sort:no_proxy:'
|
||||
definitions.push(definition);
|
||||
} else {
|
||||
alert('Invalid URL: ' + playlistUrl + '. URLs must start with http:// or https://');
|
||||
return;
|
||||
|
||||
@@ -425,12 +425,14 @@ class MediaFlowSpeedTest {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
// Add current API password to headers if provided
|
||||
// Build URL with api_password as query parameter if provided
|
||||
// This is more reliable than headers when behind reverse proxies
|
||||
let configUrl = '/speedtest/config';
|
||||
if (currentApiPassword) {
|
||||
headers['api_password'] = currentApiPassword;
|
||||
configUrl += `?api_password=${encodeURIComponent(currentApiPassword)}`;
|
||||
}
|
||||
|
||||
const response = await fetch('/speedtest/config', {
|
||||
const response = await fetch(configUrl, {
|
||||
method: 'POST',
|
||||
headers: headers,
|
||||
body: JSON.stringify(requestBody)
|
||||
|
||||
39
mediaflow_proxy/utils/aes.py
Normal file
39
mediaflow_proxy/utils/aes.py
Normal file
@@ -0,0 +1,39 @@
|
||||
# Author: Trevor Perrin
|
||||
# See the LICENSE file for legal information regarding use of this file.
|
||||
|
||||
"""Abstract class for AES."""
|
||||
|
||||
class AES(object):
|
||||
def __init__(self, key, mode, IV, implementation):
|
||||
if len(key) not in (16, 24, 32):
|
||||
raise AssertionError()
|
||||
if mode not in [2, 6]:
|
||||
raise AssertionError()
|
||||
if mode == 2:
|
||||
if len(IV) != 16:
|
||||
raise AssertionError()
|
||||
if mode == 6:
|
||||
if len(IV) > 16:
|
||||
raise AssertionError()
|
||||
self.isBlockCipher = True
|
||||
self.isAEAD = False
|
||||
self.block_size = 16
|
||||
self.implementation = implementation
|
||||
if len(key)==16:
|
||||
self.name = "aes128"
|
||||
elif len(key)==24:
|
||||
self.name = "aes192"
|
||||
elif len(key)==32:
|
||||
self.name = "aes256"
|
||||
else:
|
||||
raise AssertionError()
|
||||
|
||||
#CBC-Mode encryption, returns ciphertext
|
||||
#WARNING: *MAY* modify the input as well
|
||||
def encrypt(self, plaintext):
|
||||
assert(len(plaintext) % 16 == 0)
|
||||
|
||||
#CBC-Mode decryption, returns plaintext
|
||||
#WARNING: *MAY* modify the input as well
|
||||
def decrypt(self, ciphertext):
|
||||
assert(len(ciphertext) % 16 == 0)
|
||||
193
mediaflow_proxy/utils/aesgcm.py
Normal file
193
mediaflow_proxy/utils/aesgcm.py
Normal file
@@ -0,0 +1,193 @@
|
||||
# Author: Google
|
||||
# See the LICENSE file for legal information regarding use of this file.
|
||||
|
||||
# GCM derived from Go's implementation in crypto/cipher.
|
||||
#
|
||||
# https://golang.org/src/crypto/cipher/gcm.go
|
||||
|
||||
# GCM works over elements of the field GF(2^128), each of which is a 128-bit
|
||||
# polynomial. Throughout this implementation, polynomials are represented as
|
||||
# Python integers with the low-order terms at the most significant bits. So a
|
||||
# 128-bit polynomial is an integer from 0 to 2^128-1 with the most significant
|
||||
# bit representing the x^0 term and the least significant bit representing the
|
||||
# x^127 term. This bit reversal also applies to polynomials used as indices in a
|
||||
# look-up table.
|
||||
|
||||
from __future__ import division
|
||||
from . import python_aes
|
||||
from .constanttime import ct_compare_digest
|
||||
from .cryptomath import bytesToNumber, numberToByteArray
|
||||
|
||||
class AESGCM(object):
|
||||
"""
|
||||
AES-GCM implementation. Note: this implementation does not attempt
|
||||
to be side-channel resistant. It's also rather slow.
|
||||
"""
|
||||
|
||||
def __init__(self, key, implementation, rawAesEncrypt):
|
||||
self.isBlockCipher = False
|
||||
self.isAEAD = True
|
||||
self.nonceLength = 12
|
||||
self.tagLength = 16
|
||||
self.implementation = implementation
|
||||
if len(key) == 16:
|
||||
self.name = "aes128gcm"
|
||||
elif len(key) == 32:
|
||||
self.name = "aes256gcm"
|
||||
else:
|
||||
raise AssertionError()
|
||||
self.key = key
|
||||
|
||||
self._rawAesEncrypt = rawAesEncrypt
|
||||
self._ctr = python_aes.new(self.key, 6, bytearray(b'\x00' * 16))
|
||||
|
||||
# The GCM key is AES(0).
|
||||
h = bytesToNumber(self._rawAesEncrypt(bytearray(16)))
|
||||
|
||||
# Pre-compute all 4-bit multiples of h. Note that bits are reversed
|
||||
# because our polynomial representation places low-order terms at the
|
||||
# most significant bit. Thus x^0 * h = h is at index 0b1000 = 8 and
|
||||
# x^1 * h is at index 0b0100 = 4.
|
||||
self._productTable = [0] * 16
|
||||
self._productTable[self._reverseBits(1)] = h
|
||||
for i in range(2, 16, 2):
|
||||
self._productTable[self._reverseBits(i)] = \
|
||||
self._gcmShift(self._productTable[self._reverseBits(i//2)])
|
||||
self._productTable[self._reverseBits(i+1)] = \
|
||||
self._gcmAdd(self._productTable[self._reverseBits(i)], h)
|
||||
|
||||
|
||||
def _auth(self, ciphertext, ad, tagMask):
|
||||
y = 0
|
||||
y = self._update(y, ad)
|
||||
y = self._update(y, ciphertext)
|
||||
y ^= (len(ad) << (3 + 64)) | (len(ciphertext) << 3)
|
||||
y = self._mul(y)
|
||||
y ^= bytesToNumber(tagMask)
|
||||
return numberToByteArray(y, 16)
|
||||
|
||||
def _update(self, y, data):
|
||||
for i in range(0, len(data) // 16):
|
||||
y ^= bytesToNumber(data[16*i:16*i+16])
|
||||
y = self._mul(y)
|
||||
extra = len(data) % 16
|
||||
if extra != 0:
|
||||
block = bytearray(16)
|
||||
block[:extra] = data[-extra:]
|
||||
y ^= bytesToNumber(block)
|
||||
y = self._mul(y)
|
||||
return y
|
||||
|
||||
def _mul(self, y):
|
||||
""" Returns y*H, where H is the GCM key. """
|
||||
ret = 0
|
||||
# Multiply H by y 4 bits at a time, starting with the highest power
|
||||
# terms.
|
||||
for i in range(0, 128, 4):
|
||||
# Multiply by x^4. The reduction for the top four terms is
|
||||
# precomputed.
|
||||
retHigh = ret & 0xf
|
||||
ret >>= 4
|
||||
ret ^= (AESGCM._gcmReductionTable[retHigh] << (128-16))
|
||||
|
||||
# Add in y' * H where y' are the next four terms of y, shifted down
|
||||
# to the x^0..x^4. This is one of the pre-computed multiples of
|
||||
# H. The multiplication by x^4 shifts them back into place.
|
||||
ret ^= self._productTable[y & 0xf]
|
||||
y >>= 4
|
||||
assert y == 0
|
||||
return ret
|
||||
|
||||
def seal(self, nonce, plaintext, data=''):
|
||||
"""
|
||||
Encrypts and authenticates plaintext using nonce and data. Returns the
|
||||
ciphertext, consisting of the encrypted plaintext and tag concatenated.
|
||||
"""
|
||||
|
||||
if len(nonce) != 12:
|
||||
raise ValueError("Bad nonce length")
|
||||
|
||||
# The initial counter value is the nonce, followed by a 32-bit counter
|
||||
# that starts at 1. It's used to compute the tag mask.
|
||||
counter = bytearray(16)
|
||||
counter[:12] = nonce
|
||||
counter[-1] = 1
|
||||
tagMask = self._rawAesEncrypt(counter)
|
||||
|
||||
# The counter starts at 2 for the actual encryption.
|
||||
counter[-1] = 2
|
||||
self._ctr.counter = counter
|
||||
ciphertext = self._ctr.encrypt(plaintext)
|
||||
|
||||
tag = self._auth(ciphertext, data, tagMask)
|
||||
|
||||
return ciphertext + tag
|
||||
|
||||
def open(self, nonce, ciphertext, data=''):
|
||||
"""
|
||||
Decrypts and authenticates ciphertext using nonce and data. If the
|
||||
tag is valid, the plaintext is returned. If the tag is invalid,
|
||||
returns None.
|
||||
"""
|
||||
|
||||
if len(nonce) != 12:
|
||||
raise ValueError("Bad nonce length")
|
||||
if len(ciphertext) < 16:
|
||||
return None
|
||||
|
||||
tag = ciphertext[-16:]
|
||||
ciphertext = ciphertext[:-16]
|
||||
|
||||
# The initial counter value is the nonce, followed by a 32-bit counter
|
||||
# that starts at 1. It's used to compute the tag mask.
|
||||
counter = bytearray(16)
|
||||
counter[:12] = nonce
|
||||
counter[-1] = 1
|
||||
tagMask = self._rawAesEncrypt(counter)
|
||||
|
||||
if data and not ct_compare_digest(tag, self._auth(ciphertext, data, tagMask)):
|
||||
return None
|
||||
|
||||
# The counter starts at 2 for the actual decryption.
|
||||
counter[-1] = 2
|
||||
self._ctr.counter = counter
|
||||
return self._ctr.decrypt(ciphertext)
|
||||
|
||||
@staticmethod
|
||||
def _reverseBits(i):
|
||||
assert i < 16
|
||||
i = ((i << 2) & 0xc) | ((i >> 2) & 0x3)
|
||||
i = ((i << 1) & 0xa) | ((i >> 1) & 0x5)
|
||||
return i
|
||||
|
||||
@staticmethod
|
||||
def _gcmAdd(x, y):
|
||||
return x ^ y
|
||||
|
||||
@staticmethod
|
||||
def _gcmShift(x):
|
||||
# Multiplying by x is a right shift, due to bit order.
|
||||
highTermSet = x & 1
|
||||
x >>= 1
|
||||
if highTermSet:
|
||||
# The x^127 term was shifted up to x^128, so subtract a 1+x+x^2+x^7
|
||||
# term. This is 0b11100001 or 0xe1 when represented as an 8-bit
|
||||
# polynomial.
|
||||
x ^= 0xe1 << (128-8)
|
||||
return x
|
||||
|
||||
@staticmethod
|
||||
def _inc32(counter):
|
||||
for i in range(len(counter)-1, len(counter)-5, -1):
|
||||
counter[i] = (counter[i] + 1) % 256
|
||||
if counter[i] != 0:
|
||||
break
|
||||
return counter
|
||||
|
||||
# _gcmReductionTable[i] is i * (1+x+x^2+x^7) for all 4-bit polynomials i. The
|
||||
# result is stored as a 16-bit polynomial. This is used in the reduction step to
|
||||
# multiply elements of GF(2^128) by x^4.
|
||||
_gcmReductionTable = [
|
||||
0x0000, 0x1c20, 0x3840, 0x2460, 0x7080, 0x6ca0, 0x48c0, 0x54e0,
|
||||
0xe100, 0xfd20, 0xd940, 0xc560, 0x9180, 0x8da0, 0xa9c0, 0xb5e0,
|
||||
]
|
||||
@@ -175,12 +175,27 @@ class HybridCache:
|
||||
if not isinstance(data, (bytes, bytearray, memoryview)):
|
||||
raise ValueError("Data must be bytes, bytearray, or memoryview")
|
||||
|
||||
expires_at = time.time() + (ttl or self.ttl)
|
||||
ttl_seconds = self.ttl if ttl is None else ttl
|
||||
|
||||
key = self._get_md5_hash(key)
|
||||
|
||||
if ttl_seconds <= 0:
|
||||
# Explicit request to avoid caching - remove any previous entry and return success
|
||||
self.memory_cache.remove(key)
|
||||
try:
|
||||
file_path = self._get_file_path(key)
|
||||
await aiofiles.os.remove(file_path)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.error(f"Error removing cache file: {e}")
|
||||
return True
|
||||
|
||||
expires_at = time.time() + ttl_seconds
|
||||
|
||||
# Create cache entry
|
||||
entry = CacheEntry(data=data, expires_at=expires_at, access_count=0, last_access=time.time(), size=len(data))
|
||||
|
||||
key = self._get_md5_hash(key)
|
||||
# Update memory cache
|
||||
self.memory_cache.set(key, entry)
|
||||
file_path = self._get_file_path(key)
|
||||
@@ -210,10 +225,11 @@ class HybridCache:
|
||||
|
||||
async def delete(self, key: str) -> bool:
|
||||
"""Delete item from both caches."""
|
||||
self.memory_cache.remove(key)
|
||||
hashed_key = self._get_md5_hash(key)
|
||||
self.memory_cache.remove(hashed_key)
|
||||
|
||||
try:
|
||||
file_path = self._get_file_path(key)
|
||||
file_path = self._get_file_path(hashed_key)
|
||||
await aiofiles.os.remove(file_path)
|
||||
return True
|
||||
except FileNotFoundError:
|
||||
@@ -237,7 +253,13 @@ class AsyncMemoryCache:
|
||||
async def set(self, key: str, data: Union[bytes, bytearray, memoryview], ttl: Optional[int] = None) -> bool:
|
||||
"""Set value in cache."""
|
||||
try:
|
||||
expires_at = time.time() + (ttl or 3600) # Default 1 hour TTL if not specified
|
||||
ttl_seconds = 3600 if ttl is None else ttl
|
||||
|
||||
if ttl_seconds <= 0:
|
||||
self.memory_cache.remove(key)
|
||||
return True
|
||||
|
||||
expires_at = time.time() + ttl_seconds
|
||||
entry = CacheEntry(
|
||||
data=data, expires_at=expires_at, access_count=0, last_access=time.time(), size=len(data)
|
||||
)
|
||||
@@ -276,18 +298,35 @@ EXTRACTOR_CACHE = HybridCache(
|
||||
|
||||
|
||||
# Specific cache implementations
|
||||
async def get_cached_init_segment(init_url: str, headers: dict) -> Optional[bytes]:
|
||||
"""Get initialization segment from cache or download it."""
|
||||
# Try cache first
|
||||
cached_data = await INIT_SEGMENT_CACHE.get(init_url)
|
||||
if cached_data is not None:
|
||||
return cached_data
|
||||
async def get_cached_init_segment(
|
||||
init_url: str,
|
||||
headers: dict,
|
||||
cache_token: str | None = None,
|
||||
ttl: Optional[int] = None,
|
||||
) -> Optional[bytes]:
|
||||
"""Get initialization segment from cache or download it.
|
||||
|
||||
cache_token allows differentiating entries that share the same init_url but
|
||||
rely on different DRM keys or initialization payloads (e.g. key rotation).
|
||||
|
||||
ttl overrides the default cache TTL; pass a value <= 0 to skip caching entirely.
|
||||
"""
|
||||
|
||||
use_cache = ttl is None or ttl > 0
|
||||
cache_key = f"{init_url}|{cache_token}" if cache_token else init_url
|
||||
|
||||
if use_cache:
|
||||
cached_data = await INIT_SEGMENT_CACHE.get(cache_key)
|
||||
if cached_data is not None:
|
||||
return cached_data
|
||||
else:
|
||||
# Remove any previously cached entry when caching is disabled
|
||||
await INIT_SEGMENT_CACHE.delete(cache_key)
|
||||
|
||||
# Download if not cached
|
||||
try:
|
||||
init_content = await download_file_with_retry(init_url, headers)
|
||||
if init_content:
|
||||
await INIT_SEGMENT_CACHE.set(init_url, init_content)
|
||||
if init_content and use_cache:
|
||||
await INIT_SEGMENT_CACHE.set(cache_key, init_content, ttl=ttl)
|
||||
return init_content
|
||||
except Exception as e:
|
||||
logger.error(f"Error downloading init segment: {e}")
|
||||
|
||||
465
mediaflow_proxy/utils/codec.py
Normal file
465
mediaflow_proxy/utils/codec.py
Normal file
@@ -0,0 +1,465 @@
|
||||
# Author: Trevor Perrin
|
||||
# See the LICENSE file for legal information regarding use of this file.
|
||||
|
||||
"""Classes for reading/writing binary data (such as TLS records)."""
|
||||
|
||||
from __future__ import division
|
||||
|
||||
import sys
|
||||
import struct
|
||||
from struct import pack
|
||||
from .compat import bytes_to_int
|
||||
|
||||
|
||||
class DecodeError(SyntaxError):
|
||||
"""Exception raised in case of decoding errors."""
|
||||
pass
|
||||
|
||||
|
||||
class BadCertificateError(SyntaxError):
|
||||
"""Exception raised in case of bad certificate."""
|
||||
pass
|
||||
|
||||
|
||||
class Writer(object):
|
||||
"""Serialisation helper for complex byte-based structures."""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialise the serializer with no data."""
|
||||
self.bytes = bytearray(0)
|
||||
|
||||
def addOne(self, val):
|
||||
"""Add a single-byte wide element to buffer, see add()."""
|
||||
self.bytes.append(val)
|
||||
|
||||
if sys.version_info < (2, 7):
|
||||
# struct.pack on Python2.6 does not raise exception if the value
|
||||
# is larger than can fit inside the specified size
|
||||
def addTwo(self, val):
|
||||
"""Add a double-byte wide element to buffer, see add()."""
|
||||
if not 0 <= val <= 0xffff:
|
||||
raise ValueError("Can't represent value in specified length")
|
||||
self.bytes += pack('>H', val)
|
||||
|
||||
def addThree(self, val):
|
||||
"""Add a three-byte wide element to buffer, see add()."""
|
||||
if not 0 <= val <= 0xffffff:
|
||||
raise ValueError("Can't represent value in specified length")
|
||||
self.bytes += pack('>BH', val >> 16, val & 0xffff)
|
||||
|
||||
def addFour(self, val):
|
||||
"""Add a four-byte wide element to buffer, see add()."""
|
||||
if not 0 <= val <= 0xffffffff:
|
||||
raise ValueError("Can't represent value in specified length")
|
||||
self.bytes += pack('>I', val)
|
||||
else:
|
||||
def addTwo(self, val):
|
||||
"""Add a double-byte wide element to buffer, see add()."""
|
||||
try:
|
||||
self.bytes += pack('>H', val)
|
||||
except struct.error:
|
||||
raise ValueError("Can't represent value in specified length")
|
||||
|
||||
def addThree(self, val):
|
||||
"""Add a three-byte wide element to buffer, see add()."""
|
||||
try:
|
||||
self.bytes += pack('>BH', val >> 16, val & 0xffff)
|
||||
except struct.error:
|
||||
raise ValueError("Can't represent value in specified length")
|
||||
|
||||
def addFour(self, val):
|
||||
"""Add a four-byte wide element to buffer, see add()."""
|
||||
try:
|
||||
self.bytes += pack('>I', val)
|
||||
except struct.error:
|
||||
raise ValueError("Can't represent value in specified length")
|
||||
|
||||
if sys.version_info >= (3, 0):
|
||||
# the method is called thousands of times, so it's better to extern
|
||||
# the version info check
|
||||
def add(self, x, length):
|
||||
"""
|
||||
Add a single positive integer value x, encode it in length bytes
|
||||
|
||||
Encode positive integer x in big-endian format using length bytes,
|
||||
add to the internal buffer.
|
||||
|
||||
:type x: int
|
||||
:param x: value to encode
|
||||
|
||||
:type length: int
|
||||
:param length: number of bytes to use for encoding the value
|
||||
"""
|
||||
try:
|
||||
self.bytes += x.to_bytes(length, 'big')
|
||||
except OverflowError:
|
||||
raise ValueError("Can't represent value in specified length")
|
||||
else:
|
||||
_addMethods = {1: addOne, 2: addTwo, 3: addThree, 4: addFour}
|
||||
|
||||
def add(self, x, length):
|
||||
"""
|
||||
Add a single positive integer value x, encode it in length bytes
|
||||
|
||||
Encode positive iteger x in big-endian format using length bytes,
|
||||
add to the internal buffer.
|
||||
|
||||
:type x: int
|
||||
:param x: value to encode
|
||||
|
||||
:type length: int
|
||||
:param length: number of bytes to use for encoding the value
|
||||
"""
|
||||
try:
|
||||
self._addMethods[length](self, x)
|
||||
except KeyError:
|
||||
self.bytes += bytearray(length)
|
||||
newIndex = len(self.bytes) - 1
|
||||
for i in range(newIndex, newIndex - length, -1):
|
||||
self.bytes[i] = x & 0xFF
|
||||
x >>= 8
|
||||
if x != 0:
|
||||
raise ValueError("Can't represent value in specified "
|
||||
"length")
|
||||
|
||||
def addFixSeq(self, seq, length):
|
||||
"""
|
||||
Add a list of items, encode every item in length bytes
|
||||
|
||||
Uses the unbounded iterable seq to produce items, each of
|
||||
which is then encoded to length bytes
|
||||
|
||||
:type seq: iterable of int
|
||||
:param seq: list of positive integers to encode
|
||||
|
||||
:type length: int
|
||||
:param length: number of bytes to which encode every element
|
||||
"""
|
||||
for e in seq:
|
||||
self.add(e, length)
|
||||
|
||||
if sys.version_info < (2, 7):
|
||||
# struct.pack on Python2.6 does not raise exception if the value
|
||||
# is larger than can fit inside the specified size
|
||||
def _addVarSeqTwo(self, seq):
|
||||
"""Helper method for addVarSeq"""
|
||||
if not all(0 <= i <= 0xffff for i in seq):
|
||||
raise ValueError("Can't represent value in specified "
|
||||
"length")
|
||||
self.bytes += pack('>' + 'H' * len(seq), *seq)
|
||||
|
||||
def addVarSeq(self, seq, length, lengthLength):
|
||||
"""
|
||||
Add a bounded list of same-sized values
|
||||
|
||||
Create a list of specific length with all items being of the same
|
||||
size
|
||||
|
||||
:type seq: list of int
|
||||
:param seq: list of positive integers to encode
|
||||
|
||||
:type length: int
|
||||
:param length: amount of bytes in which to encode every item
|
||||
|
||||
:type lengthLength: int
|
||||
:param lengthLength: amount of bytes in which to encode the overall
|
||||
length of the array
|
||||
"""
|
||||
self.add(len(seq)*length, lengthLength)
|
||||
if length == 1:
|
||||
self.bytes.extend(seq)
|
||||
elif length == 2:
|
||||
self._addVarSeqTwo(seq)
|
||||
else:
|
||||
for i in seq:
|
||||
self.add(i, length)
|
||||
else:
|
||||
def addVarSeq(self, seq, length, lengthLength):
|
||||
"""
|
||||
Add a bounded list of same-sized values
|
||||
|
||||
Create a list of specific length with all items being of the same
|
||||
size
|
||||
|
||||
:type seq: list of int
|
||||
:param seq: list of positive integers to encode
|
||||
|
||||
:type length: int
|
||||
:param length: amount of bytes in which to encode every item
|
||||
|
||||
:type lengthLength: int
|
||||
:param lengthLength: amount of bytes in which to encode the overall
|
||||
length of the array
|
||||
"""
|
||||
seqLen = len(seq)
|
||||
self.add(seqLen*length, lengthLength)
|
||||
if length == 1:
|
||||
self.bytes.extend(seq)
|
||||
elif length == 2:
|
||||
try:
|
||||
self.bytes += pack('>' + 'H' * seqLen, *seq)
|
||||
except struct.error:
|
||||
raise ValueError("Can't represent value in specified "
|
||||
"length")
|
||||
else:
|
||||
for i in seq:
|
||||
self.add(i, length)
|
||||
|
||||
def addVarTupleSeq(self, seq, length, lengthLength):
|
||||
"""
|
||||
Add a variable length list of same-sized element tuples.
|
||||
|
||||
Note that all tuples must have the same size.
|
||||
|
||||
Inverse of Parser.getVarTupleList()
|
||||
|
||||
:type seq: enumerable
|
||||
:param seq: list of tuples
|
||||
|
||||
:type length: int
|
||||
:param length: length of single element in tuple
|
||||
|
||||
:type lengthLength: int
|
||||
:param lengthLength: length in bytes of overall length field
|
||||
"""
|
||||
if not seq:
|
||||
self.add(0, lengthLength)
|
||||
else:
|
||||
startPos = len(self.bytes)
|
||||
dataLength = len(seq) * len(seq[0]) * length
|
||||
self.add(dataLength, lengthLength)
|
||||
# since at the time of writing, all the calls encode single byte
|
||||
# elements, and it's very easy to speed up that case, give it
|
||||
# special case
|
||||
if length == 1:
|
||||
for elemTuple in seq:
|
||||
self.bytes.extend(elemTuple)
|
||||
else:
|
||||
for elemTuple in seq:
|
||||
self.addFixSeq(elemTuple, length)
|
||||
if startPos + dataLength + lengthLength != len(self.bytes):
|
||||
raise ValueError("Tuples of different lengths")
|
||||
|
||||
def add_var_bytes(self, data, length_length):
|
||||
"""
|
||||
Add a variable length array of bytes.
|
||||
|
||||
Inverse of Parser.getVarBytes()
|
||||
|
||||
:type data: bytes
|
||||
:param data: bytes to add to the buffer
|
||||
|
||||
:param int length_length: size of the field to represent the length
|
||||
of the data string
|
||||
"""
|
||||
length = len(data)
|
||||
self.add(length, length_length)
|
||||
self.bytes += data
|
||||
|
||||
|
||||
class Parser(object):
|
||||
"""
|
||||
Parser for TLV and LV byte-based encodings.
|
||||
|
||||
Parser that can handle arbitrary byte-based encodings usually employed in
|
||||
Type-Length-Value or Length-Value binary encoding protocols like ASN.1
|
||||
or TLS
|
||||
|
||||
Note: if the raw bytes don't match expected values (like trying to
|
||||
read a 4-byte integer from a 2-byte buffer), most methods will raise a
|
||||
DecodeError exception.
|
||||
|
||||
TODO: don't use an exception used by language parser to indicate errors
|
||||
in application code.
|
||||
|
||||
:vartype bytes: bytearray
|
||||
:ivar bytes: data to be interpreted (buffer)
|
||||
|
||||
:vartype index: int
|
||||
:ivar index: current position in the buffer
|
||||
|
||||
:vartype lengthCheck: int
|
||||
:ivar lengthCheck: size of struct being parsed
|
||||
|
||||
:vartype indexCheck: int
|
||||
:ivar indexCheck: position at which the structure begins in buffer
|
||||
"""
|
||||
|
||||
def __init__(self, bytes):
|
||||
"""
|
||||
Bind raw bytes with parser.
|
||||
|
||||
:type bytes: bytearray
|
||||
:param bytes: bytes to be parsed/interpreted
|
||||
"""
|
||||
self.bytes = bytes
|
||||
self.index = 0
|
||||
self.indexCheck = 0
|
||||
self.lengthCheck = 0
|
||||
|
||||
def get(self, length):
|
||||
"""
|
||||
Read a single big-endian integer value encoded in 'length' bytes.
|
||||
|
||||
:type length: int
|
||||
:param length: number of bytes in which the value is encoded in
|
||||
|
||||
:rtype: int
|
||||
"""
|
||||
ret = self.getFixBytes(length)
|
||||
return bytes_to_int(ret, 'big')
|
||||
|
||||
def getFixBytes(self, lengthBytes):
|
||||
"""
|
||||
Read a string of bytes encoded in 'lengthBytes' bytes.
|
||||
|
||||
:type lengthBytes: int
|
||||
:param lengthBytes: number of bytes to return
|
||||
|
||||
:rtype: bytearray
|
||||
"""
|
||||
end = self.index + lengthBytes
|
||||
if end > len(self.bytes):
|
||||
raise DecodeError("Read past end of buffer")
|
||||
ret = self.bytes[self.index : end]
|
||||
self.index += lengthBytes
|
||||
return ret
|
||||
|
||||
def skip_bytes(self, length):
|
||||
"""Move the internal pointer ahead length bytes."""
|
||||
if self.index + length > len(self.bytes):
|
||||
raise DecodeError("Read past end of buffer")
|
||||
self.index += length
|
||||
|
||||
def getVarBytes(self, lengthLength):
|
||||
"""
|
||||
Read a variable length string with a fixed length.
|
||||
|
||||
see Writer.add_var_bytes() for an inverse of this method
|
||||
|
||||
:type lengthLength: int
|
||||
:param lengthLength: number of bytes in which the length of the string
|
||||
is encoded in
|
||||
|
||||
:rtype: bytearray
|
||||
"""
|
||||
lengthBytes = self.get(lengthLength)
|
||||
return self.getFixBytes(lengthBytes)
|
||||
|
||||
def getFixList(self, length, lengthList):
|
||||
"""
|
||||
Read a list of static length with same-sized ints.
|
||||
|
||||
:type length: int
|
||||
:param length: size in bytes of a single element in list
|
||||
|
||||
:type lengthList: int
|
||||
:param lengthList: number of elements in list
|
||||
|
||||
:rtype: list of int
|
||||
"""
|
||||
l = [0] * lengthList
|
||||
for x in range(lengthList):
|
||||
l[x] = self.get(length)
|
||||
return l
|
||||
|
||||
def getVarList(self, length, lengthLength):
|
||||
"""
|
||||
Read a variable length list of same-sized integers.
|
||||
|
||||
:type length: int
|
||||
:param length: size in bytes of a single element
|
||||
|
||||
:type lengthLength: int
|
||||
:param lengthLength: size of the encoded length of the list
|
||||
|
||||
:rtype: list of int
|
||||
"""
|
||||
lengthList = self.get(lengthLength)
|
||||
if lengthList % length != 0:
|
||||
raise DecodeError("Encoded length not a multiple of element "
|
||||
"length")
|
||||
lengthList = lengthList // length
|
||||
l = [0] * lengthList
|
||||
for x in range(lengthList):
|
||||
l[x] = self.get(length)
|
||||
return l
|
||||
|
||||
def getVarTupleList(self, elemLength, elemNum, lengthLength):
|
||||
"""
|
||||
Read a variable length list of same sized tuples.
|
||||
|
||||
:type elemLength: int
|
||||
:param elemLength: length in bytes of single tuple element
|
||||
|
||||
:type elemNum: int
|
||||
:param elemNum: number of elements in tuple
|
||||
|
||||
:type lengthLength: int
|
||||
:param lengthLength: length in bytes of the list length variable
|
||||
|
||||
:rtype: list of tuple of int
|
||||
"""
|
||||
lengthList = self.get(lengthLength)
|
||||
if lengthList % (elemLength * elemNum) != 0:
|
||||
raise DecodeError("Encoded length not a multiple of element "
|
||||
"length")
|
||||
tupleCount = lengthList // (elemLength * elemNum)
|
||||
tupleList = []
|
||||
for _ in range(tupleCount):
|
||||
currentTuple = []
|
||||
for _ in range(elemNum):
|
||||
currentTuple.append(self.get(elemLength))
|
||||
tupleList.append(tuple(currentTuple))
|
||||
return tupleList
|
||||
|
||||
def startLengthCheck(self, lengthLength):
|
||||
"""
|
||||
Read length of struct and start a length check for parsing.
|
||||
|
||||
:type lengthLength: int
|
||||
:param lengthLength: number of bytes in which the length is encoded
|
||||
"""
|
||||
self.lengthCheck = self.get(lengthLength)
|
||||
self.indexCheck = self.index
|
||||
|
||||
def setLengthCheck(self, length):
|
||||
"""
|
||||
Set length of struct and start a length check for parsing.
|
||||
|
||||
:type length: int
|
||||
:param length: expected size of parsed struct in bytes
|
||||
"""
|
||||
self.lengthCheck = length
|
||||
self.indexCheck = self.index
|
||||
|
||||
def stopLengthCheck(self):
|
||||
"""
|
||||
Stop struct parsing, verify that no under- or overflow occurred.
|
||||
|
||||
In case the expected length was mismatched with actual length of
|
||||
processed data, raises an exception.
|
||||
"""
|
||||
if (self.index - self.indexCheck) != self.lengthCheck:
|
||||
raise DecodeError("Under- or over-flow while reading buffer")
|
||||
|
||||
def atLengthCheck(self):
|
||||
"""
|
||||
Check if there is data in structure left for parsing.
|
||||
|
||||
Returns True if the whole structure was parsed, False if there is
|
||||
some data left.
|
||||
|
||||
Will raise an exception if overflow occured (amount of data read was
|
||||
greater than expected size)
|
||||
"""
|
||||
if (self.index - self.indexCheck) < self.lengthCheck:
|
||||
return False
|
||||
elif (self.index - self.indexCheck) == self.lengthCheck:
|
||||
return True
|
||||
else:
|
||||
raise DecodeError("Read past end of buffer")
|
||||
|
||||
def getRemainingLength(self):
|
||||
"""Return amount of data remaining in struct being parsed."""
|
||||
return len(self.bytes) - self.index
|
||||
231
mediaflow_proxy/utils/compat.py
Normal file
231
mediaflow_proxy/utils/compat.py
Normal file
@@ -0,0 +1,231 @@
|
||||
# Author: Trevor Perrin
|
||||
# See the LICENSE file for legal information regarding use of this file.
|
||||
|
||||
"""Miscellaneous functions to mask Python version differences."""
|
||||
|
||||
import sys
|
||||
import re
|
||||
import platform
|
||||
import binascii
|
||||
import traceback
|
||||
import time
|
||||
|
||||
|
||||
if sys.version_info >= (3,0):
|
||||
|
||||
def compat26Str(x): return x
|
||||
|
||||
# Python 3.3 requires bytes instead of bytearrays for HMAC
|
||||
# So, python 2.6 requires strings, python 3 requires 'bytes',
|
||||
# and python 2.7 and 3.5 can handle bytearrays...
|
||||
# pylint: disable=invalid-name
|
||||
# we need to keep compatHMAC and `x` for API compatibility
|
||||
if sys.version_info < (3, 4):
|
||||
def compatHMAC(x):
|
||||
"""Convert bytes-like input to format acceptable for HMAC."""
|
||||
return bytes(x)
|
||||
else:
|
||||
def compatHMAC(x):
|
||||
"""Convert bytes-like input to format acceptable for HMAC."""
|
||||
return x
|
||||
# pylint: enable=invalid-name
|
||||
|
||||
def compatAscii2Bytes(val):
|
||||
"""Convert ASCII string to bytes."""
|
||||
if isinstance(val, str):
|
||||
return bytes(val, 'ascii')
|
||||
return val
|
||||
|
||||
def compat_b2a(val):
|
||||
"""Convert an ASCII bytes string to string."""
|
||||
return str(val, 'ascii')
|
||||
|
||||
def raw_input(s):
|
||||
return input(s)
|
||||
|
||||
# So, the python3 binascii module deals with bytearrays, and python2
|
||||
# deals with strings... I would rather deal with the "a" part as
|
||||
# strings, and the "b" part as bytearrays, regardless of python version,
|
||||
# so...
|
||||
def a2b_hex(s):
|
||||
try:
|
||||
b = bytearray(binascii.a2b_hex(bytearray(s, "ascii")))
|
||||
except Exception as e:
|
||||
raise SyntaxError("base16 error: %s" % e)
|
||||
return b
|
||||
|
||||
def a2b_base64(s):
|
||||
try:
|
||||
if isinstance(s, str):
|
||||
s = bytearray(s, "ascii")
|
||||
b = bytearray(binascii.a2b_base64(s))
|
||||
except Exception as e:
|
||||
raise SyntaxError("base64 error: %s" % e)
|
||||
return b
|
||||
|
||||
def b2a_hex(b):
|
||||
return binascii.b2a_hex(b).decode("ascii")
|
||||
|
||||
def b2a_base64(b):
|
||||
return binascii.b2a_base64(b).decode("ascii")
|
||||
|
||||
def readStdinBinary():
|
||||
return sys.stdin.buffer.read()
|
||||
|
||||
def compatLong(num):
|
||||
return int(num)
|
||||
|
||||
int_types = tuple([int])
|
||||
|
||||
def formatExceptionTrace(e):
|
||||
"""Return exception information formatted as string"""
|
||||
return str(e)
|
||||
|
||||
def time_stamp():
|
||||
"""Returns system time as a float"""
|
||||
if sys.version_info >= (3, 3):
|
||||
return time.perf_counter()
|
||||
return time.clock()
|
||||
|
||||
def remove_whitespace(text):
|
||||
"""Removes all whitespace from passed in string"""
|
||||
return re.sub(r"\s+", "", text, flags=re.UNICODE)
|
||||
|
||||
# pylint: disable=invalid-name
|
||||
# pylint is stupid here and deson't notice it's a function, not
|
||||
# constant
|
||||
bytes_to_int = int.from_bytes
|
||||
# pylint: enable=invalid-name
|
||||
|
||||
def bit_length(val):
|
||||
"""Return number of bits necessary to represent an integer."""
|
||||
return val.bit_length()
|
||||
|
||||
def int_to_bytes(val, length=None, byteorder="big"):
|
||||
"""Return number converted to bytes"""
|
||||
if length is None:
|
||||
if val:
|
||||
length = byte_length(val)
|
||||
else:
|
||||
length = 1
|
||||
# for gmpy we need to convert back to native int
|
||||
if type(val) != int:
|
||||
val = int(val)
|
||||
return bytearray(val.to_bytes(length=length, byteorder=byteorder))
|
||||
|
||||
else:
|
||||
# Python 2.6 requires strings instead of bytearrays in a couple places,
|
||||
# so we define this function so it does the conversion if needed.
|
||||
# same thing with very old 2.7 versions
|
||||
# or on Jython
|
||||
if sys.version_info < (2, 7) or sys.version_info < (2, 7, 4) \
|
||||
or platform.system() == 'Java':
|
||||
def compat26Str(x): return str(x)
|
||||
|
||||
def remove_whitespace(text):
|
||||
"""Removes all whitespace from passed in string"""
|
||||
return re.sub(r"\s+", "", text)
|
||||
|
||||
def bit_length(val):
|
||||
"""Return number of bits necessary to represent an integer."""
|
||||
if val == 0:
|
||||
return 0
|
||||
return len(bin(val))-2
|
||||
else:
|
||||
def compat26Str(x): return x
|
||||
|
||||
def remove_whitespace(text):
|
||||
"""Removes all whitespace from passed in string"""
|
||||
return re.sub(r"\s+", "", text, flags=re.UNICODE)
|
||||
|
||||
def bit_length(val):
|
||||
"""Return number of bits necessary to represent an integer."""
|
||||
return val.bit_length()
|
||||
|
||||
def compatAscii2Bytes(val):
|
||||
"""Convert ASCII string to bytes."""
|
||||
return val
|
||||
|
||||
def compat_b2a(val):
|
||||
"""Convert an ASCII bytes string to string."""
|
||||
return str(val)
|
||||
|
||||
# So, python 2.6 requires strings, python 3 requires 'bytes',
|
||||
# and python 2.7 can handle bytearrays...
|
||||
def compatHMAC(x): return compat26Str(x)
|
||||
|
||||
def a2b_hex(s):
|
||||
try:
|
||||
b = bytearray(binascii.a2b_hex(s))
|
||||
except Exception as e:
|
||||
raise SyntaxError("base16 error: %s" % e)
|
||||
return b
|
||||
|
||||
def a2b_base64(s):
|
||||
try:
|
||||
b = bytearray(binascii.a2b_base64(s))
|
||||
except Exception as e:
|
||||
raise SyntaxError("base64 error: %s" % e)
|
||||
return b
|
||||
|
||||
def b2a_hex(b):
|
||||
return binascii.b2a_hex(compat26Str(b))
|
||||
|
||||
def b2a_base64(b):
|
||||
return binascii.b2a_base64(compat26Str(b))
|
||||
|
||||
def compatLong(num):
|
||||
return long(num)
|
||||
|
||||
int_types = (int, long)
|
||||
|
||||
# pylint on Python3 goes nuts for the sys dereferences...
|
||||
|
||||
#pylint: disable=no-member
|
||||
def formatExceptionTrace(e):
|
||||
"""Return exception information formatted as string"""
|
||||
newStr = "".join(traceback.format_exception(sys.exc_type,
|
||||
sys.exc_value,
|
||||
sys.exc_traceback))
|
||||
return newStr
|
||||
#pylint: enable=no-member
|
||||
|
||||
def time_stamp():
|
||||
"""Returns system time as a float"""
|
||||
return time.clock()
|
||||
|
||||
def bytes_to_int(val, byteorder):
|
||||
"""Convert bytes to an int."""
|
||||
if not val:
|
||||
return 0
|
||||
if byteorder == "big":
|
||||
return int(b2a_hex(val), 16)
|
||||
if byteorder == "little":
|
||||
return int(b2a_hex(val[::-1]), 16)
|
||||
raise ValueError("Only 'big' and 'little' endian supported")
|
||||
|
||||
def int_to_bytes(val, length=None, byteorder="big"):
|
||||
"""Return number converted to bytes"""
|
||||
if length is None:
|
||||
if val:
|
||||
length = byte_length(val)
|
||||
else:
|
||||
length = 1
|
||||
if byteorder == "big":
|
||||
return bytearray((val >> i) & 0xff
|
||||
for i in reversed(range(0, length*8, 8)))
|
||||
if byteorder == "little":
|
||||
return bytearray((val >> i) & 0xff
|
||||
for i in range(0, length*8, 8))
|
||||
raise ValueError("Only 'big' or 'little' endian supported")
|
||||
|
||||
|
||||
def byte_length(val):
|
||||
"""Return number of bytes necessary to represent an integer."""
|
||||
length = bit_length(val)
|
||||
return (length + 7) // 8
|
||||
|
||||
|
||||
ecdsaAllCurves = False
|
||||
ML_KEM_AVAILABLE = False
|
||||
ML_DSA_AVAILABLE = False
|
||||
218
mediaflow_proxy/utils/constanttime.py
Normal file
218
mediaflow_proxy/utils/constanttime.py
Normal file
@@ -0,0 +1,218 @@
|
||||
# Copyright (c) 2015, Hubert Kario
|
||||
#
|
||||
# See the LICENSE file for legal information regarding use of this file.
|
||||
"""Various constant time functions for processing sensitive data"""
|
||||
|
||||
from __future__ import division
|
||||
|
||||
from .compat import compatHMAC
|
||||
import hmac
|
||||
|
||||
def ct_lt_u32(val_a, val_b):
|
||||
"""
|
||||
Returns 1 if val_a < val_b, 0 otherwise. Constant time.
|
||||
|
||||
:type val_a: int
|
||||
:type val_b: int
|
||||
:param val_a: an unsigned integer representable as a 32 bit value
|
||||
:param val_b: an unsigned integer representable as a 32 bit value
|
||||
:rtype: int
|
||||
"""
|
||||
val_a &= 0xffffffff
|
||||
val_b &= 0xffffffff
|
||||
|
||||
return (val_a^((val_a^val_b)|(((val_a-val_b)&0xffffffff)^val_b)))>>31
|
||||
|
||||
|
||||
def ct_gt_u32(val_a, val_b):
|
||||
"""
|
||||
Return 1 if val_a > val_b, 0 otherwise. Constant time.
|
||||
|
||||
:type val_a: int
|
||||
:type val_b: int
|
||||
:param val_a: an unsigned integer representable as a 32 bit value
|
||||
:param val_b: an unsigned integer representable as a 32 bit value
|
||||
:rtype: int
|
||||
"""
|
||||
return ct_lt_u32(val_b, val_a)
|
||||
|
||||
|
||||
def ct_le_u32(val_a, val_b):
|
||||
"""
|
||||
Return 1 if val_a <= val_b, 0 otherwise. Constant time.
|
||||
|
||||
:type val_a: int
|
||||
:type val_b: int
|
||||
:param val_a: an unsigned integer representable as a 32 bit value
|
||||
:param val_b: an unsigned integer representable as a 32 bit value
|
||||
:rtype: int
|
||||
"""
|
||||
return 1 ^ ct_gt_u32(val_a, val_b)
|
||||
|
||||
|
||||
def ct_lsb_prop_u8(val):
|
||||
"""Propagate LSB to all 8 bits of the returned int. Constant time."""
|
||||
val &= 0x01
|
||||
val |= val << 1
|
||||
val |= val << 2
|
||||
val |= val << 4
|
||||
return val
|
||||
|
||||
|
||||
def ct_lsb_prop_u16(val):
|
||||
"""Propagate LSB to all 16 bits of the returned int. Constant time."""
|
||||
val &= 0x01
|
||||
val |= val << 1
|
||||
val |= val << 2
|
||||
val |= val << 4
|
||||
val |= val << 8
|
||||
return val
|
||||
|
||||
|
||||
def ct_isnonzero_u32(val):
|
||||
"""
|
||||
Returns 1 if val is != 0, 0 otherwise. Constant time.
|
||||
|
||||
:type val: int
|
||||
:param val: an unsigned integer representable as a 32 bit value
|
||||
:rtype: int
|
||||
"""
|
||||
val &= 0xffffffff
|
||||
return (val|(-val&0xffffffff)) >> 31
|
||||
|
||||
|
||||
def ct_neq_u32(val_a, val_b):
|
||||
"""
|
||||
Return 1 if val_a != val_b, 0 otherwise. Constant time.
|
||||
|
||||
:type val_a: int
|
||||
:type val_b: int
|
||||
:param val_a: an unsigned integer representable as a 32 bit value
|
||||
:param val_b: an unsigned integer representable as a 32 bit value
|
||||
:rtype: int
|
||||
"""
|
||||
val_a &= 0xffffffff
|
||||
val_b &= 0xffffffff
|
||||
|
||||
return (((val_a-val_b)&0xffffffff) | ((val_b-val_a)&0xffffffff)) >> 31
|
||||
|
||||
def ct_eq_u32(val_a, val_b):
|
||||
"""
|
||||
Return 1 if val_a == val_b, 0 otherwise. Constant time.
|
||||
|
||||
:type val_a: int
|
||||
:type val_b: int
|
||||
:param val_a: an unsigned integer representable as a 32 bit value
|
||||
:param val_b: an unsigned integer representable as a 32 bit value
|
||||
:rtype: int
|
||||
"""
|
||||
return 1 ^ ct_neq_u32(val_a, val_b)
|
||||
|
||||
def ct_check_cbc_mac_and_pad(data, mac, seqnumBytes, contentType, version,
|
||||
block_size=16):
|
||||
"""
|
||||
Check CBC cipher HMAC and padding. Close to constant time.
|
||||
|
||||
:type data: bytearray
|
||||
:param data: data with HMAC value to test and padding
|
||||
|
||||
:type mac: hashlib mac
|
||||
:param mac: empty HMAC, initialised with a key
|
||||
|
||||
:type seqnumBytes: bytearray
|
||||
:param seqnumBytes: TLS sequence number, used as input to HMAC
|
||||
|
||||
:type contentType: int
|
||||
:param contentType: a single byte, used as input to HMAC
|
||||
|
||||
:type version: tuple of int
|
||||
:param version: a tuple of two ints, used as input to HMAC and to guide
|
||||
checking of padding
|
||||
|
||||
:rtype: boolean
|
||||
:returns: True if MAC and pad is ok, False otherwise
|
||||
"""
|
||||
assert version in ((3, 0), (3, 1), (3, 2), (3, 3))
|
||||
|
||||
data_len = len(data)
|
||||
if mac.digest_size + 1 > data_len: # data_len is public
|
||||
return False
|
||||
|
||||
# 0 - OK
|
||||
result = 0x00
|
||||
|
||||
#
|
||||
# check padding
|
||||
#
|
||||
pad_length = data[data_len-1]
|
||||
pad_start = data_len - pad_length - 1
|
||||
pad_start = max(0, pad_start)
|
||||
|
||||
if version == (3, 0): # version is public
|
||||
# in SSLv3 we can only check if pad is not longer than the cipher
|
||||
# block size
|
||||
|
||||
# subtract 1 for the pad length byte
|
||||
mask = ct_lsb_prop_u8(ct_lt_u32(block_size, pad_length))
|
||||
result |= mask
|
||||
else:
|
||||
start_pos = max(0, data_len - 256)
|
||||
for i in range(start_pos, data_len):
|
||||
# if pad_start < i: mask = 0xff; else: mask = 0x00
|
||||
mask = ct_lsb_prop_u8(ct_le_u32(pad_start, i))
|
||||
# if data[i] != pad_length and "inside_pad": result = False
|
||||
result |= (data[i] ^ pad_length) & mask
|
||||
|
||||
#
|
||||
# check MAC
|
||||
#
|
||||
|
||||
# real place where mac starts and data ends
|
||||
mac_start = pad_start - mac.digest_size
|
||||
mac_start = max(0, mac_start)
|
||||
|
||||
# place to start processing
|
||||
start_pos = max(0, data_len - (256 + mac.digest_size)) // mac.block_size
|
||||
start_pos *= mac.block_size
|
||||
|
||||
# add start data
|
||||
data_mac = mac.copy()
|
||||
data_mac.update(compatHMAC(seqnumBytes))
|
||||
data_mac.update(compatHMAC(bytearray([contentType])))
|
||||
if version != (3, 0): # version is public
|
||||
data_mac.update(compatHMAC(bytearray([version[0]])))
|
||||
data_mac.update(compatHMAC(bytearray([version[1]])))
|
||||
data_mac.update(compatHMAC(bytearray([mac_start >> 8])))
|
||||
data_mac.update(compatHMAC(bytearray([mac_start & 0xff])))
|
||||
data_mac.update(compatHMAC(data[:start_pos]))
|
||||
|
||||
# don't check past the array end (already checked to be >= zero)
|
||||
end_pos = data_len - mac.digest_size
|
||||
|
||||
# calculate all possible
|
||||
for i in range(start_pos, end_pos): # constant for given overall length
|
||||
cur_mac = data_mac.copy()
|
||||
cur_mac.update(compatHMAC(data[start_pos:i]))
|
||||
mac_compare = bytearray(cur_mac.digest())
|
||||
# compare the hash for real only if it's the place where mac is
|
||||
# supposed to be
|
||||
mask = ct_lsb_prop_u8(ct_eq_u32(i, mac_start))
|
||||
for j in range(0, mac.digest_size): # digest_size is public
|
||||
result |= (data[i+j] ^ mac_compare[j]) & mask
|
||||
|
||||
# return python boolean
|
||||
return result == 0
|
||||
|
||||
if hasattr(hmac, 'compare_digest'):
|
||||
ct_compare_digest = hmac.compare_digest
|
||||
else:
|
||||
def ct_compare_digest(val_a, val_b):
|
||||
"""Compares if string like objects are equal. Constant time."""
|
||||
if len(val_a) != len(val_b):
|
||||
return False
|
||||
|
||||
result = 0
|
||||
for x, y in zip(val_a, val_b):
|
||||
result |= x ^ y
|
||||
|
||||
return result == 0
|
||||
366
mediaflow_proxy/utils/cryptomath.py
Normal file
366
mediaflow_proxy/utils/cryptomath.py
Normal file
@@ -0,0 +1,366 @@
|
||||
# Authors:
|
||||
# Trevor Perrin
|
||||
# Martin von Loewis - python 3 port
|
||||
# Yngve Pettersen (ported by Paul Sokolovsky) - TLS 1.2
|
||||
#
|
||||
# See the LICENSE file for legal information regarding use of this file.
|
||||
|
||||
"""cryptomath module
|
||||
|
||||
This module has basic math/crypto code."""
|
||||
from __future__ import print_function
|
||||
import os
|
||||
import math
|
||||
import base64
|
||||
import binascii
|
||||
|
||||
from .compat import compat26Str, compatHMAC, compatLong, \
|
||||
bytes_to_int, int_to_bytes, bit_length, byte_length
|
||||
from .codec import Writer
|
||||
|
||||
from . import tlshashlib as hashlib
|
||||
from . import tlshmac as hmac
|
||||
|
||||
|
||||
m2cryptoLoaded = False
|
||||
gmpyLoaded = False
|
||||
GMPY2_LOADED = False
|
||||
pycryptoLoaded = False
|
||||
|
||||
|
||||
# **************************************************************************
|
||||
# PRNG Functions
|
||||
# **************************************************************************
|
||||
|
||||
# Check that os.urandom works
|
||||
import zlib
|
||||
assert len(zlib.compress(os.urandom(1000))) > 900
|
||||
|
||||
def getRandomBytes(howMany):
|
||||
b = bytearray(os.urandom(howMany))
|
||||
assert(len(b) == howMany)
|
||||
return b
|
||||
|
||||
prngName = "os.urandom"
|
||||
|
||||
# **************************************************************************
|
||||
# Simple hash functions
|
||||
# **************************************************************************
|
||||
|
||||
def MD5(b):
|
||||
"""Return a MD5 digest of data"""
|
||||
return secureHash(b, 'md5')
|
||||
|
||||
def SHA1(b):
|
||||
"""Return a SHA1 digest of data"""
|
||||
return secureHash(b, 'sha1')
|
||||
|
||||
def secureHash(data, algorithm):
|
||||
"""Return a digest of `data` using `algorithm`"""
|
||||
hashInstance = hashlib.new(algorithm)
|
||||
hashInstance.update(compat26Str(data))
|
||||
return bytearray(hashInstance.digest())
|
||||
|
||||
def secureHMAC(k, b, algorithm):
|
||||
"""Return a HMAC using `b` and `k` using `algorithm`"""
|
||||
k = compatHMAC(k)
|
||||
b = compatHMAC(b)
|
||||
return bytearray(hmac.new(k, b, getattr(hashlib, algorithm)).digest())
|
||||
|
||||
def HMAC_MD5(k, b):
|
||||
return secureHMAC(k, b, 'md5')
|
||||
|
||||
def HMAC_SHA1(k, b):
|
||||
return secureHMAC(k, b, 'sha1')
|
||||
|
||||
def HMAC_SHA256(k, b):
|
||||
return secureHMAC(k, b, 'sha256')
|
||||
|
||||
def HMAC_SHA384(k, b):
|
||||
return secureHMAC(k, b, 'sha384')
|
||||
|
||||
def HKDF_expand(PRK, info, L, algorithm):
|
||||
N = divceil(L, getattr(hashlib, algorithm)().digest_size)
|
||||
T = bytearray()
|
||||
Titer = bytearray()
|
||||
for x in range(1, N+2):
|
||||
T += Titer
|
||||
Titer = secureHMAC(PRK, Titer + info + bytearray([x]), algorithm)
|
||||
return T[:L]
|
||||
|
||||
def HKDF_expand_label(secret, label, hashValue, length, algorithm):
|
||||
"""
|
||||
TLS1.3 key derivation function (HKDF-Expand-Label).
|
||||
|
||||
:param bytearray secret: the key from which to derive the keying material
|
||||
:param bytearray label: label used to differentiate the keying materials
|
||||
:param bytearray hashValue: bytes used to "salt" the produced keying
|
||||
material
|
||||
:param int length: number of bytes to produce
|
||||
:param str algorithm: name of the secure hash algorithm used as the
|
||||
basis of the HKDF
|
||||
:rtype: bytearray
|
||||
"""
|
||||
hkdfLabel = Writer()
|
||||
hkdfLabel.addTwo(length)
|
||||
hkdfLabel.addVarSeq(bytearray(b"tls13 ") + label, 1, 1)
|
||||
hkdfLabel.addVarSeq(hashValue, 1, 1)
|
||||
|
||||
return HKDF_expand(secret, hkdfLabel.bytes, length, algorithm)
|
||||
|
||||
def derive_secret(secret, label, handshake_hashes, algorithm):
|
||||
"""
|
||||
TLS1.3 key derivation function (Derive-Secret).
|
||||
|
||||
:param bytearray secret: secret key used to derive the keying material
|
||||
:param bytearray label: label used to differentiate they keying materials
|
||||
:param HandshakeHashes handshake_hashes: hashes of the handshake messages
|
||||
or `None` if no handshake transcript is to be used for derivation of
|
||||
keying material
|
||||
:param str algorithm: name of the secure hash algorithm used as the
|
||||
basis of the HKDF algorithm - governs how much keying material will
|
||||
be generated
|
||||
:rtype: bytearray
|
||||
"""
|
||||
if handshake_hashes is None:
|
||||
hs_hash = secureHash(bytearray(b''), algorithm)
|
||||
else:
|
||||
hs_hash = handshake_hashes.digest(algorithm)
|
||||
return HKDF_expand_label(secret, label, hs_hash,
|
||||
getattr(hashlib, algorithm)().digest_size,
|
||||
algorithm)
|
||||
|
||||
# **************************************************************************
|
||||
# Converter Functions
|
||||
# **************************************************************************
|
||||
|
||||
def bytesToNumber(b, endian="big"):
|
||||
"""
|
||||
Convert a number stored in bytearray to an integer.
|
||||
|
||||
By default assumes big-endian encoding of the number.
|
||||
"""
|
||||
return bytes_to_int(b, endian)
|
||||
|
||||
|
||||
def numberToByteArray(n, howManyBytes=None, endian="big"):
|
||||
"""
|
||||
Convert an integer into a bytearray, zero-pad to howManyBytes.
|
||||
|
||||
The returned bytearray may be smaller than howManyBytes, but will
|
||||
not be larger. The returned bytearray will contain a big- or little-endian
|
||||
encoding of the input integer (n). Big endian encoding is used by default.
|
||||
"""
|
||||
if howManyBytes is not None:
|
||||
length = byte_length(n)
|
||||
if howManyBytes < length:
|
||||
ret = int_to_bytes(n, length, endian)
|
||||
if endian == "big":
|
||||
return ret[length-howManyBytes:length]
|
||||
return ret[:howManyBytes]
|
||||
return int_to_bytes(n, howManyBytes, endian)
|
||||
|
||||
|
||||
def mpiToNumber(mpi):
|
||||
"""Convert a MPI (OpenSSL bignum string) to an integer."""
|
||||
byte = bytearray(mpi)
|
||||
if byte[4] & 0x80:
|
||||
raise ValueError("Input must be a positive integer")
|
||||
return bytesToNumber(byte[4:])
|
||||
|
||||
|
||||
def numberToMPI(n):
|
||||
b = numberToByteArray(n)
|
||||
ext = 0
|
||||
#If the high-order bit is going to be set,
|
||||
#add an extra byte of zeros
|
||||
if (numBits(n) & 0x7)==0:
|
||||
ext = 1
|
||||
length = numBytes(n) + ext
|
||||
b = bytearray(4+ext) + b
|
||||
b[0] = (length >> 24) & 0xFF
|
||||
b[1] = (length >> 16) & 0xFF
|
||||
b[2] = (length >> 8) & 0xFF
|
||||
b[3] = length & 0xFF
|
||||
return bytes(b)
|
||||
|
||||
|
||||
# **************************************************************************
|
||||
# Misc. Utility Functions
|
||||
# **************************************************************************
|
||||
|
||||
|
||||
# pylint: disable=invalid-name
|
||||
# pylint recognises them as constants, not function names, also
|
||||
# we can't change their names without API change
|
||||
numBits = bit_length
|
||||
|
||||
|
||||
numBytes = byte_length
|
||||
# pylint: enable=invalid-name
|
||||
|
||||
|
||||
# **************************************************************************
|
||||
# Big Number Math
|
||||
# **************************************************************************
|
||||
|
||||
def getRandomNumber(low, high):
|
||||
assert low < high
|
||||
howManyBits = numBits(high)
|
||||
howManyBytes = numBytes(high)
|
||||
lastBits = howManyBits % 8
|
||||
while 1:
|
||||
bytes = getRandomBytes(howManyBytes)
|
||||
if lastBits:
|
||||
bytes[0] = bytes[0] % (1 << lastBits)
|
||||
n = bytesToNumber(bytes)
|
||||
if n >= low and n < high:
|
||||
return n
|
||||
|
||||
def gcd(a,b):
|
||||
a, b = max(a,b), min(a,b)
|
||||
while b:
|
||||
a, b = b, a % b
|
||||
return a
|
||||
|
||||
def lcm(a, b):
|
||||
return (a * b) // gcd(a, b)
|
||||
|
||||
# pylint: disable=invalid-name
|
||||
# disable pylint check as the (a, b) are part of the API
|
||||
if GMPY2_LOADED:
|
||||
def invMod(a, b):
|
||||
"""Return inverse of a mod b, zero if none."""
|
||||
if a == 0:
|
||||
return 0
|
||||
return powmod(a, -1, b)
|
||||
else:
|
||||
# Use Extended Euclidean Algorithm
|
||||
def invMod(a, b):
|
||||
"""Return inverse of a mod b, zero if none."""
|
||||
c, d = a, b
|
||||
uc, ud = 1, 0
|
||||
while c != 0:
|
||||
q = d // c
|
||||
c, d = d-(q*c), c
|
||||
uc, ud = ud - (q * uc), uc
|
||||
if d == 1:
|
||||
return ud % b
|
||||
return 0
|
||||
# pylint: enable=invalid-name
|
||||
|
||||
|
||||
if gmpyLoaded or GMPY2_LOADED:
|
||||
def powMod(base, power, modulus):
|
||||
base = mpz(base)
|
||||
power = mpz(power)
|
||||
modulus = mpz(modulus)
|
||||
result = pow(base, power, modulus)
|
||||
return compatLong(result)
|
||||
else:
|
||||
powMod = pow
|
||||
|
||||
|
||||
def divceil(divident, divisor):
|
||||
"""Integer division with rounding up"""
|
||||
quot, r = divmod(divident, divisor)
|
||||
return quot + int(bool(r))
|
||||
|
||||
|
||||
#Pre-calculate a sieve of the ~100 primes < 1000:
|
||||
def makeSieve(n):
|
||||
sieve = list(range(n))
|
||||
for count in range(2, int(math.sqrt(n))+1):
|
||||
if sieve[count] == 0:
|
||||
continue
|
||||
x = sieve[count] * 2
|
||||
while x < len(sieve):
|
||||
sieve[x] = 0
|
||||
x += sieve[count]
|
||||
sieve = [x for x in sieve[2:] if x]
|
||||
return sieve
|
||||
|
||||
def isPrime(n, iterations=5, display=False, sieve=makeSieve(1000)):
|
||||
#Trial division with sieve
|
||||
for x in sieve:
|
||||
if x >= n: return True
|
||||
if n % x == 0: return False
|
||||
#Passed trial division, proceed to Rabin-Miller
|
||||
#Rabin-Miller implemented per Ferguson & Schneier
|
||||
#Compute s, t for Rabin-Miller
|
||||
if display: print("*", end=' ')
|
||||
s, t = n-1, 0
|
||||
while s % 2 == 0:
|
||||
s, t = s//2, t+1
|
||||
#Repeat Rabin-Miller x times
|
||||
a = 2 #Use 2 as a base for first iteration speedup, per HAC
|
||||
for count in range(iterations):
|
||||
v = powMod(a, s, n)
|
||||
if v==1:
|
||||
continue
|
||||
i = 0
|
||||
while v != n-1:
|
||||
if i == t-1:
|
||||
return False
|
||||
else:
|
||||
v, i = powMod(v, 2, n), i+1
|
||||
a = getRandomNumber(2, n)
|
||||
return True
|
||||
|
||||
|
||||
def getRandomPrime(bits, display=False):
|
||||
"""
|
||||
Generate a random prime number of a given size.
|
||||
|
||||
the number will be 'bits' bits long (i.e. generated number will be
|
||||
larger than `(2^(bits-1) * 3 ) / 2` but smaller than 2^bits.
|
||||
"""
|
||||
assert bits >= 10
|
||||
#The 1.5 ensures the 2 MSBs are set
|
||||
#Thus, when used for p,q in RSA, n will have its MSB set
|
||||
#
|
||||
#Since 30 is lcm(2,3,5), we'll set our test numbers to
|
||||
#29 % 30 and keep them there
|
||||
low = ((2 ** (bits-1)) * 3) // 2
|
||||
high = 2 ** bits - 30
|
||||
while True:
|
||||
if display:
|
||||
print(".", end=' ')
|
||||
cand_p = getRandomNumber(low, high)
|
||||
# make odd
|
||||
if cand_p % 2 == 0:
|
||||
cand_p += 1
|
||||
if isPrime(cand_p, display=display):
|
||||
return cand_p
|
||||
|
||||
|
||||
#Unused at the moment...
|
||||
def getRandomSafePrime(bits, display=False):
|
||||
"""Generate a random safe prime.
|
||||
|
||||
Will generate a prime `bits` bits long (see getRandomPrime) such that
|
||||
the (p-1)/2 will also be prime.
|
||||
"""
|
||||
assert bits >= 10
|
||||
#The 1.5 ensures the 2 MSBs are set
|
||||
#Thus, when used for p,q in RSA, n will have its MSB set
|
||||
#
|
||||
#Since 30 is lcm(2,3,5), we'll set our test numbers to
|
||||
#29 % 30 and keep them there
|
||||
low = (2 ** (bits-2)) * 3//2
|
||||
high = (2 ** (bits-1)) - 30
|
||||
q = getRandomNumber(low, high)
|
||||
q += 29 - (q % 30)
|
||||
while 1:
|
||||
if display: print(".", end=' ')
|
||||
q += 30
|
||||
if (q >= high):
|
||||
q = getRandomNumber(low, high)
|
||||
q += 29 - (q % 30)
|
||||
#Ideas from Tom Wu's SRP code
|
||||
#Do trial division on p and q before Rabin-Miller
|
||||
if isPrime(q, 0, display=display):
|
||||
p = (2 * q) + 1
|
||||
if isPrime(p, display=display):
|
||||
if isPrime(q, display=display):
|
||||
return p
|
||||
218
mediaflow_proxy/utils/deprecations.py
Normal file
218
mediaflow_proxy/utils/deprecations.py
Normal file
@@ -0,0 +1,218 @@
|
||||
# Copyright (c) 2018 Hubert Kario
|
||||
#
|
||||
# See the LICENSE file for legal information regarding use of this file.
|
||||
"""Methods for deprecating old names for arguments or attributes."""
|
||||
import warnings
|
||||
import inspect
|
||||
from functools import wraps
|
||||
|
||||
|
||||
def deprecated_class_name(old_name,
|
||||
warn="Class name '{old_name}' is deprecated, "
|
||||
"please use '{new_name}'"):
|
||||
"""
|
||||
Class decorator to deprecate a use of class.
|
||||
|
||||
:param str old_name: the deprecated name that will be registered, but
|
||||
will raise warnings if used.
|
||||
|
||||
:param str warn: DeprecationWarning format string for informing the
|
||||
user what is the current class name, uses 'old_name' for the deprecated
|
||||
keyword name and the 'new_name' for the current one.
|
||||
Example: "Old name: {old_nam}, use '{new_name}' instead".
|
||||
"""
|
||||
def _wrap(obj):
|
||||
assert callable(obj)
|
||||
|
||||
def _warn():
|
||||
warnings.warn(warn.format(old_name=old_name,
|
||||
new_name=obj.__name__),
|
||||
DeprecationWarning,
|
||||
stacklevel=3)
|
||||
|
||||
def _wrap_with_warn(func, is_inspect):
|
||||
@wraps(func)
|
||||
def _func(*args, **kwargs):
|
||||
if is_inspect:
|
||||
# XXX: If use another name to call,
|
||||
# you will not get the warning.
|
||||
# we do this instead of subclassing or metaclass as
|
||||
# we want to isinstance(new_name(), old_name) and
|
||||
# isinstance(old_name(), new_name) to work
|
||||
frame = inspect.currentframe().f_back
|
||||
code = inspect.getframeinfo(frame).code_context
|
||||
if [line for line in code
|
||||
if '{0}('.format(old_name) in line]:
|
||||
_warn()
|
||||
else:
|
||||
_warn()
|
||||
return func(*args, **kwargs)
|
||||
return _func
|
||||
|
||||
# Make old name available.
|
||||
frame = inspect.currentframe().f_back
|
||||
if old_name in frame.f_globals:
|
||||
raise NameError("Name '{0}' already in use.".format(old_name))
|
||||
|
||||
if inspect.isclass(obj):
|
||||
obj.__init__ = _wrap_with_warn(obj.__init__, True)
|
||||
placeholder = obj
|
||||
else:
|
||||
placeholder = _wrap_with_warn(obj, False)
|
||||
|
||||
frame.f_globals[old_name] = placeholder
|
||||
|
||||
return obj
|
||||
return _wrap
|
||||
|
||||
|
||||
def deprecated_params(names, warn="Param name '{old_name}' is deprecated, "
|
||||
"please use '{new_name}'"):
|
||||
"""Decorator to translate obsolete names and warn about their use.
|
||||
|
||||
:param dict names: dictionary with pairs of new_name: old_name
|
||||
that will be used for translating obsolete param names to new names
|
||||
|
||||
:param str warn: DeprecationWarning format string for informing the user
|
||||
what is the current parameter name, uses 'old_name' for the
|
||||
deprecated keyword name and 'new_name' for the current one.
|
||||
Example: "Old name: {old_name}, use {new_name} instead".
|
||||
"""
|
||||
def decorator(func):
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
for new_name, old_name in names.items():
|
||||
if old_name in kwargs:
|
||||
if new_name in kwargs:
|
||||
raise TypeError("got multiple values for keyword "
|
||||
"argument '{0}'".format(new_name))
|
||||
warnings.warn(warn.format(old_name=old_name,
|
||||
new_name=new_name),
|
||||
DeprecationWarning,
|
||||
stacklevel=2)
|
||||
kwargs[new_name] = kwargs.pop(old_name)
|
||||
return func(*args, **kwargs)
|
||||
return wrapper
|
||||
return decorator
|
||||
|
||||
|
||||
def deprecated_instance_attrs(names,
|
||||
warn="Attribute '{old_name}' is deprecated, "
|
||||
"please use '{new_name}'"):
|
||||
"""Decorator to deprecate class instance attributes.
|
||||
|
||||
Translates all names in `names` to use new names and emits warnings
|
||||
if the translation was necessary. Does apply only to instance variables
|
||||
and attributes (won't modify behaviour of class variables, static methods,
|
||||
etc.
|
||||
|
||||
:param dict names: dictionary with paris of new_name: old_name that will
|
||||
be used to translate the calls
|
||||
:param str warn: DeprecationWarning format string for informing the user
|
||||
what is the current parameter name, uses 'old_name' for the
|
||||
deprecated keyword name and 'new_name' for the current one.
|
||||
Example: "Old name: {old_name}, use {new_name} instead".
|
||||
"""
|
||||
# reverse the dict as we're looking for old attributes, not new ones
|
||||
names = dict((j, i) for i, j in names.items())
|
||||
|
||||
def decorator(clazz):
|
||||
def getx(self, name, __old_getx=getattr(clazz, "__getattr__", None)):
|
||||
if name in names:
|
||||
warnings.warn(warn.format(old_name=name,
|
||||
new_name=names[name]),
|
||||
DeprecationWarning,
|
||||
stacklevel=2)
|
||||
return getattr(self, names[name])
|
||||
if __old_getx:
|
||||
if hasattr(__old_getx, "__func__"):
|
||||
return __old_getx.__func__(self, name)
|
||||
return __old_getx(self, name)
|
||||
raise AttributeError("'{0}' object has no attribute '{1}'"
|
||||
.format(clazz.__name__, name))
|
||||
|
||||
getx.__name__ = "__getattr__"
|
||||
clazz.__getattr__ = getx
|
||||
|
||||
def setx(self, name, value, __old_setx=getattr(clazz, "__setattr__")):
|
||||
if name in names:
|
||||
warnings.warn(warn.format(old_name=name,
|
||||
new_name=names[name]),
|
||||
DeprecationWarning,
|
||||
stacklevel=2)
|
||||
setattr(self, names[name], value)
|
||||
else:
|
||||
__old_setx(self, name, value)
|
||||
|
||||
setx.__name__ = "__setattr__"
|
||||
clazz.__setattr__ = setx
|
||||
|
||||
def delx(self, name, __old_delx=getattr(clazz, "__delattr__")):
|
||||
if name in names:
|
||||
warnings.warn(warn.format(old_name=name,
|
||||
new_name=names[name]),
|
||||
DeprecationWarning,
|
||||
stacklevel=2)
|
||||
delattr(self, names[name])
|
||||
else:
|
||||
__old_delx(self, name)
|
||||
|
||||
delx.__name__ = "__delattr__"
|
||||
clazz.__delattr__ = delx
|
||||
|
||||
return clazz
|
||||
return decorator
|
||||
|
||||
|
||||
def deprecated_attrs(names, warn="Attribute '{old_name}' is deprecated, "
|
||||
"please use '{new_name}'"):
|
||||
"""Decorator to deprecate all specified attributes in class.
|
||||
|
||||
Translates all names in `names` to use new names and emits warnings
|
||||
if the translation was necessary.
|
||||
|
||||
Note: uses metaclass magic so is incompatible with other metaclass uses
|
||||
|
||||
:param dict names: dictionary with paris of new_name: old_name that will
|
||||
be used to translate the calls
|
||||
:param str warn: DeprecationWarning format string for informing the user
|
||||
what is the current parameter name, uses 'old_name' for the
|
||||
deprecated keyword name and 'new_name' for the current one.
|
||||
Example: "Old name: {old_name}, use {new_name} instead".
|
||||
"""
|
||||
# prepare metaclass for handling all the class methods, class variables
|
||||
# and static methods (as they don't go through instance's __getattr__)
|
||||
class DeprecatedProps(type):
|
||||
pass
|
||||
|
||||
metaclass = deprecated_instance_attrs(names, warn)(DeprecatedProps)
|
||||
|
||||
def wrapper(cls):
|
||||
cls = deprecated_instance_attrs(names, warn)(cls)
|
||||
|
||||
# apply metaclass
|
||||
orig_vars = cls.__dict__.copy()
|
||||
slots = orig_vars.get('__slots__')
|
||||
if slots is not None:
|
||||
if isinstance(slots, str):
|
||||
slots = [slots]
|
||||
for slots_var in slots:
|
||||
orig_vars.pop(slots_var)
|
||||
orig_vars.pop('__dict__', None)
|
||||
orig_vars.pop('__weakref__', None)
|
||||
return metaclass(cls.__name__, cls.__bases__, orig_vars)
|
||||
return wrapper
|
||||
|
||||
def deprecated_method(message):
|
||||
"""Decorator for deprecating methods.
|
||||
|
||||
:param ste message: The message you want to display.
|
||||
"""
|
||||
def decorator(func):
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
warnings.warn("{0} is a deprecated method. {1}".format(func.__name__, message),
|
||||
DeprecationWarning, stacklevel=2)
|
||||
return func(*args, **kwargs)
|
||||
return wrapper
|
||||
return decorator
|
||||
@@ -6,6 +6,9 @@ from urllib.parse import urlparse
|
||||
import httpx
|
||||
from mediaflow_proxy.utils.http_utils import create_httpx_client
|
||||
from mediaflow_proxy.configs import settings
|
||||
from collections import OrderedDict
|
||||
import time
|
||||
from urllib.parse import urljoin
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -23,12 +26,21 @@ class HLSPreBuffer:
|
||||
max_cache_size (int): Maximum number of segments to cache (uses config if None)
|
||||
prebuffer_segments (int): Number of segments to pre-buffer ahead (uses config if None)
|
||||
"""
|
||||
from collections import OrderedDict
|
||||
import time
|
||||
from urllib.parse import urljoin
|
||||
self.max_cache_size = max_cache_size or settings.hls_prebuffer_cache_size
|
||||
self.prebuffer_segments = prebuffer_segments or settings.hls_prebuffer_segments
|
||||
self.max_memory_percent = settings.hls_prebuffer_max_memory_percent
|
||||
self.emergency_threshold = settings.hls_prebuffer_emergency_threshold
|
||||
self.segment_cache: Dict[str, bytes] = {}
|
||||
# Cache LRU
|
||||
self.segment_cache: "OrderedDict[str, bytes]" = OrderedDict()
|
||||
# Mappa playlist -> lista segmenti
|
||||
self.segment_urls: Dict[str, List[str]] = {}
|
||||
# Mappa inversa segmento -> (playlist_url, index)
|
||||
self.segment_to_playlist: Dict[str, tuple[str, int]] = {}
|
||||
# Stato per playlist: {headers, last_access, refresh_task, target_duration}
|
||||
self.playlist_state: Dict[str, dict] = {}
|
||||
self.client = create_httpx_client()
|
||||
|
||||
async def prebuffer_playlist(self, playlist_url: str, headers: Dict[str, str]) -> None:
|
||||
@@ -41,37 +53,44 @@ class HLSPreBuffer:
|
||||
"""
|
||||
try:
|
||||
logger.debug(f"Starting pre-buffer for playlist: {playlist_url}")
|
||||
|
||||
# Download and parse playlist
|
||||
response = await self.client.get(playlist_url, headers=headers)
|
||||
response.raise_for_status()
|
||||
playlist_content = response.text
|
||||
|
||||
# Check if this is a master playlist (contains variants)
|
||||
|
||||
# Se master playlist: prendi la prima variante (fix relativo)
|
||||
if "#EXT-X-STREAM-INF" in playlist_content:
|
||||
logger.debug(f"Master playlist detected, finding first variant")
|
||||
# Extract variant URLs
|
||||
variant_urls = self._extract_variant_urls(playlist_content, playlist_url)
|
||||
if variant_urls:
|
||||
# Pre-buffer the first variant
|
||||
first_variant_url = variant_urls[0]
|
||||
logger.debug(f"Pre-buffering first variant: {first_variant_url}")
|
||||
await self.prebuffer_playlist(first_variant_url, headers)
|
||||
else:
|
||||
logger.warning("No variants found in master playlist")
|
||||
return
|
||||
|
||||
# Extract segment URLs
|
||||
|
||||
# Media playlist: estrai segmenti, salva stato e lancia refresh loop
|
||||
segment_urls = self._extract_segment_urls(playlist_content, playlist_url)
|
||||
|
||||
# Store segment URLs for this playlist
|
||||
self.segment_urls[playlist_url] = segment_urls
|
||||
|
||||
# Pre-buffer first few segments
|
||||
# aggiorna mappa inversa
|
||||
for idx, u in enumerate(segment_urls):
|
||||
self.segment_to_playlist[u] = (playlist_url, idx)
|
||||
|
||||
# prebuffer iniziale
|
||||
await self._prebuffer_segments(segment_urls[:self.prebuffer_segments], headers)
|
||||
|
||||
logger.info(f"Pre-buffered {min(self.prebuffer_segments, len(segment_urls))} segments for {playlist_url}")
|
||||
|
||||
|
||||
# setup refresh loop se non già attivo
|
||||
target_duration = self._parse_target_duration(playlist_content) or 6
|
||||
st = self.playlist_state.get(playlist_url, {})
|
||||
if not st.get("refresh_task") or st["refresh_task"].done():
|
||||
task = asyncio.create_task(self._refresh_playlist_loop(playlist_url, headers, target_duration))
|
||||
self.playlist_state[playlist_url] = {
|
||||
"headers": headers,
|
||||
"last_access": asyncio.get_event_loop().time(),
|
||||
"refresh_task": task,
|
||||
"target_duration": target_duration,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to pre-buffer playlist {playlist_url}: {e}")
|
||||
|
||||
@@ -124,34 +143,24 @@ class HLSPreBuffer:
|
||||
|
||||
def _extract_variant_urls(self, playlist_content: str, base_url: str) -> List[str]:
|
||||
"""
|
||||
Extract variant URLs from master playlist content.
|
||||
|
||||
Args:
|
||||
playlist_content (str): Content of the master playlist
|
||||
base_url (str): Base URL for resolving relative URLs
|
||||
|
||||
Returns:
|
||||
List[str]: List of variant URLs
|
||||
Estrae le varianti dal master playlist. Corretto per gestire URI relativi:
|
||||
prende la riga non-commento successiva a #EXT-X-STREAM-INF e la risolve rispetto a base_url.
|
||||
"""
|
||||
from urllib.parse import urljoin
|
||||
variant_urls = []
|
||||
lines = playlist_content.split('\n')
|
||||
|
||||
lines = [l.strip() for l in playlist_content.split('\n')]
|
||||
take_next_uri = False
|
||||
for line in lines:
|
||||
line = line.strip()
|
||||
if line and not line.startswith('#') and ('http://' in line or 'https://' in line):
|
||||
# Resolve relative URLs
|
||||
if line.startswith('http'):
|
||||
variant_urls.append(line)
|
||||
else:
|
||||
# Join with base URL for relative paths
|
||||
parsed_base = urlparse(base_url)
|
||||
variant_url = f"{parsed_base.scheme}://{parsed_base.netloc}{line}"
|
||||
variant_urls.append(variant_url)
|
||||
|
||||
if line.startswith("#EXT-X-STREAM-INF"):
|
||||
take_next_uri = True
|
||||
continue
|
||||
if take_next_uri:
|
||||
take_next_uri = False
|
||||
if line and not line.startswith('#'):
|
||||
variant_urls.append(urljoin(base_url, line))
|
||||
logger.debug(f"Extracted {len(variant_urls)} variant URLs from master playlist")
|
||||
if variant_urls:
|
||||
logger.debug(f"First variant URL: {variant_urls[0]}")
|
||||
|
||||
return variant_urls
|
||||
|
||||
async def _prebuffer_segments(self, segment_urls: List[str], headers: Dict[str, str]) -> None:
|
||||
@@ -166,7 +175,6 @@ class HLSPreBuffer:
|
||||
for url in segment_urls:
|
||||
if url not in self.segment_cache:
|
||||
tasks.append(self._download_segment(url, headers))
|
||||
|
||||
if tasks:
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
@@ -196,16 +204,16 @@ class HLSPreBuffer:
|
||||
|
||||
def _emergency_cache_cleanup(self) -> None:
|
||||
"""
|
||||
Perform emergency cache cleanup when memory usage is high.
|
||||
Esegue cleanup LRU rimuovendo il 50% più vecchio.
|
||||
"""
|
||||
if self._check_memory_threshold():
|
||||
logger.warning("Emergency cache cleanup triggered due to high memory usage")
|
||||
# Clear 50% of cache
|
||||
cache_size = len(self.segment_cache)
|
||||
keys_to_remove = list(self.segment_cache.keys())[:cache_size // 2]
|
||||
for key in keys_to_remove:
|
||||
del self.segment_cache[key]
|
||||
logger.info(f"Emergency cleanup removed {len(keys_to_remove)} segments from cache")
|
||||
to_remove = max(1, len(self.segment_cache) // 2)
|
||||
removed = 0
|
||||
while removed < to_remove and self.segment_cache:
|
||||
self.segment_cache.popitem(last=False) # rimuovi LRU
|
||||
removed += 1
|
||||
logger.info(f"Emergency cleanup removed {removed} segments from cache")
|
||||
|
||||
async def _download_segment(self, segment_url: str, headers: Dict[str, str]) -> None:
|
||||
"""
|
||||
@@ -216,29 +224,26 @@ class HLSPreBuffer:
|
||||
headers (Dict[str, str]): Headers to use for request
|
||||
"""
|
||||
try:
|
||||
# Check memory usage before downloading
|
||||
memory_percent = self._get_memory_usage_percent()
|
||||
if memory_percent > self.max_memory_percent:
|
||||
logger.warning(f"Memory usage {memory_percent}% exceeds limit {self.max_memory_percent}%, skipping download")
|
||||
return
|
||||
|
||||
|
||||
response = await self.client.get(segment_url, headers=headers)
|
||||
response.raise_for_status()
|
||||
|
||||
# Cache the segment
|
||||
|
||||
# Cache LRU
|
||||
self.segment_cache[segment_url] = response.content
|
||||
|
||||
# Check for emergency cleanup
|
||||
self.segment_cache.move_to_end(segment_url, last=True)
|
||||
|
||||
if self._check_memory_threshold():
|
||||
self._emergency_cache_cleanup()
|
||||
# Maintain cache size
|
||||
elif len(self.segment_cache) > self.max_cache_size:
|
||||
# Remove oldest entries (simple FIFO)
|
||||
oldest_key = next(iter(self.segment_cache))
|
||||
del self.segment_cache[oldest_key]
|
||||
|
||||
# Evict LRU finché non rientra
|
||||
while len(self.segment_cache) > self.max_cache_size:
|
||||
self.segment_cache.popitem(last=False)
|
||||
|
||||
logger.debug(f"Cached segment: {segment_url}")
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to download segment {segment_url}: {e}")
|
||||
|
||||
@@ -256,38 +261,64 @@ class HLSPreBuffer:
|
||||
# Check cache first
|
||||
if segment_url in self.segment_cache:
|
||||
logger.debug(f"Cache hit for segment: {segment_url}")
|
||||
return self.segment_cache[segment_url]
|
||||
|
||||
# Check memory usage before downloading
|
||||
# LRU touch
|
||||
data = self.segment_cache[segment_url]
|
||||
self.segment_cache.move_to_end(segment_url, last=True)
|
||||
# aggiorna last_access per la playlist se mappata
|
||||
pl = self.segment_to_playlist.get(segment_url)
|
||||
if pl:
|
||||
st = self.playlist_state.get(pl[0])
|
||||
if st:
|
||||
st["last_access"] = asyncio.get_event_loop().time()
|
||||
return data
|
||||
|
||||
memory_percent = self._get_memory_usage_percent()
|
||||
if memory_percent > self.max_memory_percent:
|
||||
logger.warning(f"Memory usage {memory_percent}% exceeds limit {self.max_memory_percent}%, skipping download")
|
||||
return None
|
||||
|
||||
# Download if not in cache
|
||||
|
||||
try:
|
||||
response = await self.client.get(segment_url, headers=headers)
|
||||
response.raise_for_status()
|
||||
segment_data = response.content
|
||||
|
||||
# Cache the segment
|
||||
|
||||
# Cache LRU
|
||||
self.segment_cache[segment_url] = segment_data
|
||||
|
||||
# Check for emergency cleanup
|
||||
self.segment_cache.move_to_end(segment_url, last=True)
|
||||
|
||||
if self._check_memory_threshold():
|
||||
self._emergency_cache_cleanup()
|
||||
# Maintain cache size
|
||||
elif len(self.segment_cache) > self.max_cache_size:
|
||||
oldest_key = next(iter(self.segment_cache))
|
||||
del self.segment_cache[oldest_key]
|
||||
|
||||
while len(self.segment_cache) > self.max_cache_size:
|
||||
self.segment_cache.popitem(last=False)
|
||||
|
||||
# aggiorna last_access per playlist
|
||||
pl = self.segment_to_playlist.get(segment_url)
|
||||
if pl:
|
||||
st = self.playlist_state.get(pl[0])
|
||||
if st:
|
||||
st["last_access"] = asyncio.get_event_loop().time()
|
||||
|
||||
logger.debug(f"Downloaded and cached segment: {segment_url}")
|
||||
return segment_data
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to get segment {segment_url}: {e}")
|
||||
return None
|
||||
|
||||
async def prebuffer_from_segment(self, segment_url: str, headers: Dict[str, str]) -> None:
|
||||
"""
|
||||
Dato un URL di segmento, prebuffer i successivi in base alla playlist e all'indice mappato.
|
||||
"""
|
||||
mapped = self.segment_to_playlist.get(segment_url)
|
||||
if not mapped:
|
||||
return
|
||||
playlist_url, idx = mapped
|
||||
# aggiorna access time
|
||||
st = self.playlist_state.get(playlist_url)
|
||||
if st:
|
||||
st["last_access"] = asyncio.get_event_loop().time()
|
||||
await self.prebuffer_next_segments(playlist_url, idx, headers)
|
||||
|
||||
async def prebuffer_next_segments(self, playlist_url: str, current_segment_index: int, headers: Dict[str, str]) -> None:
|
||||
"""
|
||||
Pre-buffer next segments based on current playback position.
|
||||
@@ -299,10 +330,8 @@ class HLSPreBuffer:
|
||||
"""
|
||||
if playlist_url not in self.segment_urls:
|
||||
return
|
||||
|
||||
segment_urls = self.segment_urls[playlist_url]
|
||||
next_segments = segment_urls[current_segment_index + 1:current_segment_index + 1 + self.prebuffer_segments]
|
||||
|
||||
if next_segments:
|
||||
await self._prebuffer_segments(next_segments, headers)
|
||||
|
||||
@@ -310,6 +339,8 @@ class HLSPreBuffer:
|
||||
"""Clear the segment cache."""
|
||||
self.segment_cache.clear()
|
||||
self.segment_urls.clear()
|
||||
self.segment_to_playlist.clear()
|
||||
self.playlist_state.clear()
|
||||
logger.info("HLS pre-buffer cache cleared")
|
||||
|
||||
async def close(self) -> None:
|
||||
@@ -318,4 +349,142 @@ class HLSPreBuffer:
|
||||
|
||||
|
||||
# Global pre-buffer instance
|
||||
hls_prebuffer = HLSPreBuffer()
|
||||
hls_prebuffer = HLSPreBuffer()
|
||||
|
||||
|
||||
class HLSPreBuffer:
|
||||
def _parse_target_duration(self, playlist_content: str) -> Optional[int]:
|
||||
"""
|
||||
Parse EXT-X-TARGETDURATION from a media playlist and return duration in seconds.
|
||||
Returns None if not present or unparsable.
|
||||
"""
|
||||
for line in playlist_content.splitlines():
|
||||
line = line.strip()
|
||||
if line.startswith("#EXT-X-TARGETDURATION:"):
|
||||
try:
|
||||
value = line.split(":", 1)[1].strip()
|
||||
return int(float(value))
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
async def _refresh_playlist_loop(self, playlist_url: str, headers: Dict[str, str], target_duration: int) -> None:
|
||||
"""
|
||||
Aggiorna periodicamente la playlist per seguire la sliding window e mantenere la cache coerente.
|
||||
Interrompe e pulisce dopo inattività prolungata.
|
||||
"""
|
||||
sleep_s = max(2, min(15, int(target_duration)))
|
||||
inactivity_timeout = 600 # 10 minuti
|
||||
while True:
|
||||
try:
|
||||
st = self.playlist_state.get(playlist_url)
|
||||
now = asyncio.get_event_loop().time()
|
||||
if not st:
|
||||
return
|
||||
if now - st.get("last_access", now) > inactivity_timeout:
|
||||
# cleanup specifico della playlist
|
||||
urls = set(self.segment_urls.get(playlist_url, []))
|
||||
if urls:
|
||||
# rimuovi dalla cache solo i segmenti di questa playlist
|
||||
for u in list(self.segment_cache.keys()):
|
||||
if u in urls:
|
||||
self.segment_cache.pop(u, None)
|
||||
# rimuovi mapping
|
||||
for u in urls:
|
||||
self.segment_to_playlist.pop(u, None)
|
||||
self.segment_urls.pop(playlist_url, None)
|
||||
self.playlist_state.pop(playlist_url, None)
|
||||
logger.info(f"Stopped HLS prebuffer for inactive playlist: {playlist_url}")
|
||||
return
|
||||
|
||||
# refresh manifest
|
||||
resp = await self.client.get(playlist_url, headers=headers)
|
||||
resp.raise_for_status()
|
||||
content = resp.text
|
||||
new_target = self._parse_target_duration(content)
|
||||
if new_target:
|
||||
sleep_s = max(2, min(15, int(new_target)))
|
||||
|
||||
new_urls = self._extract_segment_urls(content, playlist_url)
|
||||
if new_urls:
|
||||
self.segment_urls[playlist_url] = new_urls
|
||||
# rebuild reverse map per gli ultimi N (limita la memoria)
|
||||
for idx, u in enumerate(new_urls[-(self.max_cache_size * 2):]):
|
||||
# rimappiando sovrascrivi eventuali entry
|
||||
real_idx = len(new_urls) - (self.max_cache_size * 2) + idx if len(new_urls) > (self.max_cache_size * 2) else idx
|
||||
self.segment_to_playlist[u] = (playlist_url, real_idx)
|
||||
|
||||
# tenta un prebuffer proattivo: se conosciamo l'ultimo segmento accessibile, anticipa i successivi
|
||||
# Non conosciamo l'indice di riproduzione corrente qui, quindi non facciamo nulla di aggressivo.
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"Playlist refresh error for {playlist_url}: {e}")
|
||||
await asyncio.sleep(sleep_s)
|
||||
def _extract_segment_urls(self, playlist_content: str, base_url: str) -> List[str]:
|
||||
"""
|
||||
Extract segment URLs from HLS playlist content.
|
||||
|
||||
Args:
|
||||
playlist_content (str): Content of the HLS playlist
|
||||
base_url (str): Base URL for resolving relative URLs
|
||||
|
||||
Returns:
|
||||
List[str]: List of segment URLs
|
||||
"""
|
||||
segment_urls = []
|
||||
lines = playlist_content.split('\n')
|
||||
|
||||
logger.debug(f"Analyzing playlist with {len(lines)} lines")
|
||||
|
||||
for line in lines:
|
||||
line = line.strip()
|
||||
if line and not line.startswith('#'):
|
||||
# Check if line contains a URL (http/https) or is a relative path
|
||||
if 'http://' in line or 'https://' in line:
|
||||
segment_urls.append(line)
|
||||
logger.debug(f"Found absolute URL: {line}")
|
||||
elif line and not line.startswith('#'):
|
||||
# This might be a relative path to a segment
|
||||
parsed_base = urlparse(base_url)
|
||||
# Ensure proper path joining
|
||||
if line.startswith('/'):
|
||||
segment_url = f"{parsed_base.scheme}://{parsed_base.netloc}{line}"
|
||||
else:
|
||||
# Get the directory path from base_url
|
||||
base_path = parsed_base.path.rsplit('/', 1)[0] if '/' in parsed_base.path else ''
|
||||
segment_url = f"{parsed_base.scheme}://{parsed_base.netloc}{base_path}/{line}"
|
||||
segment_urls.append(segment_url)
|
||||
logger.debug(f"Found relative path: {line} -> {segment_url}")
|
||||
|
||||
logger.debug(f"Extracted {len(segment_urls)} segment URLs from playlist")
|
||||
if segment_urls:
|
||||
logger.debug(f"First segment URL: {segment_urls[0]}")
|
||||
else:
|
||||
logger.debug("No segment URLs found in playlist")
|
||||
# Log first few lines for debugging
|
||||
for i, line in enumerate(lines[:10]):
|
||||
logger.debug(f"Line {i}: {line}")
|
||||
|
||||
return segment_urls
|
||||
|
||||
def _extract_variant_urls(self, playlist_content: str, base_url: str) -> List[str]:
|
||||
"""
|
||||
Estrae le varianti dal master playlist. Corretto per gestire URI relativi:
|
||||
prende la riga non-commento successiva a #EXT-X-STREAM-INF e la risolve rispetto a base_url.
|
||||
"""
|
||||
from urllib.parse import urljoin
|
||||
variant_urls = []
|
||||
lines = [l.strip() for l in playlist_content.split('\n')]
|
||||
take_next_uri = False
|
||||
for line in lines:
|
||||
if line.startswith("#EXT-X-STREAM-INF"):
|
||||
take_next_uri = True
|
||||
continue
|
||||
if take_next_uri:
|
||||
take_next_uri = False
|
||||
if line and not line.startswith('#'):
|
||||
variant_urls.append(urljoin(base_url, line))
|
||||
logger.debug(f"Extracted {len(variant_urls)} variant URLs from master playlist")
|
||||
if variant_urls:
|
||||
logger.debug(f"First variant URL: {variant_urls[0]}")
|
||||
return variant_urls
|
||||
54
mediaflow_proxy/utils/hls_utils.py
Normal file
54
mediaflow_proxy/utils/hls_utils.py
Normal file
@@ -0,0 +1,54 @@
|
||||
import logging
|
||||
import re
|
||||
from typing import List, Dict, Any, Optional, Tuple
|
||||
from urllib.parse import urljoin
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def parse_hls_playlist(playlist_content: str, base_url: Optional[str] = None) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Parses an HLS master playlist to extract stream information.
|
||||
|
||||
Args:
|
||||
playlist_content (str): The content of the M3U8 master playlist.
|
||||
base_url (str, optional): The base URL of the playlist for resolving relative stream URLs. Defaults to None.
|
||||
|
||||
Returns:
|
||||
List[Dict[str, Any]]: A list of dictionaries, each representing a stream variant.
|
||||
"""
|
||||
streams = []
|
||||
lines = playlist_content.strip().split('\n')
|
||||
|
||||
# Regex to capture attributes from #EXT-X-STREAM-INF
|
||||
stream_inf_pattern = re.compile(r'#EXT-X-STREAM-INF:(.*)')
|
||||
|
||||
for i, line in enumerate(lines):
|
||||
if line.startswith('#EXT-X-STREAM-INF'):
|
||||
stream_info = {'raw_stream_inf': line}
|
||||
match = stream_inf_pattern.match(line)
|
||||
if not match:
|
||||
logger.warning(f"Could not parse #EXT-X-STREAM-INF line: {line}")
|
||||
continue
|
||||
attributes_str = match.group(1)
|
||||
|
||||
# Parse attributes like BANDWIDTH, RESOLUTION, etc.
|
||||
attributes = re.findall(r'([A-Z-]+)=("([^"]+)"|([^,]+))', attributes_str)
|
||||
for key, _, quoted_val, unquoted_val in attributes:
|
||||
value = quoted_val if quoted_val else unquoted_val
|
||||
if key == 'RESOLUTION':
|
||||
try:
|
||||
width, height = map(int, value.split('x'))
|
||||
stream_info['resolution'] = (width, height)
|
||||
except ValueError:
|
||||
stream_info['resolution'] = (0, 0)
|
||||
else:
|
||||
stream_info[key.lower().replace('-', '_')] = value
|
||||
|
||||
# The next line should be the stream URL
|
||||
if i + 1 < len(lines) and not lines[i + 1].startswith('#'):
|
||||
stream_url = lines[i + 1].strip()
|
||||
stream_info['url'] = urljoin(base_url, stream_url) if base_url else stream_url
|
||||
streams.append(stream_info)
|
||||
|
||||
return streams
|
||||
@@ -3,7 +3,7 @@ import typing
|
||||
from dataclasses import dataclass
|
||||
from functools import partial
|
||||
from urllib import parse
|
||||
from urllib.parse import urlencode
|
||||
from urllib.parse import urlencode, urlparse
|
||||
|
||||
import anyio
|
||||
import h11
|
||||
@@ -81,6 +81,10 @@ async def fetch_with_retry(client, method, url, headers, follow_redirects=True,
|
||||
|
||||
|
||||
class Streamer:
|
||||
# PNG signature and IEND marker for fake PNG header detection (StreamWish/FileMoon)
|
||||
_PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
|
||||
_PNG_IEND_MARKER = b"\x49\x45\x4E\x44\xAE\x42\x60\x82"
|
||||
|
||||
def __init__(self, client):
|
||||
"""
|
||||
Initializes the Streamer with an HTTP client.
|
||||
@@ -132,13 +136,48 @@ class Streamer:
|
||||
logger.error(f"Error creating streaming response: {e}")
|
||||
raise RuntimeError(f"Error creating streaming response: {e}")
|
||||
|
||||
@staticmethod
|
||||
def _strip_fake_png_wrapper(chunk: bytes) -> bytes:
|
||||
"""
|
||||
Strip fake PNG wrapper from chunk data.
|
||||
|
||||
Some streaming services (StreamWish, FileMoon) prepend a fake PNG image
|
||||
to video data to evade detection. This method detects and removes it.
|
||||
|
||||
Args:
|
||||
chunk: The raw chunk data that may contain a fake PNG header.
|
||||
|
||||
Returns:
|
||||
The chunk with fake PNG wrapper removed, or original chunk if not present.
|
||||
"""
|
||||
if not chunk.startswith(Streamer._PNG_SIGNATURE):
|
||||
return chunk
|
||||
|
||||
# Find the IEND marker that signals end of PNG data
|
||||
iend_pos = chunk.find(Streamer._PNG_IEND_MARKER)
|
||||
if iend_pos == -1:
|
||||
# IEND not found in this chunk - return as-is to avoid data corruption
|
||||
logger.debug("PNG signature detected but IEND marker not found in chunk")
|
||||
return chunk
|
||||
|
||||
# Calculate position after IEND marker
|
||||
content_start = iend_pos + len(Streamer._PNG_IEND_MARKER)
|
||||
|
||||
# Skip any padding bytes (null or 0xFF) between PNG and actual content
|
||||
while content_start < len(chunk) and chunk[content_start] in (0x00, 0xFF):
|
||||
content_start += 1
|
||||
|
||||
stripped_bytes = content_start
|
||||
logger.debug(f"Stripped {stripped_bytes} bytes of fake PNG wrapper from stream")
|
||||
|
||||
return chunk[content_start:]
|
||||
|
||||
async def stream_content(self) -> typing.AsyncGenerator[bytes, None]:
|
||||
"""
|
||||
Streams the content from the response.
|
||||
"""
|
||||
if not self.response:
|
||||
raise RuntimeError("No response available for streaming")
|
||||
|
||||
is_first_chunk = True
|
||||
|
||||
try:
|
||||
self.parse_content_range()
|
||||
|
||||
@@ -154,15 +193,19 @@ class Streamer:
|
||||
mininterval=1,
|
||||
) as self.progress_bar:
|
||||
async for chunk in self.response.aiter_bytes():
|
||||
if is_first_chunk:
|
||||
is_first_chunk = False
|
||||
chunk = self._strip_fake_png_wrapper(chunk)
|
||||
|
||||
yield chunk
|
||||
chunk_size = len(chunk)
|
||||
self.bytes_transferred += chunk_size
|
||||
self.progress_bar.set_postfix_str(
|
||||
f"📥 : {self.format_bytes(self.bytes_transferred)}", refresh=False
|
||||
)
|
||||
self.progress_bar.update(chunk_size)
|
||||
self.bytes_transferred += len(chunk)
|
||||
self.progress_bar.update(len(chunk))
|
||||
else:
|
||||
async for chunk in self.response.aiter_bytes():
|
||||
if is_first_chunk:
|
||||
is_first_chunk = False
|
||||
chunk = self._strip_fake_png_wrapper(chunk)
|
||||
|
||||
yield chunk
|
||||
self.bytes_transferred += len(chunk)
|
||||
|
||||
@@ -187,10 +230,19 @@ class Streamer:
|
||||
raise DownloadError(502, f"Protocol error while streaming: {e}")
|
||||
except GeneratorExit:
|
||||
logger.info("Streaming session stopped by the user")
|
||||
except httpx.ReadError as e:
|
||||
# Handle network read errors gracefully - these occur when upstream connection drops
|
||||
logger.warning(f"ReadError while streaming: {e}")
|
||||
if self.bytes_transferred > 0:
|
||||
logger.info(f"Partial content received ({self.bytes_transferred} bytes) before ReadError. Graceful termination.")
|
||||
return
|
||||
else:
|
||||
raise DownloadError(502, f"ReadError while streaming: {e}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error streaming content: {e}")
|
||||
raise
|
||||
|
||||
|
||||
@staticmethod
|
||||
def format_bytes(size) -> str:
|
||||
power = 2**10
|
||||
@@ -490,6 +542,23 @@ def get_proxy_headers(request: Request) -> ProxyRequestHeaders:
|
||||
"""
|
||||
request_headers = {k: v for k, v in request.headers.items() if k in SUPPORTED_REQUEST_HEADERS}
|
||||
request_headers.update({k[2:].lower(): v for k, v in request.query_params.items() if k.startswith("h_")})
|
||||
request_headers.setdefault("user-agent", settings.user_agent)
|
||||
|
||||
# Handle common misspelling of referer
|
||||
if "referrer" in request_headers:
|
||||
if "referer" not in request_headers:
|
||||
request_headers["referer"] = request_headers.pop("referrer")
|
||||
|
||||
dest = request.query_params.get("d", "")
|
||||
host = urlparse(dest).netloc.lower()
|
||||
|
||||
if "vidoza" in host or "videzz" in host:
|
||||
# Remove ALL empty headers
|
||||
for h in list(request_headers.keys()):
|
||||
v = request_headers[h]
|
||||
if v is None or v.strip() == "":
|
||||
request_headers.pop(h, None)
|
||||
|
||||
response_headers = {k[2:].lower(): v for k, v in request.query_params.items() if k.startswith("r_")}
|
||||
return ProxyRequestHeaders(request_headers, response_headers)
|
||||
|
||||
@@ -527,21 +596,14 @@ class EnhancedStreamingResponse(Response):
|
||||
logger.error(f"Error in listen_for_disconnect: {str(e)}")
|
||||
|
||||
async def stream_response(self, send: Send) -> None:
|
||||
# Track if response headers have been sent to prevent duplicate headers
|
||||
response_started = False
|
||||
# Track if response finalization (more_body: False) has been sent to prevent ASGI protocol violation
|
||||
finalization_sent = False
|
||||
try:
|
||||
# Initialize headers
|
||||
headers = list(self.raw_headers)
|
||||
|
||||
# Set the transfer-encoding to chunked for streamed responses with content-length
|
||||
# when content-length is present. This ensures we don't hit protocol errors
|
||||
# if the upstream connection is closed prematurely.
|
||||
for i, (name, _) in enumerate(headers):
|
||||
if name.lower() == b"content-length":
|
||||
# Replace content-length with transfer-encoding: chunked for streaming
|
||||
headers[i] = (b"transfer-encoding", b"chunked")
|
||||
headers = [h for h in headers if h[0].lower() != b"content-length"]
|
||||
logger.debug("Switched from content-length to chunked transfer-encoding for streaming")
|
||||
break
|
||||
|
||||
# Start the response
|
||||
await send(
|
||||
{
|
||||
@@ -550,6 +612,7 @@ class EnhancedStreamingResponse(Response):
|
||||
"headers": headers,
|
||||
}
|
||||
)
|
||||
response_started = True
|
||||
|
||||
# Track if we've sent any data
|
||||
data_sent = False
|
||||
@@ -568,27 +631,29 @@ class EnhancedStreamingResponse(Response):
|
||||
|
||||
# Successfully streamed all content
|
||||
await send({"type": "http.response.body", "body": b"", "more_body": False})
|
||||
except (httpx.RemoteProtocolError, h11._util.LocalProtocolError) as e:
|
||||
# Handle connection closed errors
|
||||
finalization_sent = True
|
||||
except (httpx.RemoteProtocolError, httpx.ReadError, h11._util.LocalProtocolError) as e:
|
||||
# Handle connection closed / read errors gracefully
|
||||
if data_sent:
|
||||
# We've sent some data to the client, so try to complete the response
|
||||
logger.warning(f"Remote protocol error after partial streaming: {e}")
|
||||
logger.warning(f"Upstream connection error after partial streaming: {e}")
|
||||
try:
|
||||
await send({"type": "http.response.body", "body": b"", "more_body": False})
|
||||
finalization_sent = True
|
||||
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}")
|
||||
logger.warning(f"Could not finalize response after upstream error: {close_err}")
|
||||
else:
|
||||
# No data was sent, re-raise the error
|
||||
logger.error(f"Protocol error before any data was streamed: {e}")
|
||||
logger.error(f"Upstream error before any data was streamed: {e}")
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception(f"Error in stream_response: {str(e)}")
|
||||
if not isinstance(e, (ConnectionResetError, anyio.BrokenResourceError)):
|
||||
if not isinstance(e, (ConnectionResetError, anyio.BrokenResourceError)) and not response_started:
|
||||
# Only attempt to send error response if headers haven't been sent yet
|
||||
try:
|
||||
# Try to send an error response if client is still connected
|
||||
await send(
|
||||
{
|
||||
"type": "http.response.start",
|
||||
@@ -598,36 +663,39 @@ class EnhancedStreamingResponse(Response):
|
||||
)
|
||||
error_message = f"Streaming error: {str(e)}".encode("utf-8")
|
||||
await send({"type": "http.response.body", "body": error_message, "more_body": False})
|
||||
finalization_sent = True
|
||||
except Exception:
|
||||
# If we can't send an error response, just log it
|
||||
pass
|
||||
elif response_started and not finalization_sent:
|
||||
# Response already started but not finalized - gracefully close the stream
|
||||
try:
|
||||
await send({"type": "http.response.body", "body": b"", "more_body": False})
|
||||
finalization_sent = True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||
async with anyio.create_task_group() as task_group:
|
||||
streaming_completed = False
|
||||
stream_func = partial(self.stream_response, send)
|
||||
listen_func = partial(self.listen_for_disconnect, receive)
|
||||
|
||||
async def wrap(func: typing.Callable[[], typing.Awaitable[None]]) -> None:
|
||||
try:
|
||||
await func()
|
||||
# If this is the stream_response function and it completes successfully, mark as done
|
||||
if func == stream_func:
|
||||
nonlocal streaming_completed
|
||||
streaming_completed = True
|
||||
except Exception as e:
|
||||
if isinstance(e, (httpx.RemoteProtocolError, h11._util.LocalProtocolError)):
|
||||
# Handle protocol errors more gracefully
|
||||
logger.warning(f"Protocol error during streaming: {e}")
|
||||
elif not isinstance(e, anyio.get_cancelled_exc_class()):
|
||||
logger.exception("Error in streaming task")
|
||||
# Only re-raise if it's not a protocol error or cancellation
|
||||
# Note: stream_response and listen_for_disconnect handle their own exceptions
|
||||
# internally. This is a safety net for any unexpected exceptions that might
|
||||
# escape due to future code changes.
|
||||
if not isinstance(e, anyio.get_cancelled_exc_class()):
|
||||
logger.exception(f"Unexpected error in streaming task: {type(e).__name__}: {e}")
|
||||
# Re-raise unexpected errors to surface bugs rather than silently swallowing them
|
||||
raise
|
||||
finally:
|
||||
# Only cancel the task group if we're in disconnect listener or
|
||||
# if streaming_completed is True (meaning we finished normally)
|
||||
if func == listen_func or streaming_completed:
|
||||
task_group.cancel_scope.cancel()
|
||||
# Cancel task group when either task completes or fails:
|
||||
# - stream_func finished (success or failure) -> stop listening for disconnect
|
||||
# - listen_func finished (client disconnected) -> stop streaming
|
||||
task_group.cancel_scope.cancel()
|
||||
|
||||
# Start the streaming response in a separate task
|
||||
task_group.start_soon(wrap, stream_func)
|
||||
|
||||
@@ -11,7 +11,7 @@ from mediaflow_proxy.utils.hls_prebuffer import hls_prebuffer
|
||||
|
||||
|
||||
class M3U8Processor:
|
||||
def __init__(self, request, key_url: str = None, force_playlist_proxy: bool = None):
|
||||
def __init__(self, request, key_url: str = None, force_playlist_proxy: bool = None, key_only_proxy: bool = False, no_proxy: bool = False):
|
||||
"""
|
||||
Initializes the M3U8Processor with the request and URL prefix.
|
||||
|
||||
@@ -19,9 +19,13 @@ class M3U8Processor:
|
||||
request (Request): The incoming HTTP request.
|
||||
key_url (HttpUrl, optional): The URL of the key server. Defaults to None.
|
||||
force_playlist_proxy (bool, optional): Force all playlist URLs to be proxied through MediaFlow. Defaults to None.
|
||||
key_only_proxy (bool, optional): Only proxy the key URL, leaving segment URLs direct. Defaults to False.
|
||||
no_proxy (bool, optional): If True, returns the manifest without proxying any URLs. Defaults to False.
|
||||
"""
|
||||
self.request = request
|
||||
self.key_url = parse.urlparse(key_url) if key_url else None
|
||||
self.key_only_proxy = key_only_proxy
|
||||
self.no_proxy = no_proxy
|
||||
self.force_playlist_proxy = force_playlist_proxy
|
||||
self.mediaflow_proxy_url = str(
|
||||
request.url_for("hls_manifest_proxy").replace(scheme=get_original_scheme(request))
|
||||
@@ -174,6 +178,15 @@ class M3U8Processor:
|
||||
Returns:
|
||||
str: The processed key line.
|
||||
"""
|
||||
# If no_proxy is enabled, just resolve relative URLs without proxying
|
||||
if self.no_proxy:
|
||||
uri_match = re.search(r'URI="([^"]+)"', line)
|
||||
if uri_match:
|
||||
original_uri = uri_match.group(1)
|
||||
full_url = parse.urljoin(base_url, original_uri)
|
||||
line = line.replace(f'URI="{original_uri}"', f'URI="{full_url}"')
|
||||
return line
|
||||
|
||||
uri_match = re.search(r'URI="([^"]+)"', line)
|
||||
if uri_match:
|
||||
original_uri = uri_match.group(1)
|
||||
@@ -197,6 +210,14 @@ class M3U8Processor:
|
||||
"""
|
||||
full_url = parse.urljoin(base_url, url)
|
||||
|
||||
# If no_proxy is enabled, return the direct URL without any proxying
|
||||
if self.no_proxy:
|
||||
return full_url
|
||||
|
||||
# If key_only_proxy is enabled, return the direct URL for segments
|
||||
if self.key_only_proxy and not url.endswith((".m3u", ".m3u8")):
|
||||
return full_url
|
||||
|
||||
# Determine routing strategy based on configuration
|
||||
routing_strategy = settings.m3u8_content_routing
|
||||
|
||||
|
||||
@@ -270,7 +270,7 @@ def parse_representation(
|
||||
if item:
|
||||
profile["segments"] = parse_segment_template(parsed_dict, item, profile, source)
|
||||
else:
|
||||
profile["segments"] = parse_segment_base(representation, source)
|
||||
profile["segments"] = parse_segment_base(representation, profile, source)
|
||||
|
||||
return profile
|
||||
|
||||
@@ -547,7 +547,7 @@ def create_segment_data(segment: Dict, item: dict, profile: dict, source: str, t
|
||||
return segment_data
|
||||
|
||||
|
||||
def parse_segment_base(representation: dict, source: str) -> List[Dict]:
|
||||
def parse_segment_base(representation: dict, profile: dict, source: str) -> List[Dict]:
|
||||
"""
|
||||
Parses segment base information and extracts segment data. This is used for single-segment representations.
|
||||
|
||||
@@ -562,6 +562,12 @@ def parse_segment_base(representation: dict, source: str) -> List[Dict]:
|
||||
start, end = map(int, segment["@indexRange"].split("-"))
|
||||
if "Initialization" in segment:
|
||||
start, _ = map(int, segment["Initialization"]["@range"].split("-"))
|
||||
|
||||
# Set initUrl for SegmentBase
|
||||
if not representation['BaseURL'].startswith("http"):
|
||||
profile["initUrl"] = f"{source}/{representation['BaseURL']}"
|
||||
else:
|
||||
profile["initUrl"] = representation['BaseURL']
|
||||
|
||||
return [
|
||||
{
|
||||
|
||||
122
mediaflow_proxy/utils/python_aes.py
Normal file
122
mediaflow_proxy/utils/python_aes.py
Normal file
@@ -0,0 +1,122 @@
|
||||
# Author: Trevor Perrin
|
||||
# See the LICENSE file for legal information regarding use of this file.
|
||||
|
||||
"""Pure-Python AES implementation."""
|
||||
|
||||
import sys
|
||||
from .aes import AES
|
||||
from .rijndael import Rijndael
|
||||
from .cryptomath import bytesToNumber, numberToByteArray
|
||||
|
||||
__all__ = ['new', 'Python_AES']
|
||||
|
||||
|
||||
def new(key, mode, IV):
|
||||
# IV argument name is a part of the interface
|
||||
# pylint: disable=invalid-name
|
||||
if mode == 2:
|
||||
return Python_AES(key, mode, IV)
|
||||
elif mode == 6:
|
||||
return Python_AES_CTR(key, mode, IV)
|
||||
else:
|
||||
raise NotImplementedError()
|
||||
|
||||
|
||||
class Python_AES(AES):
|
||||
def __init__(self, key, mode, IV):
|
||||
# IV argument/field names are a part of the interface
|
||||
# pylint: disable=invalid-name
|
||||
key, IV = bytearray(key), bytearray(IV)
|
||||
super(Python_AES, self).__init__(key, mode, IV, "python")
|
||||
self.rijndael = Rijndael(key, 16)
|
||||
self.IV = IV
|
||||
|
||||
def encrypt(self, plaintext):
|
||||
super(Python_AES, self).encrypt(plaintext)
|
||||
|
||||
plaintextBytes = bytearray(plaintext)
|
||||
chainBytes = self.IV[:]
|
||||
|
||||
#CBC Mode: For each block...
|
||||
for x in range(len(plaintextBytes)//16):
|
||||
|
||||
#XOR with the chaining block
|
||||
blockBytes = plaintextBytes[x*16 : (x*16)+16]
|
||||
for y in range(16):
|
||||
blockBytes[y] ^= chainBytes[y]
|
||||
|
||||
#Encrypt it
|
||||
encryptedBytes = self.rijndael.encrypt(blockBytes)
|
||||
|
||||
#Overwrite the input with the output
|
||||
for y in range(16):
|
||||
plaintextBytes[(x*16)+y] = encryptedBytes[y]
|
||||
|
||||
#Set the next chaining block
|
||||
chainBytes = encryptedBytes
|
||||
|
||||
self.IV = chainBytes[:]
|
||||
return plaintextBytes
|
||||
|
||||
def decrypt(self, ciphertext):
|
||||
super(Python_AES, self).decrypt(ciphertext)
|
||||
|
||||
ciphertextBytes = ciphertext[:]
|
||||
chainBytes = self.IV[:]
|
||||
|
||||
#CBC Mode: For each block...
|
||||
for x in range(len(ciphertextBytes)//16):
|
||||
|
||||
#Decrypt it
|
||||
blockBytes = ciphertextBytes[x*16 : (x*16)+16]
|
||||
decryptedBytes = self.rijndael.decrypt(blockBytes)
|
||||
|
||||
#XOR with the chaining block and overwrite the input with output
|
||||
for y in range(16):
|
||||
decryptedBytes[y] ^= chainBytes[y]
|
||||
ciphertextBytes[(x*16)+y] = decryptedBytes[y]
|
||||
|
||||
#Set the next chaining block
|
||||
chainBytes = blockBytes
|
||||
|
||||
self.IV = chainBytes[:]
|
||||
return ciphertextBytes
|
||||
|
||||
|
||||
class Python_AES_CTR(AES):
|
||||
def __init__(self, key, mode, IV):
|
||||
super(Python_AES_CTR, self).__init__(key, mode, IV, "python")
|
||||
self.rijndael = Rijndael(key, 16)
|
||||
self.IV = IV
|
||||
self._counter_bytes = 16 - len(self.IV)
|
||||
self._counter = self.IV + bytearray(b'\x00' * self._counter_bytes)
|
||||
|
||||
@property
|
||||
def counter(self):
|
||||
return self._counter
|
||||
|
||||
@counter.setter
|
||||
def counter(self, ctr):
|
||||
self._counter = ctr
|
||||
|
||||
def _counter_update(self):
|
||||
counter_int = bytesToNumber(self._counter) + 1
|
||||
self._counter = numberToByteArray(counter_int, 16)
|
||||
if self._counter_bytes > 0 and \
|
||||
self._counter[-self._counter_bytes:] == \
|
||||
bytearray(b'\xff' * self._counter_bytes):
|
||||
raise OverflowError("CTR counter overflowed")
|
||||
|
||||
def encrypt(self, plaintext):
|
||||
mask = bytearray()
|
||||
while len(mask) < len(plaintext):
|
||||
mask += self.rijndael.encrypt(self._counter)
|
||||
self._counter_update()
|
||||
if sys.version_info < (3, 0):
|
||||
inp_bytes = bytearray(ord(i) ^ j for i, j in zip(plaintext, mask))
|
||||
else:
|
||||
inp_bytes = bytearray(i ^ j for i, j in zip(plaintext, mask))
|
||||
return inp_bytes
|
||||
|
||||
def decrypt(self, ciphertext):
|
||||
return self.encrypt(ciphertext)
|
||||
12
mediaflow_proxy/utils/python_aesgcm.py
Normal file
12
mediaflow_proxy/utils/python_aesgcm.py
Normal file
@@ -0,0 +1,12 @@
|
||||
# mediaflow_proxy/utils/python_aesgcm.py
|
||||
|
||||
from .aesgcm import AESGCM
|
||||
from .rijndael import Rijndael
|
||||
|
||||
|
||||
def new(key: bytes) -> AESGCM:
|
||||
"""
|
||||
Mirror ResolveURL's python_aesgcm.new(key) API:
|
||||
returns an AESGCM instance with pure-Python Rijndael backend.
|
||||
"""
|
||||
return AESGCM(key, "python", Rijndael(key, 16).encrypt)
|
||||
1118
mediaflow_proxy/utils/rijndael.py
Normal file
1118
mediaflow_proxy/utils/rijndael.py
Normal file
File diff suppressed because it is too large
Load Diff
32
mediaflow_proxy/utils/tlshashlib.py
Normal file
32
mediaflow_proxy/utils/tlshashlib.py
Normal file
@@ -0,0 +1,32 @@
|
||||
# Author: Hubert Kario (c) 2015
|
||||
# see LICENCE file for legal information regarding use of this file
|
||||
|
||||
"""hashlib that handles FIPS mode."""
|
||||
|
||||
# Because we are extending the hashlib module, we need to import all its
|
||||
# fields to suppport the same uses
|
||||
# pylint: disable=unused-wildcard-import, wildcard-import
|
||||
from hashlib import *
|
||||
# pylint: enable=unused-wildcard-import, wildcard-import
|
||||
import hashlib
|
||||
|
||||
|
||||
def _fipsFunction(func, *args, **kwargs):
|
||||
"""Make hash function support FIPS mode."""
|
||||
try:
|
||||
return func(*args, **kwargs)
|
||||
except ValueError:
|
||||
return func(*args, usedforsecurity=False, **kwargs)
|
||||
|
||||
|
||||
# redefining the function is exactly what we intend to do
|
||||
# pylint: disable=function-redefined
|
||||
def md5(*args, **kwargs):
|
||||
"""MD5 constructor that works in FIPS mode."""
|
||||
return _fipsFunction(hashlib.md5, *args, **kwargs)
|
||||
|
||||
|
||||
def new(*args, **kwargs):
|
||||
"""General constructor that works in FIPS mode."""
|
||||
return _fipsFunction(hashlib.new, *args, **kwargs)
|
||||
# pylint: enable=function-redefined
|
||||
88
mediaflow_proxy/utils/tlshmac.py
Normal file
88
mediaflow_proxy/utils/tlshmac.py
Normal file
@@ -0,0 +1,88 @@
|
||||
# Author: Hubert Kario (c) 2019
|
||||
# see LICENCE file for legal information regarding use of this file
|
||||
|
||||
"""
|
||||
HMAC module that works in FIPS mode.
|
||||
|
||||
Note that this makes this code FIPS non-compliant!
|
||||
"""
|
||||
|
||||
# Because we are extending the hashlib module, we need to import all its
|
||||
# fields to suppport the same uses
|
||||
from . import tlshashlib
|
||||
from .compat import compatHMAC
|
||||
try:
|
||||
from hmac import compare_digest
|
||||
__all__ = ["new", "compare_digest", "HMAC"]
|
||||
except ImportError:
|
||||
__all__ = ["new", "HMAC"]
|
||||
|
||||
try:
|
||||
from hmac import HMAC, new
|
||||
# if we can calculate HMAC on MD5, then use the built-in HMAC
|
||||
# implementation
|
||||
_val = HMAC(b'some key', b'msg', 'md5')
|
||||
_val.digest()
|
||||
del _val
|
||||
except Exception:
|
||||
# fallback only when MD5 doesn't work
|
||||
class HMAC(object):
|
||||
"""Hacked version of HMAC that works in FIPS mode even with MD5."""
|
||||
|
||||
def __init__(self, key, msg=None, digestmod=None):
|
||||
"""
|
||||
Initialise the HMAC and hash first portion of data.
|
||||
|
||||
msg: data to hash
|
||||
digestmod: name of hash or object that be used as a hash and be cloned
|
||||
"""
|
||||
self.key = key
|
||||
if digestmod is None:
|
||||
digestmod = 'md5'
|
||||
if callable(digestmod):
|
||||
digestmod = digestmod()
|
||||
if not hasattr(digestmod, 'digest_size'):
|
||||
digestmod = tlshashlib.new(digestmod)
|
||||
self.block_size = digestmod.block_size
|
||||
self.digest_size = digestmod.digest_size
|
||||
self.digestmod = digestmod
|
||||
if len(key) > self.block_size:
|
||||
k_hash = digestmod.copy()
|
||||
k_hash.update(compatHMAC(key))
|
||||
key = k_hash.digest()
|
||||
if len(key) < self.block_size:
|
||||
key = key + b'\x00' * (self.block_size - len(key))
|
||||
key = bytearray(key)
|
||||
ipad = bytearray(b'\x36' * self.block_size)
|
||||
opad = bytearray(b'\x5c' * self.block_size)
|
||||
i_key = bytearray(i ^ j for i, j in zip(key, ipad))
|
||||
self._o_key = bytearray(i ^ j for i, j in zip(key, opad))
|
||||
self._context = digestmod.copy()
|
||||
self._context.update(compatHMAC(i_key))
|
||||
if msg:
|
||||
self._context.update(compatHMAC(msg))
|
||||
|
||||
def update(self, msg):
|
||||
self._context.update(compatHMAC(msg))
|
||||
|
||||
def digest(self):
|
||||
i_digest = self._context.digest()
|
||||
o_hash = self.digestmod.copy()
|
||||
o_hash.update(compatHMAC(self._o_key))
|
||||
o_hash.update(compatHMAC(i_digest))
|
||||
return o_hash.digest()
|
||||
|
||||
def copy(self):
|
||||
new = HMAC.__new__(HMAC)
|
||||
new.key = self.key
|
||||
new.digestmod = self.digestmod
|
||||
new.block_size = self.block_size
|
||||
new.digest_size = self.digest_size
|
||||
new._o_key = self._o_key
|
||||
new._context = self._context.copy()
|
||||
return new
|
||||
|
||||
|
||||
def new(*args, **kwargs):
|
||||
"""General constructor that works in FIPS mode."""
|
||||
return HMAC(*args, **kwargs)
|
||||
Reference in New Issue
Block a user