mirror of
https://github.com/UrloMythus/UnHided.git
synced 2026-04-11 03:40:54 +00:00
new version
This commit is contained in:
@@ -19,11 +19,14 @@ class TransportConfig(BaseSettings):
|
|||||||
proxy_url: Optional[str] = Field(
|
proxy_url: Optional[str] = Field(
|
||||||
None, description="Primary proxy URL. Example: socks5://user:pass@proxy:1080 or http://proxy:8080"
|
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")
|
all_proxy: bool = Field(False, description="Enable proxy for all routes by default")
|
||||||
transport_routes: Dict[str, RouteConfig] = Field(
|
transport_routes: Dict[str, RouteConfig] = Field(
|
||||||
default_factory=dict, description="Pattern-based route configuration"
|
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(
|
def get_mounts(
|
||||||
self, async_http: bool = True
|
self, async_http: bool = True
|
||||||
@@ -33,11 +36,13 @@ class TransportConfig(BaseSettings):
|
|||||||
"""
|
"""
|
||||||
mounts = {}
|
mounts = {}
|
||||||
transport_cls = httpx.AsyncHTTPTransport if async_http else httpx.HTTPTransport
|
transport_cls = httpx.AsyncHTTPTransport if async_http else httpx.HTTPTransport
|
||||||
|
global_verify = not self.disable_ssl_verification_globally
|
||||||
|
|
||||||
# Configure specific routes
|
# Configure specific routes
|
||||||
for pattern, route in self.transport_routes.items():
|
for pattern, route in self.transport_routes.items():
|
||||||
mounts[pattern] = transport_cls(
|
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
|
# 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
|
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
|
# Set default proxy for all routes if enabled
|
||||||
if self.all_proxy:
|
# This part is now handled above to combine proxy and SSL settings
|
||||||
mounts["all://"] = transport_cls(proxy=self.proxy_url)
|
# if self.all_proxy:
|
||||||
|
# mounts["all://"] = transport_cls(proxy=self.proxy_url)
|
||||||
|
|
||||||
return mounts
|
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_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_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.
|
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 = (
|
user_agent: str = (
|
||||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36" # The user agent to use for HTTP requests.
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36" # The user agent to use for HTTP requests.
|
||||||
|
|||||||
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,49 +1,122 @@
|
|||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from typing import Dict, Optional, Any
|
from typing import Dict, Optional, Any
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import httpx
|
import httpx
|
||||||
|
import logging
|
||||||
|
|
||||||
from mediaflow_proxy.configs import settings
|
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):
|
class ExtractorError(Exception):
|
||||||
"""Base exception for all extractors."""
|
"""Base exception for all extractors."""
|
||||||
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class BaseExtractor(ABC):
|
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):
|
def __init__(self, request_headers: dict):
|
||||||
self.base_headers = {
|
self.base_headers = {
|
||||||
"user-agent": settings.user_agent,
|
"user-agent": settings.user_agent,
|
||||||
}
|
}
|
||||||
self.mediaflow_endpoint = "proxy_stream_endpoint"
|
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(
|
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:
|
) -> httpx.Response:
|
||||||
"""Make HTTP request with error handling."""
|
"""
|
||||||
try:
|
Make HTTP request with retry and timeout support.
|
||||||
async with create_httpx_client() as client:
|
|
||||||
|
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()
|
request_headers = self.base_headers.copy()
|
||||||
request_headers.update(headers or {})
|
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(
|
response = await client.request(
|
||||||
method,
|
method,
|
||||||
url,
|
url,
|
||||||
headers=request_headers,
|
headers=request_headers,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if raise_on_status:
|
||||||
|
try:
|
||||||
response.raise_for_status()
|
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
|
return response
|
||||||
except httpx.HTTPError as e:
|
|
||||||
raise ExtractorError(f"HTTP request failed for URL {url}: {str(e)}")
|
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:
|
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)}")
|
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
|
@abstractmethod
|
||||||
async def extract(self, url: str, **kwargs) -> Dict[str, Any]:
|
async def extract(self, url: str, **kwargs) -> Dict[str, Any]:
|
||||||
"""Extract final URL and required headers."""
|
"""Extract final URL and required headers."""
|
||||||
|
|||||||
@@ -1,543 +1,332 @@
|
|||||||
import re
|
import re
|
||||||
import base64
|
import base64
|
||||||
import logging
|
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
|
from mediaflow_proxy.extractors.base import BaseExtractor, ExtractorError
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Silenzia l'errore ConnectionResetError su Windows
|
||||||
|
logging.getLogger('asyncio').setLevel(logging.CRITICAL)
|
||||||
|
|
||||||
|
|
||||||
class DLHDExtractor(BaseExtractor):
|
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):
|
def __init__(self, request_headers: dict):
|
||||||
super().__init__(request_headers)
|
super().__init__(request_headers)
|
||||||
# Default to HLS proxy endpoint
|
|
||||||
self.mediaflow_endpoint = "hls_manifest_proxy"
|
self.mediaflow_endpoint = "hls_manifest_proxy"
|
||||||
# Cache for the resolved base URL to avoid repeated network calls
|
self._iframe_context: Optional[str] = None
|
||||||
self._cached_base_url = None
|
|
||||||
# Store iframe context for newkso.ru requests
|
|
||||||
self._iframe_context = 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)
|
async def _make_request(self, url: str, method: str = "GET", headers: Optional[Dict] = None, **kwargs) -> Any:
|
||||||
if "newkso.ru" in parsed_url.netloc:
|
"""Override to disable SSL verification for this extractor and use fetch_with_retry if available."""
|
||||||
# Use iframe URL as referer if available, otherwise use the newkso domain itself
|
from mediaflow_proxy.utils.http_utils import create_httpx_client, fetch_with_retry
|
||||||
if self._iframe_context:
|
|
||||||
iframe_origin = f"https://{urlparse(self._iframe_context).netloc}"
|
|
||||||
newkso_headers = {
|
timeout = kwargs.pop("timeout", 15)
|
||||||
'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',
|
retries = kwargs.pop("retries", 3)
|
||||||
'Referer': self._iframe_context,
|
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]*["\']([^"\']+)["\']',
|
||||||
|
]
|
||||||
|
|
||||||
|
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
|
'Origin': iframe_origin
|
||||||
}
|
}
|
||||||
logger.info(f"Applied newkso.ru specific headers with iframe context for URL: {url}")
|
|
||||||
logger.debug(f"Headers applied: {newkso_headers}")
|
# Determina endpoint in base al dominio dello stream
|
||||||
else:
|
endpoint = "hls_key_proxy"
|
||||||
# Fallback to newkso domain itself
|
|
||||||
newkso_origin = f"{parsed_url.scheme}://{parsed_url.netloc}"
|
logger.info(f"Using lovecdn.ru stream with endpoint: {endpoint}")
|
||||||
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',
|
return {
|
||||||
'Referer': newkso_origin,
|
"destination_url": stream_url,
|
||||||
'Origin': newkso_origin
|
"request_headers": stream_headers,
|
||||||
|
"mediaflow_endpoint": endpoint,
|
||||||
}
|
}
|
||||||
logger.info(f"Applied newkso.ru specific headers (fallback) for URL: {url}")
|
|
||||||
logger.debug(f"Headers applied: {newkso_headers}")
|
|
||||||
|
|
||||||
headers.update(newkso_headers)
|
except Exception as e:
|
||||||
|
raise ExtractorError(f"Failed to extract lovecdn.ru stream: {e}")
|
||||||
|
|
||||||
return headers
|
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."""
|
||||||
|
|
||||||
async def _make_request(self, url: str, method: str = "GET", headers: dict = None, **kwargs):
|
def _extract_params(js: str) -> Dict[str, Optional[str]]:
|
||||||
"""Override _make_request to apply newkso.ru specific headers when needed."""
|
params = {}
|
||||||
request_headers = headers or {}
|
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
|
||||||
|
|
||||||
# Apply newkso.ru specific headers if the URL contains newkso.ru
|
params = _extract_params(iframe_content)
|
||||||
final_headers = self._get_headers_for_url(url, request_headers)
|
|
||||||
|
|
||||||
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]:
|
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)."""
|
"""Main extraction flow: resolve base, fetch players, extract iframe, auth and final m3u8."""
|
||||||
from urllib.parse import urlparse, quote_plus
|
baseurl = "https://dlhd.dad/"
|
||||||
|
|
||||||
async def get_daddylive_base_url():
|
def extract_channel_id(u: str) -> Optional[str]:
|
||||||
if self._cached_base_url:
|
match_watch_id = re.search(r'watch\.php\?id=(\d+)', u)
|
||||||
return self._cached_base_url
|
if match_watch_id:
|
||||||
try:
|
return match_watch_id.group(1)
|
||||||
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)
|
|
||||||
return None
|
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
|
daddy_origin = urlparse(baseurl).scheme + "://" + urlparse(baseurl).netloc
|
||||||
daddylive_headers = {
|
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',
|
'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,
|
'Referer': baseurl,
|
||||||
'Origin': daddy_origin
|
'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
|
# 1. Request initial page
|
||||||
iframes = re.findall(r'<a[^>]*href="([^"]+)"[^>]*>\s*<button[^>]*>\s*Player\s*2\s*</button>', resp1.text)
|
resp1 = await self._make_request(initial_url, headers=daddylive_headers, timeout=15)
|
||||||
if not iframes:
|
player_links = re.findall(r'<button[^>]*data-url="([^"]+)"[^>]*>Player\s*\d+</button>', resp1.text)
|
||||||
raise ExtractorError("No Player 2 link found")
|
if not player_links:
|
||||||
url2 = iframes[0]
|
raise ExtractorError("No player links found on the page.")
|
||||||
url2 = baseurl + url2
|
|
||||||
url2 = url2.replace('//cast', '/cast')
|
|
||||||
daddylive_headers['Referer'] = url2
|
# Prova tutti i player e raccogli tutti gli iframe validi
|
||||||
daddylive_headers['Origin'] = url2
|
last_player_error = None
|
||||||
# 3. Richiesta alla pagina Player 2
|
iframe_candidates = []
|
||||||
resp2 = await self._make_request(url2, headers=daddylive_headers)
|
|
||||||
# 4. Estrai iframe
|
for player_url in player_links:
|
||||||
iframes2 = re.findall(r'iframe src="([^"]*)', resp2.text)
|
try:
|
||||||
if not iframes2:
|
if not player_url.startswith('http'):
|
||||||
raise ExtractorError("No iframe found in Player 2 page")
|
player_url = baseurl + player_url.lstrip('/')
|
||||||
iframe_url = iframes2[0]
|
|
||||||
# Store iframe context for newkso.ru requests
|
|
||||||
self._iframe_context = iframe_url
|
daddylive_headers['Referer'] = player_url
|
||||||
resp3 = await self._make_request(iframe_url, headers=daddylive_headers)
|
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:
|
||||||
|
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
|
iframe_content = resp3.text
|
||||||
# 5. Estrai parametri auth (robusto) - Handle both old and new formats
|
logger.info(f"Successfully loaded iframe from: {iframe_domain}")
|
||||||
def extract_var_old_format(js, name):
|
|
||||||
# Try multiple patterns for variable extraction (old format)
|
if 'lovecdn.ru' in iframe_domain:
|
||||||
patterns = [
|
logger.info("Detected lovecdn.ru iframe - using alternative extraction")
|
||||||
rf'var (?:__)?{name}\s*=\s*atob\("([^"]+)"\)',
|
return await self._extract_lovecdn_stream(iframe_candidate, iframe_content, daddylive_headers)
|
||||||
rf'var (?:__)?{name}\s*=\s*atob\(\'([^\']+)\'\)',
|
else:
|
||||||
rf'(?:var\s+)?(?:__)?{name}\s*=\s*atob\s*\(\s*["\']([^"\']+)["\']\s*\)',
|
logger.info("Attempting new auth flow extraction.")
|
||||||
rf'(?:let|const)\s+(?:__)?{name}\s*=\s*atob\s*\(\s*["\']([^"\']+)["\']\s*\)'
|
return await self._extract_new_auth_flow(iframe_candidate, iframe_content, daddylive_headers)
|
||||||
]
|
|
||||||
for pattern in patterns:
|
except Exception as e:
|
||||||
m = re.search(pattern, js)
|
logger.warning(f"Failed to process iframe {iframe_candidate}: {e}")
|
||||||
if m:
|
last_iframe_error = e
|
||||||
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
|
continue
|
||||||
return None
|
|
||||||
|
|
||||||
def extract_xjz_format(js):
|
raise ExtractorError(f"All iframe candidates failed. Last error: {last_iframe_error}")
|
||||||
"""Extract parameters from the new XJZ base64-encoded JSON format."""
|
|
||||||
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
|
|
||||||
|
|
||||||
def extract_bundle_format(js):
|
|
||||||
"""Extract parameters from new BUNDLE format (legacy fallback)."""
|
|
||||||
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
|
|
||||||
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')
|
|
||||||
|
|
||||||
# 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}")
|
|
||||||
|
|
||||||
# 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:
|
try:
|
||||||
lookup_resp = await self._make_request(server_lookup_url, headers=daddylive_headers)
|
channel_id = extract_channel_id(url)
|
||||||
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)
|
|
||||||
if not channel_id:
|
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:
|
except Exception as e:
|
||||||
raise ExtractorError(f"Extraction failed: {str(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.base import BaseExtractor, ExtractorError
|
||||||
from mediaflow_proxy.extractors.dlhd import DLHDExtractor
|
from mediaflow_proxy.extractors.dlhd import DLHDExtractor
|
||||||
from mediaflow_proxy.extractors.doodstream import DoodStreamExtractor
|
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.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.livetv import LiveTVExtractor
|
||||||
|
from mediaflow_proxy.extractors.lulustream import LuluStreamExtractor
|
||||||
from mediaflow_proxy.extractors.maxstream import MaxstreamExtractor
|
from mediaflow_proxy.extractors.maxstream import MaxstreamExtractor
|
||||||
from mediaflow_proxy.extractors.mixdrop import MixdropExtractor
|
from mediaflow_proxy.extractors.mixdrop import MixdropExtractor
|
||||||
from mediaflow_proxy.extractors.okru import OkruExtractor
|
from mediaflow_proxy.extractors.okru import OkruExtractor
|
||||||
from mediaflow_proxy.extractors.streamtape import StreamtapeExtractor
|
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.supervideo import SupervideoExtractor
|
||||||
|
from mediaflow_proxy.extractors.turbovidplay import TurboVidPlayExtractor
|
||||||
from mediaflow_proxy.extractors.uqload import UqloadExtractor
|
from mediaflow_proxy.extractors.uqload import UqloadExtractor
|
||||||
from mediaflow_proxy.extractors.vavoo import VavooExtractor
|
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.vixcloud import VixCloudExtractor
|
||||||
from mediaflow_proxy.extractors.fastream import FastreamExtractor
|
from mediaflow_proxy.extractors.fastream import FastreamExtractor
|
||||||
|
from mediaflow_proxy.extractors.voe import VoeExtractor
|
||||||
|
|
||||||
|
|
||||||
class ExtractorFactory:
|
class ExtractorFactory:
|
||||||
"""Factory for creating URL extractors."""
|
"""Factory for creating URL extractors."""
|
||||||
@@ -21,17 +31,26 @@ class ExtractorFactory:
|
|||||||
_extractors: Dict[str, Type[BaseExtractor]] = {
|
_extractors: Dict[str, Type[BaseExtractor]] = {
|
||||||
"Doodstream": DoodStreamExtractor,
|
"Doodstream": DoodStreamExtractor,
|
||||||
"FileLions": FileLionsExtractor,
|
"FileLions": FileLionsExtractor,
|
||||||
|
"FileMoon": FileMoonExtractor,
|
||||||
|
"F16Px": F16PxExtractor,
|
||||||
"Uqload": UqloadExtractor,
|
"Uqload": UqloadExtractor,
|
||||||
"Mixdrop": MixdropExtractor,
|
"Mixdrop": MixdropExtractor,
|
||||||
"Streamtape": StreamtapeExtractor,
|
"Streamtape": StreamtapeExtractor,
|
||||||
|
"StreamWish": StreamWishExtractor,
|
||||||
"Supervideo": SupervideoExtractor,
|
"Supervideo": SupervideoExtractor,
|
||||||
|
"TurboVidPlay": TurboVidPlayExtractor,
|
||||||
"VixCloud": VixCloudExtractor,
|
"VixCloud": VixCloudExtractor,
|
||||||
"Okru": OkruExtractor,
|
"Okru": OkruExtractor,
|
||||||
"Maxstream": MaxstreamExtractor,
|
"Maxstream": MaxstreamExtractor,
|
||||||
"LiveTV": LiveTVExtractor,
|
"LiveTV": LiveTVExtractor,
|
||||||
|
"LuluStream": LuluStreamExtractor,
|
||||||
"DLHD": DLHDExtractor,
|
"DLHD": DLHDExtractor,
|
||||||
"Vavoo": VavooExtractor,
|
"Vavoo": VavooExtractor,
|
||||||
"Fastream": FastreamExtractor
|
"Vidmoly": VidmolyExtractor,
|
||||||
|
"Vidoza": VidozaExtractor,
|
||||||
|
"Fastream": FastreamExtractor,
|
||||||
|
"Voe": VoeExtractor,
|
||||||
|
"Sportsonline": SportsonlineExtractor,
|
||||||
}
|
}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ class FileLionsExtractor(BaseExtractor):
|
|||||||
headers = {}
|
headers = {}
|
||||||
patterns = [ # See https://github.com/Gujal00/ResolveURL/blob/master/script.module.resolveurl/lib/resolveurl/plugins/filelions.py
|
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'''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)
|
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,20 +4,27 @@ from mediaflow_proxy.extractors.base import BaseExtractor, ExtractorError
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class VavooExtractor(BaseExtractor):
|
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):
|
def __init__(self, request_headers: dict):
|
||||||
super().__init__(request_headers)
|
super().__init__(request_headers)
|
||||||
self.mediaflow_endpoint = "proxy_stream_endpoint"
|
self.mediaflow_endpoint = "proxy_stream_endpoint"
|
||||||
|
|
||||||
async def get_auth_signature(self) -> Optional[str]:
|
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 = {
|
headers = {
|
||||||
"user-agent": "okhttp/4.11.0",
|
"user-agent": "okhttp/4.11.0",
|
||||||
"accept": "application/json",
|
"accept": "application/json",
|
||||||
"content-type": "application/json; charset=utf-8",
|
"content-type": "application/json; charset=utf-8",
|
||||||
"accept-encoding": "gzip"
|
"accept-encoding": "gzip",
|
||||||
}
|
}
|
||||||
import time
|
import time
|
||||||
current_time = int(time.time() * 1000)
|
current_time = int(time.time() * 1000)
|
||||||
@@ -37,23 +44,17 @@ class VavooExtractor(BaseExtractor):
|
|||||||
},
|
},
|
||||||
"os": {
|
"os": {
|
||||||
"name": "android",
|
"name": "android",
|
||||||
"version": "13",
|
"version": "13"
|
||||||
"abis": ["arm64-v8a", "armeabi-v7a", "armeabi"],
|
|
||||||
"host": "android"
|
|
||||||
},
|
},
|
||||||
"app": {
|
"app": {
|
||||||
"platform": "android",
|
"platform": "android",
|
||||||
"version": "3.1.21",
|
"version": "3.1.21"
|
||||||
"buildId": "289515000",
|
|
||||||
"engine": "hbc85",
|
|
||||||
"signatures": ["6e8a975e3cbf07d5de823a760d4c2547f86c1403105020adee5de67ac510999e"],
|
|
||||||
"installer": "app.revanced.manager.flutter"
|
|
||||||
},
|
},
|
||||||
"version": {
|
"version": {
|
||||||
"package": "tv.vavoo.app",
|
"package": "tv.vavoo.app",
|
||||||
"binary": "3.1.21",
|
"binary": "3.1.21",
|
||||||
"js": "3.1.21"
|
"js": "3.1.21"
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
"appFocusTime": 0,
|
"appFocusTime": 0,
|
||||||
"playerActive": False,
|
"playerActive": False,
|
||||||
@@ -86,38 +87,42 @@ class VavooExtractor(BaseExtractor):
|
|||||||
"https://www.vavoo.tv/api/app/ping",
|
"https://www.vavoo.tv/api/app/ping",
|
||||||
method="POST",
|
method="POST",
|
||||||
json=data,
|
json=data,
|
||||||
headers=headers
|
headers=headers,
|
||||||
|
timeout=10,
|
||||||
|
retries=2,
|
||||||
)
|
)
|
||||||
|
try:
|
||||||
result = resp.json()
|
result = resp.json()
|
||||||
addon_sig = result.get("addonSig")
|
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:
|
if addon_sig:
|
||||||
logger.info("Successfully obtained Vavoo authentication signature")
|
logger.info("Successfully obtained Vavoo authentication signature")
|
||||||
return addon_sig
|
return addon_sig
|
||||||
else:
|
else:
|
||||||
logger.warning("No addonSig in Vavoo API response")
|
logger.warning("No addonSig in Vavoo API response: %s", result)
|
||||||
return None
|
return None
|
||||||
except Exception as e:
|
except ExtractorError as e:
|
||||||
logger.exception(f"Failed to get Vavoo authentication signature: {str(e)}")
|
logger.warning("Failed to get Vavoo auth signature: %s", e)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def extract(self, url: str, **kwargs) -> Dict[str, Any]:
|
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:
|
if "vavoo.to" not in url:
|
||||||
raise ExtractorError("Not a valid Vavoo URL")
|
raise ExtractorError("Not a valid Vavoo URL")
|
||||||
|
|
||||||
# Get authentication signature
|
|
||||||
signature = await self.get_auth_signature()
|
signature = await self.get_auth_signature()
|
||||||
if not signature:
|
if not signature:
|
||||||
raise ExtractorError("Failed to get Vavoo authentication signature")
|
raise ExtractorError("Failed to get Vavoo authentication signature")
|
||||||
|
|
||||||
# Resolve the URL
|
|
||||||
resolved_url = await self._resolve_vavoo_link(url, signature)
|
resolved_url = await self._resolve_vavoo_link(url, signature)
|
||||||
if not resolved_url:
|
if not resolved_url:
|
||||||
raise ExtractorError("Failed to resolve Vavoo URL")
|
raise ExtractorError("Failed to resolve Vavoo URL")
|
||||||
|
|
||||||
# Set up headers for the resolved stream
|
|
||||||
stream_headers = {
|
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/",
|
"referer": "https://vavoo.to/",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,9 +133,9 @@ class VavooExtractor(BaseExtractor):
|
|||||||
}
|
}
|
||||||
|
|
||||||
async def _resolve_vavoo_link(self, link: str, signature: str) -> Optional[str]:
|
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 = {
|
headers = {
|
||||||
"user-agent": "MediaHubMX/2",
|
"user-agent": "okhttp/4.11.0",
|
||||||
"accept": "application/json",
|
"accept": "application/json",
|
||||||
"content-type": "application/json; charset=utf-8",
|
"content-type": "application/json; charset=utf-8",
|
||||||
"accept-encoding": "gzip",
|
"accept-encoding": "gzip",
|
||||||
@@ -148,22 +153,34 @@ class VavooExtractor(BaseExtractor):
|
|||||||
"https://vavoo.to/mediahubmx-resolve.json",
|
"https://vavoo.to/mediahubmx-resolve.json",
|
||||||
method="POST",
|
method="POST",
|
||||||
json=data,
|
json=data,
|
||||||
headers=headers
|
headers=headers,
|
||||||
|
timeout=12,
|
||||||
|
retries=3,
|
||||||
|
backoff_factor=0.6,
|
||||||
)
|
)
|
||||||
|
try:
|
||||||
result = resp.json()
|
result = resp.json()
|
||||||
logger.info(f"Vavoo API response: {result}")
|
except Exception:
|
||||||
|
logger.warning("Vavoo resolve returned non-json response (status=%s). Body preview: %s", resp.status_code, getattr(resp, "text", "")[:500])
|
||||||
|
return None
|
||||||
|
|
||||||
if isinstance(result, list) and result and result[0].get("url"):
|
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"]
|
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
|
return resolved_url
|
||||||
elif isinstance(result, dict) and result.get("url"):
|
elif isinstance(result, dict) and result.get("url"):
|
||||||
resolved_url = result["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
|
return resolved_url
|
||||||
else:
|
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
|
return None
|
||||||
except Exception as e:
|
except ExtractorError as e:
|
||||||
logger.exception(f"Vavoo resolution failed for URL {link}: {str(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
|
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.m3u8_processor import M3U8Processor
|
||||||
from .utils.mpd_utils import pad_base64
|
from .utils.mpd_utils import pad_base64
|
||||||
|
from .configs import settings
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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 force_playlist_proxy is enabled, skip detection and directly process as m3u8
|
||||||
if hls_params.force_playlist_proxy:
|
if hls_params.force_playlist_proxy:
|
||||||
return await fetch_and_process_m3u8(
|
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)
|
parsed_url = urlparse(hls_params.destination)
|
||||||
@@ -111,7 +113,8 @@ async def handle_hls_stream_proxy(
|
|||||||
0
|
0
|
||||||
] in ["m3u", "m3u8", "m3u_plus"]:
|
] in ["m3u", "m3u8", "m3u_plus"]:
|
||||||
return await fetch_and_process_m3u8(
|
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
|
# 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():
|
if "mpegurl" in response_headers.get("content-type", "").lower():
|
||||||
return await fetch_and_process_m3u8(
|
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(
|
return EnhancedStreamingResponse(
|
||||||
@@ -224,7 +228,14 @@ async def proxy_stream(method: str, destination: str, proxy_headers: ProxyReques
|
|||||||
|
|
||||||
|
|
||||||
async def fetch_and_process_m3u8(
|
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.
|
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.
|
request (Request): The incoming HTTP request.
|
||||||
key_url (str, optional): The HLS Key URL to replace the original key URL. Defaults to None.
|
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.
|
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:
|
Returns:
|
||||||
Response: The HTTP response with the processed m3u8 playlist.
|
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)
|
await streamer.create_streaming_response(url, proxy_headers.request)
|
||||||
|
|
||||||
# Initialize processor and response headers
|
# 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 = {
|
response_headers = {
|
||||||
"content-disposition": "inline",
|
"content-disposition": "inline",
|
||||||
"accept-ranges": "none",
|
"accept-ranges": "none",
|
||||||
@@ -378,7 +391,13 @@ async def get_segment(
|
|||||||
Response: The HTTP response with the processed segment.
|
Response: The HTTP response with the processed segment.
|
||||||
"""
|
"""
|
||||||
try:
|
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)
|
segment_content = await download_file_with_retry(segment_params.segment_url, proxy_headers.request)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return handle_exceptions(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']}")
|
logger.warning(f"No segments found for profile {profile['id']}")
|
||||||
continue
|
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
|
# Add headers for only the first profile
|
||||||
if index == 0:
|
if index == 0:
|
||||||
first_segment = segments[0]
|
first_segment = trimmed_segments[0]
|
||||||
extinf_values = [f["extinf"] for f in segments if "extinf" in f]
|
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
|
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")
|
mpd_start_number = profile.get("segment_template_start_number")
|
||||||
if mpd_start_number and mpd_start_number >= 1000:
|
sequence = first_segment.get("number")
|
||||||
# Amazon-style: Use absolute segment numbering
|
|
||||||
sequence = first_segment.get("number", mpd_start_number)
|
if sequence is None:
|
||||||
|
# Fallback to MPD template start number
|
||||||
|
if mpd_start_number is not None:
|
||||||
|
sequence = mpd_start_number
|
||||||
else:
|
else:
|
||||||
# Sky-style: Use time-based calculation if available
|
# As a last resort, derive from timeline information
|
||||||
time_val = first_segment.get("time")
|
time_val = first_segment.get("time")
|
||||||
duration_val = first_segment.get("duration_mpd_timescale")
|
duration_val = first_segment.get("duration_mpd_timescale")
|
||||||
if time_val is not None and duration_val and duration_val > 0:
|
if time_val is not None and duration_val and duration_val > 0:
|
||||||
calculated_sequence = math.floor(time_val / duration_val)
|
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:
|
else:
|
||||||
sequence = calculated_sequence
|
sequence = 1
|
||||||
else:
|
|
||||||
sequence = first_segment.get("number", 1)
|
|
||||||
|
|
||||||
hls.extend(
|
hls.extend(
|
||||||
[
|
[
|
||||||
@@ -235,10 +239,18 @@ def build_hls_playlist(mpd_dict: dict, profiles: list[dict], request: Request) -
|
|||||||
query_params.pop("d", None)
|
query_params.pop("d", None)
|
||||||
has_encrypted = query_params.pop("has_encrypted", False)
|
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},')
|
hls.append(f'#EXTINF:{segment["extinf"]:.3f},')
|
||||||
query_params.update(
|
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(
|
hls.append(
|
||||||
encode_mediaflow_proxy_url(
|
encode_mediaflow_proxy_url(
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import logging
|
import logging
|
||||||
from typing import Annotated
|
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 fastapi.responses import RedirectResponse
|
||||||
|
|
||||||
from mediaflow_proxy.extractors.base import ExtractorError
|
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.schemas import ExtractorURLParams
|
||||||
from mediaflow_proxy.utils.cache_utils import get_cached_extractor_result, set_cache_extractor_result
|
from mediaflow_proxy.utils.cache_utils import get_cached_extractor_result, set_cache_extractor_result
|
||||||
from mediaflow_proxy.utils.http_utils import (
|
from mediaflow_proxy.utils.http_utils import (
|
||||||
|
DownloadError,
|
||||||
encode_mediaflow_proxy_url,
|
encode_mediaflow_proxy_url,
|
||||||
get_original_scheme,
|
get_original_scheme,
|
||||||
ProxyRequestHeaders,
|
ProxyRequestHeaders,
|
||||||
@@ -19,12 +20,24 @@ from mediaflow_proxy.utils.base64_utils import process_potential_base64_url
|
|||||||
extractor_router = APIRouter()
|
extractor_router = APIRouter()
|
||||||
logger = logging.getLogger(__name__)
|
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.head("/video")
|
||||||
@extractor_router.get("/video")
|
@extractor_router.get("/video")
|
||||||
async def extract_url(
|
async def extract_url(
|
||||||
extractor_params: Annotated[ExtractorURLParams, Query()],
|
extractor_params: Annotated[ExtractorURLParams, Query()],
|
||||||
request: Request,
|
request: Request,
|
||||||
|
background_tasks: BackgroundTasks,
|
||||||
proxy_headers: Annotated[ProxyRequestHeaders, Depends(get_proxy_headers)],
|
proxy_headers: Annotated[ProxyRequestHeaders, Depends(get_proxy_headers)],
|
||||||
):
|
):
|
||||||
"""Extract clean links from various video hosting services."""
|
"""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()}"
|
cache_key = f"{extractor_params.host}_{extractor_params.model_dump_json()}"
|
||||||
response = await get_cached_extractor_result(cache_key)
|
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)
|
extractor = ExtractorFactory.get_extractor(extractor_params.host, proxy_headers.request)
|
||||||
response = await extractor.extract(extractor_params.destination, **extractor_params.extra_params)
|
response = await extractor.extract(extractor_params.destination, **extractor_params.extra_params)
|
||||||
await set_cache_extractor_result(cache_key, response)
|
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(
|
response["mediaflow_proxy_url"] = str(
|
||||||
request.url_for(response.pop("mediaflow_endpoint")).replace(scheme=get_original_scheme(request))
|
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
|
# Add API password to query params
|
||||||
response["query_params"]["api_password"] = request.query_params.get("api_password")
|
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:
|
if extractor_params.redirect_stream:
|
||||||
stream_url = encode_mediaflow_proxy_url(
|
stream_url = encode_mediaflow_proxy_url(
|
||||||
**response,
|
**response,
|
||||||
@@ -58,6 +85,9 @@ async def extract_url(
|
|||||||
|
|
||||||
return response
|
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:
|
except ExtractorError as e:
|
||||||
logger.error(f"Extraction failed: {str(e)}")
|
logger.error(f"Extraction failed: {str(e)}")
|
||||||
raise HTTPException(status_code=400, detail=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.
|
includendo gli headers da #EXTVLCOPT e #EXTHTTP. Yields rewritten lines.
|
||||||
"""
|
"""
|
||||||
current_ext_headers: Dict[str, str] = {} # Dizionario per conservare gli headers dalle direttive
|
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:
|
for line_with_newline in m3u_lines_iterator:
|
||||||
line_content = line_with_newline.rstrip('\n')
|
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
|
is_header_tag = False
|
||||||
if logical_line.startswith('#EXTVLCOPT:'):
|
if logical_line.startswith('#EXTVLCOPT:'):
|
||||||
|
# Yield the original line to preserve it
|
||||||
|
yield line_with_newline
|
||||||
|
|
||||||
is_header_tag = True
|
is_header_tag = True
|
||||||
try:
|
try:
|
||||||
option_str = logical_line.split(':', 1)[1]
|
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
|
current_ext_headers[header_key] = header_value
|
||||||
elif key_vlc.startswith('http-'):
|
elif key_vlc.startswith('http-'):
|
||||||
# Gestisce http-user-agent, http-referer etc.
|
# 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
|
current_ext_headers[header_key] = value_vlc
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"⚠️ Error parsing #EXTVLCOPT '{logical_line}': {e}")
|
logger.error(f"⚠️ Error parsing #EXTVLCOPT '{logical_line}': {e}")
|
||||||
|
|
||||||
elif logical_line.startswith('#EXTHTTP:'):
|
elif logical_line.startswith('#EXTHTTP:'):
|
||||||
|
# Yield the original line to preserve it
|
||||||
|
yield line_with_newline
|
||||||
|
|
||||||
is_header_tag = True
|
is_header_tag = True
|
||||||
try:
|
try:
|
||||||
json_str = logical_line.split(':', 1)[1]
|
json_str = logical_line.split(':', 1)[1]
|
||||||
@@ -58,8 +65,21 @@ def rewrite_m3u_links_streaming(m3u_lines_iterator: Iterator[str], base_url: str
|
|||||||
logger.error(f"⚠️ Error parsing #EXTHTTP '{logical_line}': {e}")
|
logger.error(f"⚠️ Error parsing #EXTHTTP '{logical_line}': {e}")
|
||||||
current_ext_headers = {} # Resetta in caso di errore
|
current_ext_headers = {} # Resetta in caso di errore
|
||||||
|
|
||||||
if is_header_tag:
|
elif logical_line.startswith('#KODIPROP:'):
|
||||||
|
# Yield the original line to preserve it
|
||||||
yield line_with_newline
|
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:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if logical_line and not logical_line.startswith('#') and \
|
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}"
|
processed_url_content = f"{base_url}/proxy/hls/manifest.m3u8?d={encoded_url}"
|
||||||
elif 'vixsrc.to' in logical_line:
|
elif 'vixsrc.to' in logical_line:
|
||||||
encoded_url = urllib.parse.quote(logical_line, safe='')
|
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:
|
elif '.m3u8' in logical_line:
|
||||||
encoded_url = urllib.parse.quote(logical_line, safe='')
|
encoded_url = urllib.parse.quote(logical_line, safe='')
|
||||||
processed_url_content = f"{base_url}/proxy/hls/manifest.m3u8?d={encoded_url}"
|
processed_url_content = f"{base_url}/proxy/hls/manifest.m3u8?d={encoded_url}"
|
||||||
elif '.mpd' in logical_line:
|
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
|
from urllib.parse import urlparse, parse_qs, urlencode, urlunparse
|
||||||
|
|
||||||
# Parse dell'URL per estrarre parametri
|
# Parse dell'URL per estrarre parametri
|
||||||
parsed_url = urlparse(logical_line)
|
parsed_url = urlparse(logical_line)
|
||||||
query_params = parse_qs(parsed_url.query)
|
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_id = query_params.get('key_id', [None])[0]
|
||||||
key = query_params.get('key', [None])[0]
|
key = query_params.get('key', [None])[0]
|
||||||
|
|
||||||
# Rimuovi key_id e key dai parametri originali
|
# 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
|
# 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((
|
clean_url = urlunparse((
|
||||||
parsed_url.scheme,
|
parsed_url.scheme,
|
||||||
parsed_url.netloc,
|
parsed_url.netloc,
|
||||||
parsed_url.path,
|
parsed_url.path,
|
||||||
parsed_url.params,
|
parsed_url.params,
|
||||||
clean_query,
|
clean_query,
|
||||||
parsed_url.fragment
|
'' # Rimuovi il frammento per evitare problemi
|
||||||
))
|
))
|
||||||
|
|
||||||
# Encode the MPD URL like other URL types
|
# Codifica l'URL pulito per il parametro 'd'
|
||||||
clean_url_for_param = urllib.parse.quote(clean_url, safe='')
|
encoded_clean_url = urllib.parse.quote(clean_url, safe='')
|
||||||
|
|
||||||
# Costruisci l'URL MediaFlow con parametri DRM separati
|
# Costruisci l'URL MediaFlow con parametri DRM separati
|
||||||
processed_url_content = f"{base_url}/proxy/mpd/manifest.m3u8?d={clean_url_for_param}"
|
processed_url_content = f"{base_url}/proxy/mpd/manifest.m3u8?d={encoded_clean_url}"
|
||||||
|
|
||||||
# Aggiungi parametri DRM se presenti
|
# Aggiungi i parametri DRM all'URL di MediaFlow se sono stati trovati
|
||||||
if key_id:
|
if key_id:
|
||||||
processed_url_content += f"&key_id={key_id}"
|
processed_url_content += f"&key_id={key_id}"
|
||||||
if key:
|
if key:
|
||||||
processed_url_content += f"&key={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:
|
elif '.php' in logical_line:
|
||||||
encoded_url = urllib.parse.quote(logical_line, safe='')
|
encoded_url = urllib.parse.quote(logical_line, safe='')
|
||||||
processed_url_content = f"{base_url}/proxy/hls/manifest.m3u8?d={encoded_url}"
|
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
|
processed_url_content += header_params_str
|
||||||
current_ext_headers = {}
|
current_ext_headers = {}
|
||||||
|
|
||||||
|
# Resetta le proprietà KODI dopo averle usate
|
||||||
|
current_kodi_props = {}
|
||||||
|
|
||||||
# Aggiungi api_password sempre alla fine
|
# Aggiungi api_password sempre alla fine
|
||||||
if api_password:
|
if api_password:
|
||||||
processed_url_content += f"&api_password={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 = []
|
lines = []
|
||||||
try:
|
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:
|
async with client.stream('GET', url, headers=headers) as response:
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
async for line_bytes in response.aiter_lines():
|
async for line_bytes in response.aiter_lines():
|
||||||
@@ -164,48 +195,136 @@ async def async_download_m3u_playlist(url: str) -> list[str]:
|
|||||||
raise
|
raise
|
||||||
return lines
|
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]):
|
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."""
|
"""Genera una playlist combinata da multiple definizioni, scaricando in parallelo."""
|
||||||
# Prepara gli URL
|
# Prepara i task di download
|
||||||
playlist_urls = []
|
download_tasks = []
|
||||||
for definition in playlist_definitions:
|
for definition in playlist_definitions:
|
||||||
if '&' in definition:
|
should_proxy = True
|
||||||
parts = definition.split('&', 1)
|
playlist_url_str = definition
|
||||||
playlist_url_str = parts[1] if len(parts) > 1 else parts[0]
|
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:
|
else:
|
||||||
playlist_url_str = definition
|
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
|
# Scarica tutte le playlist in parallelo
|
||||||
results = await asyncio.gather(*[async_download_m3u_playlist(url) for url in playlist_urls], return_exceptions=True)
|
results = await asyncio.gather(*[async_download_m3u_playlist(task["url"]) for task in download_tasks], return_exceptions=True)
|
||||||
|
|
||||||
first_playlist_header_handled = False
|
# Raggruppa le playlist da ordinare e quelle da non ordinare
|
||||||
for idx, lines in enumerate(results):
|
sorted_playlist_lines = []
|
||||||
if isinstance(lines, Exception):
|
unsorted_playlists_data = []
|
||||||
yield f"# ERROR processing playlist {playlist_urls[idx]}: {str(lines)}\n"
|
|
||||||
|
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
|
continue
|
||||||
playlist_lines: list[str] = lines # type: ignore
|
|
||||||
current_playlist_had_lines = False
|
if task_info.get("sort", False):
|
||||||
first_line_of_this_segment = True
|
sorted_playlist_lines.extend(result)
|
||||||
lines_processed_for_current_playlist = 0
|
else:
|
||||||
rewritten_lines_iter = rewrite_m3u_links_streaming(iter(playlist_lines), base_url, api_password)
|
unsorted_playlists_data.append({'lines': result, 'proxy': task_info['proxy']})
|
||||||
for line in rewritten_lines_iter:
|
|
||||||
current_playlist_had_lines = True
|
# Gestione dell'header #EXTM3U
|
||||||
is_extm3u_line = line.strip().startswith('#EXTM3U')
|
first_playlist_header_handled = False
|
||||||
lines_processed_for_current_playlist += 1
|
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:
|
if not first_playlist_header_handled:
|
||||||
yield line
|
|
||||||
if is_extm3u_line:
|
|
||||||
first_playlist_header_handled = True
|
first_playlist_header_handled = True
|
||||||
else:
|
yield line
|
||||||
if first_line_of_this_segment and is_extm3u_line:
|
|
||||||
pass
|
|
||||||
else:
|
else:
|
||||||
yield line
|
yield line
|
||||||
first_line_of_this_segment = False
|
if has_header and not first_playlist_header_handled:
|
||||||
if current_playlist_had_lines and not first_playlist_header_handled:
|
|
||||||
first_playlist_header_handled = True
|
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")
|
@playlist_builder_router.get("/playlist")
|
||||||
async def proxy_handler(
|
async def proxy_handler(
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
|
import asyncio
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
from urllib.parse import quote, unquote
|
from urllib.parse import quote, unquote
|
||||||
import re
|
import re
|
||||||
import logging
|
import logging
|
||||||
|
import httpx
|
||||||
|
import time
|
||||||
|
|
||||||
from fastapi import Request, Depends, APIRouter, Query, HTTPException
|
from fastapi import Request, Depends, APIRouter, Query, HTTPException
|
||||||
from fastapi.responses import Response, RedirectResponse
|
from fastapi.responses import Response
|
||||||
|
|
||||||
from mediaflow_proxy.handlers import (
|
from mediaflow_proxy.handlers import (
|
||||||
handle_hls_stream_proxy,
|
handle_hls_stream_proxy,
|
||||||
@@ -21,11 +24,22 @@ from mediaflow_proxy.schemas import (
|
|||||||
HLSManifestParams,
|
HLSManifestParams,
|
||||||
MPDManifestParams,
|
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
|
from mediaflow_proxy.utils.base64_utils import process_potential_base64_url
|
||||||
|
|
||||||
proxy_router = APIRouter()
|
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:
|
def sanitize_url(url: str) -> str:
|
||||||
"""
|
"""
|
||||||
@@ -122,42 +136,147 @@ def extract_drm_params_from_url(url: str) -> tuple[str, str, str]:
|
|||||||
return clean_url, key_id, key
|
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:
|
Args:
|
||||||
request (Request): The incoming HTTP request.
|
request (Request): The incoming HTTP request.
|
||||||
destination (str): The destination URL to check.
|
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:
|
Returns:
|
||||||
RedirectResponse | None: RedirectResponse if redirect is needed, None otherwise.
|
dict | None: Extracted stream data if DLHD link detected, None otherwise.
|
||||||
"""
|
"""
|
||||||
import re
|
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.)
|
# Check for common DLHD/DaddyLive patterns in the URL
|
||||||
if re.search(r'stream-\d+', destination):
|
# This includes stream-XXX pattern and domain names like dlhd.dad or daddylive.sx
|
||||||
from urllib.parse import urlencode
|
is_dlhd_link = (
|
||||||
|
re.search(r'stream-\d+', destination) or
|
||||||
# Build redirect URL to extractor
|
"dlhd.dad" in urlparse(destination).netloc or
|
||||||
redirect_params = {
|
"daddylive.sx" in urlparse(destination).netloc
|
||||||
"host": "DLHD",
|
)
|
||||||
"redirect_stream": "true",
|
|
||||||
"d": destination
|
|
||||||
}
|
|
||||||
|
|
||||||
# Preserve api_password if present
|
|
||||||
if "api_password" in request.query_params:
|
|
||||||
redirect_params["api_password"] = request.query_params["api_password"]
|
|
||||||
|
|
||||||
# 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)
|
|
||||||
|
|
||||||
|
if not is_dlhd_link:
|
||||||
return None
|
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"]
|
||||||
|
|
||||||
|
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")
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
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")
|
@proxy_router.head("/hls/manifest.m3u8")
|
||||||
@proxy_router.head("/hls/manifest.m3u8")
|
@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.
|
Response: The HTTP response with the processed m3u8 playlist or streamed content.
|
||||||
"""
|
"""
|
||||||
# Sanitize destination URL to fix common encoding issues
|
# Sanitize destination URL to fix common encoding issues
|
||||||
|
original_destination = hls_params.destination
|
||||||
hls_params.destination = sanitize_url(hls_params.destination)
|
hls_params.destination = sanitize_url(hls_params.destination)
|
||||||
|
|
||||||
# Check if destination contains stream-{numero} pattern and redirect to extractor
|
# Check if this is a retry after 403 error (dlhd_retry parameter)
|
||||||
redirect_response = _check_and_redirect_dlhd_stream(request, hls_params.destination)
|
force_refresh = request.query_params.get("dlhd_retry") == "1"
|
||||||
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, 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)
|
return await handle_hls_stream_proxy(request, hls_params, proxy_headers)
|
||||||
|
|
||||||
@@ -222,6 +526,8 @@ async def hls_segment_proxy(
|
|||||||
if settings.enable_hls_prebuffer:
|
if settings.enable_hls_prebuffer:
|
||||||
cached_segment = await hls_prebuffer.get_segment(segment_url, headers)
|
cached_segment = await hls_prebuffer.get_segment(segment_url, headers)
|
||||||
if cached_segment:
|
if cached_segment:
|
||||||
|
# Avvia prebuffer dei successivi in background
|
||||||
|
asyncio.create_task(hls_prebuffer.prebuffer_from_segment(segment_url, headers))
|
||||||
return Response(
|
return Response(
|
||||||
content=cached_segment,
|
content=cached_segment,
|
||||||
media_type="video/mp2t",
|
media_type="video/mp2t",
|
||||||
@@ -232,7 +538,10 @@ async def hls_segment_proxy(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
# 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)
|
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
|
# Sanitize destination URL to fix common encoding issues
|
||||||
destination = sanitize_url(destination)
|
destination = sanitize_url(destination)
|
||||||
|
|
||||||
# Check if destination contains stream-{numero} pattern and redirect to extractor
|
# Check if destination contains DLHD pattern and extract stream directly
|
||||||
redirect_response = _check_and_redirect_dlhd_stream(request, destination)
|
dlhd_result = await _check_and_extract_dlhd_stream(request, destination, proxy_headers)
|
||||||
if redirect_response:
|
if dlhd_result:
|
||||||
return redirect_response
|
# 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 filename:
|
||||||
# If a filename is provided, set it in the headers using RFC 6266 format
|
# If a filename is provided, set it in the headers using RFC 6266 format
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -71,6 +71,18 @@ class HLSManifestParams(GenericParams):
|
|||||||
None,
|
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.",
|
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):
|
class MPDManifestParams(GenericParams):
|
||||||
@@ -92,11 +104,12 @@ class MPDSegmentParams(GenericParams):
|
|||||||
mime_type: str = Field(..., description="The MIME type of the segment.")
|
mime_type: str = Field(..., description="The MIME type of the segment.")
|
||||||
key_id: Optional[str] = Field(None, description="The DRM key ID (optional).")
|
key_id: Optional[str] = Field(None, description="The DRM key ID (optional).")
|
||||||
key: Optional[str] = Field(None, description="The DRM key (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):
|
class ExtractorURLParams(GenericParams):
|
||||||
host: Literal[
|
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.")
|
] = Field(..., description="The host to extract the URL from.")
|
||||||
destination: str = Field(..., description="The URL of the stream.", alias="d")
|
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.")
|
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>
|
<head>
|
||||||
|
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
|
||||||
|
|
||||||
<title>App Status</title>
|
<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; }
|
h2 { color: #2c5aa0; border-bottom: 2px solid #2c5aa0; padding-bottom: 5px; text-align: left; margin-top: 30px; }
|
||||||
.form-group { margin-bottom: 15px; }
|
.form-group { margin-bottom: 15px; }
|
||||||
label { display: block; margin-bottom: 5px; font-weight: bold; color: #555; }
|
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; }
|
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 { 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; }
|
.btn:hover { background: #1e3d6f; }
|
||||||
@@ -60,6 +61,14 @@
|
|||||||
<label>M3U Playlist URL</label>
|
<label>M3U Playlist URL</label>
|
||||||
<input type="url" class="playlist-url" placeholder="Ex: http://provider.com/playlist.m3u">
|
<input type="url" class="playlist-url" placeholder="Ex: http://provider.com/playlist.m3u">
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -94,8 +103,14 @@
|
|||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
const playlistUrl = entry.querySelector('.playlist-url').value.trim();
|
const playlistUrl = entry.querySelector('.playlist-url').value.trim();
|
||||||
if (playlistUrl) {
|
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://')) {
|
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 {
|
} else {
|
||||||
alert('Invalid URL: ' + playlistUrl + '. URLs must start with http:// or https://');
|
alert('Invalid URL: ' + playlistUrl + '. URLs must start with http:// or https://');
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -425,12 +425,14 @@ class MediaFlowSpeedTest {
|
|||||||
'Content-Type': 'application/json',
|
'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) {
|
if (currentApiPassword) {
|
||||||
headers['api_password'] = currentApiPassword;
|
configUrl += `?api_password=${encodeURIComponent(currentApiPassword)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch('/speedtest/config', {
|
const response = await fetch(configUrl, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: headers,
|
headers: headers,
|
||||||
body: JSON.stringify(requestBody)
|
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)):
|
if not isinstance(data, (bytes, bytearray, memoryview)):
|
||||||
raise ValueError("Data must be bytes, bytearray, or 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
|
# Create cache entry
|
||||||
entry = CacheEntry(data=data, expires_at=expires_at, access_count=0, last_access=time.time(), size=len(data))
|
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
|
# Update memory cache
|
||||||
self.memory_cache.set(key, entry)
|
self.memory_cache.set(key, entry)
|
||||||
file_path = self._get_file_path(key)
|
file_path = self._get_file_path(key)
|
||||||
@@ -210,10 +225,11 @@ class HybridCache:
|
|||||||
|
|
||||||
async def delete(self, key: str) -> bool:
|
async def delete(self, key: str) -> bool:
|
||||||
"""Delete item from both caches."""
|
"""Delete item from both caches."""
|
||||||
self.memory_cache.remove(key)
|
hashed_key = self._get_md5_hash(key)
|
||||||
|
self.memory_cache.remove(hashed_key)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
file_path = self._get_file_path(key)
|
file_path = self._get_file_path(hashed_key)
|
||||||
await aiofiles.os.remove(file_path)
|
await aiofiles.os.remove(file_path)
|
||||||
return True
|
return True
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
@@ -237,7 +253,13 @@ class AsyncMemoryCache:
|
|||||||
async def set(self, key: str, data: Union[bytes, bytearray, memoryview], ttl: Optional[int] = None) -> bool:
|
async def set(self, key: str, data: Union[bytes, bytearray, memoryview], ttl: Optional[int] = None) -> bool:
|
||||||
"""Set value in cache."""
|
"""Set value in cache."""
|
||||||
try:
|
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(
|
entry = CacheEntry(
|
||||||
data=data, expires_at=expires_at, access_count=0, last_access=time.time(), size=len(data)
|
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
|
# Specific cache implementations
|
||||||
async def get_cached_init_segment(init_url: str, headers: dict) -> Optional[bytes]:
|
async def get_cached_init_segment(
|
||||||
"""Get initialization segment from cache or download it."""
|
init_url: str,
|
||||||
# Try cache first
|
headers: dict,
|
||||||
cached_data = await INIT_SEGMENT_CACHE.get(init_url)
|
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:
|
if cached_data is not None:
|
||||||
return cached_data
|
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:
|
try:
|
||||||
init_content = await download_file_with_retry(init_url, headers)
|
init_content = await download_file_with_retry(init_url, headers)
|
||||||
if init_content:
|
if init_content and use_cache:
|
||||||
await INIT_SEGMENT_CACHE.set(init_url, init_content)
|
await INIT_SEGMENT_CACHE.set(cache_key, init_content, ttl=ttl)
|
||||||
return init_content
|
return init_content
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error downloading init segment: {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
|
import httpx
|
||||||
from mediaflow_proxy.utils.http_utils import create_httpx_client
|
from mediaflow_proxy.utils.http_utils import create_httpx_client
|
||||||
from mediaflow_proxy.configs import settings
|
from mediaflow_proxy.configs import settings
|
||||||
|
from collections import OrderedDict
|
||||||
|
import time
|
||||||
|
from urllib.parse import urljoin
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -23,12 +26,21 @@ class HLSPreBuffer:
|
|||||||
max_cache_size (int): Maximum number of segments to cache (uses config if None)
|
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)
|
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.max_cache_size = max_cache_size or settings.hls_prebuffer_cache_size
|
||||||
self.prebuffer_segments = prebuffer_segments or settings.hls_prebuffer_segments
|
self.prebuffer_segments = prebuffer_segments or settings.hls_prebuffer_segments
|
||||||
self.max_memory_percent = settings.hls_prebuffer_max_memory_percent
|
self.max_memory_percent = settings.hls_prebuffer_max_memory_percent
|
||||||
self.emergency_threshold = settings.hls_prebuffer_emergency_threshold
|
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]] = {}
|
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()
|
self.client = create_httpx_client()
|
||||||
|
|
||||||
async def prebuffer_playlist(self, playlist_url: str, headers: Dict[str, str]) -> None:
|
async def prebuffer_playlist(self, playlist_url: str, headers: Dict[str, str]) -> None:
|
||||||
@@ -41,19 +53,15 @@ class HLSPreBuffer:
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
logger.debug(f"Starting pre-buffer for playlist: {playlist_url}")
|
logger.debug(f"Starting pre-buffer for playlist: {playlist_url}")
|
||||||
|
|
||||||
# Download and parse playlist
|
|
||||||
response = await self.client.get(playlist_url, headers=headers)
|
response = await self.client.get(playlist_url, headers=headers)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
playlist_content = response.text
|
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:
|
if "#EXT-X-STREAM-INF" in playlist_content:
|
||||||
logger.debug(f"Master playlist detected, finding first variant")
|
logger.debug(f"Master playlist detected, finding first variant")
|
||||||
# Extract variant URLs
|
|
||||||
variant_urls = self._extract_variant_urls(playlist_content, playlist_url)
|
variant_urls = self._extract_variant_urls(playlist_content, playlist_url)
|
||||||
if variant_urls:
|
if variant_urls:
|
||||||
# Pre-buffer the first variant
|
|
||||||
first_variant_url = variant_urls[0]
|
first_variant_url = variant_urls[0]
|
||||||
logger.debug(f"Pre-buffering first variant: {first_variant_url}")
|
logger.debug(f"Pre-buffering first variant: {first_variant_url}")
|
||||||
await self.prebuffer_playlist(first_variant_url, headers)
|
await self.prebuffer_playlist(first_variant_url, headers)
|
||||||
@@ -61,17 +69,28 @@ class HLSPreBuffer:
|
|||||||
logger.warning("No variants found in master playlist")
|
logger.warning("No variants found in master playlist")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Extract segment URLs
|
# Media playlist: estrai segmenti, salva stato e lancia refresh loop
|
||||||
segment_urls = self._extract_segment_urls(playlist_content, playlist_url)
|
segment_urls = self._extract_segment_urls(playlist_content, playlist_url)
|
||||||
|
|
||||||
# Store segment URLs for this playlist
|
|
||||||
self.segment_urls[playlist_url] = segment_urls
|
self.segment_urls[playlist_url] = segment_urls
|
||||||
|
# aggiorna mappa inversa
|
||||||
|
for idx, u in enumerate(segment_urls):
|
||||||
|
self.segment_to_playlist[u] = (playlist_url, idx)
|
||||||
|
|
||||||
# Pre-buffer first few segments
|
# prebuffer iniziale
|
||||||
await self._prebuffer_segments(segment_urls[:self.prebuffer_segments], headers)
|
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}")
|
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:
|
except Exception as e:
|
||||||
logger.warning(f"Failed to pre-buffer playlist {playlist_url}: {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]:
|
def _extract_variant_urls(self, playlist_content: str, base_url: str) -> List[str]:
|
||||||
"""
|
"""
|
||||||
Extract variant URLs from master playlist content.
|
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.
|
||||||
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
|
|
||||||
"""
|
"""
|
||||||
|
from urllib.parse import urljoin
|
||||||
variant_urls = []
|
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:
|
for line in lines:
|
||||||
line = line.strip()
|
if line.startswith("#EXT-X-STREAM-INF"):
|
||||||
if line and not line.startswith('#') and ('http://' in line or 'https://' in line):
|
take_next_uri = True
|
||||||
# Resolve relative URLs
|
continue
|
||||||
if line.startswith('http'):
|
if take_next_uri:
|
||||||
variant_urls.append(line)
|
take_next_uri = False
|
||||||
else:
|
if line and not line.startswith('#'):
|
||||||
# Join with base URL for relative paths
|
variant_urls.append(urljoin(base_url, line))
|
||||||
parsed_base = urlparse(base_url)
|
|
||||||
variant_url = f"{parsed_base.scheme}://{parsed_base.netloc}{line}"
|
|
||||||
variant_urls.append(variant_url)
|
|
||||||
|
|
||||||
logger.debug(f"Extracted {len(variant_urls)} variant URLs from master playlist")
|
logger.debug(f"Extracted {len(variant_urls)} variant URLs from master playlist")
|
||||||
if variant_urls:
|
if variant_urls:
|
||||||
logger.debug(f"First variant URL: {variant_urls[0]}")
|
logger.debug(f"First variant URL: {variant_urls[0]}")
|
||||||
|
|
||||||
return variant_urls
|
return variant_urls
|
||||||
|
|
||||||
async def _prebuffer_segments(self, segment_urls: List[str], headers: Dict[str, str]) -> None:
|
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:
|
for url in segment_urls:
|
||||||
if url not in self.segment_cache:
|
if url not in self.segment_cache:
|
||||||
tasks.append(self._download_segment(url, headers))
|
tasks.append(self._download_segment(url, headers))
|
||||||
|
|
||||||
if tasks:
|
if tasks:
|
||||||
await asyncio.gather(*tasks, return_exceptions=True)
|
await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
|
|
||||||
@@ -196,16 +204,16 @@ class HLSPreBuffer:
|
|||||||
|
|
||||||
def _emergency_cache_cleanup(self) -> None:
|
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():
|
if self._check_memory_threshold():
|
||||||
logger.warning("Emergency cache cleanup triggered due to high memory usage")
|
logger.warning("Emergency cache cleanup triggered due to high memory usage")
|
||||||
# Clear 50% of cache
|
to_remove = max(1, len(self.segment_cache) // 2)
|
||||||
cache_size = len(self.segment_cache)
|
removed = 0
|
||||||
keys_to_remove = list(self.segment_cache.keys())[:cache_size // 2]
|
while removed < to_remove and self.segment_cache:
|
||||||
for key in keys_to_remove:
|
self.segment_cache.popitem(last=False) # rimuovi LRU
|
||||||
del self.segment_cache[key]
|
removed += 1
|
||||||
logger.info(f"Emergency cleanup removed {len(keys_to_remove)} segments from cache")
|
logger.info(f"Emergency cleanup removed {removed} segments from cache")
|
||||||
|
|
||||||
async def _download_segment(self, segment_url: str, headers: Dict[str, str]) -> None:
|
async def _download_segment(self, segment_url: str, headers: Dict[str, str]) -> None:
|
||||||
"""
|
"""
|
||||||
@@ -216,7 +224,6 @@ class HLSPreBuffer:
|
|||||||
headers (Dict[str, str]): Headers to use for request
|
headers (Dict[str, str]): Headers to use for request
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Check memory usage before downloading
|
|
||||||
memory_percent = self._get_memory_usage_percent()
|
memory_percent = self._get_memory_usage_percent()
|
||||||
if memory_percent > self.max_memory_percent:
|
if memory_percent > self.max_memory_percent:
|
||||||
logger.warning(f"Memory usage {memory_percent}% exceeds limit {self.max_memory_percent}%, skipping download")
|
logger.warning(f"Memory usage {memory_percent}% exceeds limit {self.max_memory_percent}%, skipping download")
|
||||||
@@ -225,20 +232,18 @@ class HLSPreBuffer:
|
|||||||
response = await self.client.get(segment_url, headers=headers)
|
response = await self.client.get(segment_url, headers=headers)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
|
|
||||||
# Cache the segment
|
# Cache LRU
|
||||||
self.segment_cache[segment_url] = response.content
|
self.segment_cache[segment_url] = response.content
|
||||||
|
self.segment_cache.move_to_end(segment_url, last=True)
|
||||||
|
|
||||||
# Check for emergency cleanup
|
|
||||||
if self._check_memory_threshold():
|
if self._check_memory_threshold():
|
||||||
self._emergency_cache_cleanup()
|
self._emergency_cache_cleanup()
|
||||||
# Maintain cache size
|
|
||||||
elif len(self.segment_cache) > self.max_cache_size:
|
elif len(self.segment_cache) > self.max_cache_size:
|
||||||
# Remove oldest entries (simple FIFO)
|
# Evict LRU finché non rientra
|
||||||
oldest_key = next(iter(self.segment_cache))
|
while len(self.segment_cache) > self.max_cache_size:
|
||||||
del self.segment_cache[oldest_key]
|
self.segment_cache.popitem(last=False)
|
||||||
|
|
||||||
logger.debug(f"Cached segment: {segment_url}")
|
logger.debug(f"Cached segment: {segment_url}")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Failed to download segment {segment_url}: {e}")
|
logger.warning(f"Failed to download segment {segment_url}: {e}")
|
||||||
|
|
||||||
@@ -256,38 +261,64 @@ class HLSPreBuffer:
|
|||||||
# Check cache first
|
# Check cache first
|
||||||
if segment_url in self.segment_cache:
|
if segment_url in self.segment_cache:
|
||||||
logger.debug(f"Cache hit for segment: {segment_url}")
|
logger.debug(f"Cache hit for segment: {segment_url}")
|
||||||
return self.segment_cache[segment_url]
|
# 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
|
||||||
|
|
||||||
# Check memory usage before downloading
|
|
||||||
memory_percent = self._get_memory_usage_percent()
|
memory_percent = self._get_memory_usage_percent()
|
||||||
if memory_percent > self.max_memory_percent:
|
if memory_percent > self.max_memory_percent:
|
||||||
logger.warning(f"Memory usage {memory_percent}% exceeds limit {self.max_memory_percent}%, skipping download")
|
logger.warning(f"Memory usage {memory_percent}% exceeds limit {self.max_memory_percent}%, skipping download")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Download if not in cache
|
|
||||||
try:
|
try:
|
||||||
response = await self.client.get(segment_url, headers=headers)
|
response = await self.client.get(segment_url, headers=headers)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
segment_data = response.content
|
segment_data = response.content
|
||||||
|
|
||||||
# Cache the segment
|
# Cache LRU
|
||||||
self.segment_cache[segment_url] = segment_data
|
self.segment_cache[segment_url] = segment_data
|
||||||
|
self.segment_cache.move_to_end(segment_url, last=True)
|
||||||
|
|
||||||
# Check for emergency cleanup
|
|
||||||
if self._check_memory_threshold():
|
if self._check_memory_threshold():
|
||||||
self._emergency_cache_cleanup()
|
self._emergency_cache_cleanup()
|
||||||
# Maintain cache size
|
|
||||||
elif len(self.segment_cache) > self.max_cache_size:
|
elif len(self.segment_cache) > self.max_cache_size:
|
||||||
oldest_key = next(iter(self.segment_cache))
|
while len(self.segment_cache) > self.max_cache_size:
|
||||||
del self.segment_cache[oldest_key]
|
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}")
|
logger.debug(f"Downloaded and cached segment: {segment_url}")
|
||||||
return segment_data
|
return segment_data
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Failed to get segment {segment_url}: {e}")
|
logger.warning(f"Failed to get segment {segment_url}: {e}")
|
||||||
return None
|
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:
|
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.
|
Pre-buffer next segments based on current playback position.
|
||||||
@@ -299,10 +330,8 @@ class HLSPreBuffer:
|
|||||||
"""
|
"""
|
||||||
if playlist_url not in self.segment_urls:
|
if playlist_url not in self.segment_urls:
|
||||||
return
|
return
|
||||||
|
|
||||||
segment_urls = self.segment_urls[playlist_url]
|
segment_urls = self.segment_urls[playlist_url]
|
||||||
next_segments = segment_urls[current_segment_index + 1:current_segment_index + 1 + self.prebuffer_segments]
|
next_segments = segment_urls[current_segment_index + 1:current_segment_index + 1 + self.prebuffer_segments]
|
||||||
|
|
||||||
if next_segments:
|
if next_segments:
|
||||||
await self._prebuffer_segments(next_segments, headers)
|
await self._prebuffer_segments(next_segments, headers)
|
||||||
|
|
||||||
@@ -310,6 +339,8 @@ class HLSPreBuffer:
|
|||||||
"""Clear the segment cache."""
|
"""Clear the segment cache."""
|
||||||
self.segment_cache.clear()
|
self.segment_cache.clear()
|
||||||
self.segment_urls.clear()
|
self.segment_urls.clear()
|
||||||
|
self.segment_to_playlist.clear()
|
||||||
|
self.playlist_state.clear()
|
||||||
logger.info("HLS pre-buffer cache cleared")
|
logger.info("HLS pre-buffer cache cleared")
|
||||||
|
|
||||||
async def close(self) -> None:
|
async def close(self) -> None:
|
||||||
@@ -319,3 +350,141 @@ class HLSPreBuffer:
|
|||||||
|
|
||||||
# Global pre-buffer instance
|
# 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 dataclasses import dataclass
|
||||||
from functools import partial
|
from functools import partial
|
||||||
from urllib import parse
|
from urllib import parse
|
||||||
from urllib.parse import urlencode
|
from urllib.parse import urlencode, urlparse
|
||||||
|
|
||||||
import anyio
|
import anyio
|
||||||
import h11
|
import h11
|
||||||
@@ -81,6 +81,10 @@ async def fetch_with_retry(client, method, url, headers, follow_redirects=True,
|
|||||||
|
|
||||||
|
|
||||||
class Streamer:
|
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):
|
def __init__(self, client):
|
||||||
"""
|
"""
|
||||||
Initializes the Streamer with an HTTP client.
|
Initializes the Streamer with an HTTP client.
|
||||||
@@ -132,13 +136,48 @@ class Streamer:
|
|||||||
logger.error(f"Error creating streaming response: {e}")
|
logger.error(f"Error creating streaming response: {e}")
|
||||||
raise RuntimeError(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]:
|
async def stream_content(self) -> typing.AsyncGenerator[bytes, None]:
|
||||||
"""
|
|
||||||
Streams the content from the response.
|
|
||||||
"""
|
|
||||||
if not self.response:
|
if not self.response:
|
||||||
raise RuntimeError("No response available for streaming")
|
raise RuntimeError("No response available for streaming")
|
||||||
|
|
||||||
|
is_first_chunk = True
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self.parse_content_range()
|
self.parse_content_range()
|
||||||
|
|
||||||
@@ -154,15 +193,19 @@ class Streamer:
|
|||||||
mininterval=1,
|
mininterval=1,
|
||||||
) as self.progress_bar:
|
) as self.progress_bar:
|
||||||
async for chunk in self.response.aiter_bytes():
|
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
|
yield chunk
|
||||||
chunk_size = len(chunk)
|
self.bytes_transferred += len(chunk)
|
||||||
self.bytes_transferred += chunk_size
|
self.progress_bar.update(len(chunk))
|
||||||
self.progress_bar.set_postfix_str(
|
|
||||||
f"📥 : {self.format_bytes(self.bytes_transferred)}", refresh=False
|
|
||||||
)
|
|
||||||
self.progress_bar.update(chunk_size)
|
|
||||||
else:
|
else:
|
||||||
async for chunk in self.response.aiter_bytes():
|
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
|
yield chunk
|
||||||
self.bytes_transferred += len(chunk)
|
self.bytes_transferred += len(chunk)
|
||||||
|
|
||||||
@@ -187,10 +230,19 @@ class Streamer:
|
|||||||
raise DownloadError(502, f"Protocol error while streaming: {e}")
|
raise DownloadError(502, f"Protocol error while streaming: {e}")
|
||||||
except GeneratorExit:
|
except GeneratorExit:
|
||||||
logger.info("Streaming session stopped by the user")
|
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:
|
except Exception as e:
|
||||||
logger.error(f"Error streaming content: {e}")
|
logger.error(f"Error streaming content: {e}")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def format_bytes(size) -> str:
|
def format_bytes(size) -> str:
|
||||||
power = 2**10
|
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 = {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.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_")}
|
response_headers = {k[2:].lower(): v for k, v in request.query_params.items() if k.startswith("r_")}
|
||||||
return ProxyRequestHeaders(request_headers, response_headers)
|
return ProxyRequestHeaders(request_headers, response_headers)
|
||||||
|
|
||||||
@@ -527,21 +596,14 @@ class EnhancedStreamingResponse(Response):
|
|||||||
logger.error(f"Error in listen_for_disconnect: {str(e)}")
|
logger.error(f"Error in listen_for_disconnect: {str(e)}")
|
||||||
|
|
||||||
async def stream_response(self, send: Send) -> None:
|
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:
|
try:
|
||||||
# Initialize headers
|
# Initialize headers
|
||||||
headers = list(self.raw_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
|
# Start the response
|
||||||
await send(
|
await send(
|
||||||
{
|
{
|
||||||
@@ -550,6 +612,7 @@ class EnhancedStreamingResponse(Response):
|
|||||||
"headers": headers,
|
"headers": headers,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
response_started = True
|
||||||
|
|
||||||
# Track if we've sent any data
|
# Track if we've sent any data
|
||||||
data_sent = False
|
data_sent = False
|
||||||
@@ -568,27 +631,29 @@ class EnhancedStreamingResponse(Response):
|
|||||||
|
|
||||||
# Successfully streamed all content
|
# Successfully streamed all content
|
||||||
await send({"type": "http.response.body", "body": b"", "more_body": False})
|
await send({"type": "http.response.body", "body": b"", "more_body": False})
|
||||||
except (httpx.RemoteProtocolError, h11._util.LocalProtocolError) as e:
|
finalization_sent = True
|
||||||
# Handle connection closed errors
|
except (httpx.RemoteProtocolError, httpx.ReadError, h11._util.LocalProtocolError) as e:
|
||||||
|
# Handle connection closed / read errors gracefully
|
||||||
if data_sent:
|
if data_sent:
|
||||||
# We've sent some data to the client, so try to complete the response
|
# 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:
|
try:
|
||||||
await send({"type": "http.response.body", "body": b"", "more_body": False})
|
await send({"type": "http.response.body", "body": b"", "more_body": False})
|
||||||
|
finalization_sent = True
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Response finalized after partial content ({self.actual_content_length} bytes transferred)"
|
f"Response finalized after partial content ({self.actual_content_length} bytes transferred)"
|
||||||
)
|
)
|
||||||
except Exception as close_err:
|
except Exception as close_err:
|
||||||
logger.warning(f"Could not finalize response after remote error: {close_err}")
|
logger.warning(f"Could not finalize response after upstream error: {close_err}")
|
||||||
else:
|
else:
|
||||||
# No data was sent, re-raise the error
|
# 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
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception(f"Error in stream_response: {str(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:
|
||||||
# Try to send an error response if client is still connected
|
|
||||||
await send(
|
await send(
|
||||||
{
|
{
|
||||||
"type": "http.response.start",
|
"type": "http.response.start",
|
||||||
@@ -598,35 +663,38 @@ class EnhancedStreamingResponse(Response):
|
|||||||
)
|
)
|
||||||
error_message = f"Streaming error: {str(e)}".encode("utf-8")
|
error_message = f"Streaming error: {str(e)}".encode("utf-8")
|
||||||
await send({"type": "http.response.body", "body": error_message, "more_body": False})
|
await send({"type": "http.response.body", "body": error_message, "more_body": False})
|
||||||
|
finalization_sent = True
|
||||||
except Exception:
|
except Exception:
|
||||||
# If we can't send an error response, just log it
|
# If we can't send an error response, just log it
|
||||||
pass
|
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 def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||||
async with anyio.create_task_group() as task_group:
|
async with anyio.create_task_group() as task_group:
|
||||||
streaming_completed = False
|
|
||||||
stream_func = partial(self.stream_response, send)
|
stream_func = partial(self.stream_response, send)
|
||||||
listen_func = partial(self.listen_for_disconnect, receive)
|
listen_func = partial(self.listen_for_disconnect, receive)
|
||||||
|
|
||||||
async def wrap(func: typing.Callable[[], typing.Awaitable[None]]) -> None:
|
async def wrap(func: typing.Callable[[], typing.Awaitable[None]]) -> None:
|
||||||
try:
|
try:
|
||||||
await func()
|
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:
|
except Exception as e:
|
||||||
if isinstance(e, (httpx.RemoteProtocolError, h11._util.LocalProtocolError)):
|
# Note: stream_response and listen_for_disconnect handle their own exceptions
|
||||||
# Handle protocol errors more gracefully
|
# internally. This is a safety net for any unexpected exceptions that might
|
||||||
logger.warning(f"Protocol error during streaming: {e}")
|
# escape due to future code changes.
|
||||||
elif not isinstance(e, anyio.get_cancelled_exc_class()):
|
if not isinstance(e, anyio.get_cancelled_exc_class()):
|
||||||
logger.exception("Error in streaming task")
|
logger.exception(f"Unexpected error in streaming task: {type(e).__name__}: {e}")
|
||||||
# Only re-raise if it's not a protocol error or cancellation
|
# Re-raise unexpected errors to surface bugs rather than silently swallowing them
|
||||||
raise
|
raise
|
||||||
finally:
|
finally:
|
||||||
# Only cancel the task group if we're in disconnect listener or
|
# Cancel task group when either task completes or fails:
|
||||||
# if streaming_completed is True (meaning we finished normally)
|
# - stream_func finished (success or failure) -> stop listening for disconnect
|
||||||
if func == listen_func or streaming_completed:
|
# - listen_func finished (client disconnected) -> stop streaming
|
||||||
task_group.cancel_scope.cancel()
|
task_group.cancel_scope.cancel()
|
||||||
|
|
||||||
# Start the streaming response in a separate task
|
# Start the streaming response in a separate task
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ from mediaflow_proxy.utils.hls_prebuffer import hls_prebuffer
|
|||||||
|
|
||||||
|
|
||||||
class M3U8Processor:
|
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.
|
Initializes the M3U8Processor with the request and URL prefix.
|
||||||
|
|
||||||
@@ -19,9 +19,13 @@ class M3U8Processor:
|
|||||||
request (Request): The incoming HTTP request.
|
request (Request): The incoming HTTP request.
|
||||||
key_url (HttpUrl, optional): The URL of the key server. Defaults to None.
|
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.
|
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.request = request
|
||||||
self.key_url = parse.urlparse(key_url) if key_url else None
|
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.force_playlist_proxy = force_playlist_proxy
|
||||||
self.mediaflow_proxy_url = str(
|
self.mediaflow_proxy_url = str(
|
||||||
request.url_for("hls_manifest_proxy").replace(scheme=get_original_scheme(request))
|
request.url_for("hls_manifest_proxy").replace(scheme=get_original_scheme(request))
|
||||||
@@ -174,6 +178,15 @@ class M3U8Processor:
|
|||||||
Returns:
|
Returns:
|
||||||
str: The processed key line.
|
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)
|
uri_match = re.search(r'URI="([^"]+)"', line)
|
||||||
if uri_match:
|
if uri_match:
|
||||||
original_uri = uri_match.group(1)
|
original_uri = uri_match.group(1)
|
||||||
@@ -197,6 +210,14 @@ class M3U8Processor:
|
|||||||
"""
|
"""
|
||||||
full_url = parse.urljoin(base_url, url)
|
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
|
# Determine routing strategy based on configuration
|
||||||
routing_strategy = settings.m3u8_content_routing
|
routing_strategy = settings.m3u8_content_routing
|
||||||
|
|
||||||
|
|||||||
@@ -270,7 +270,7 @@ def parse_representation(
|
|||||||
if item:
|
if item:
|
||||||
profile["segments"] = parse_segment_template(parsed_dict, item, profile, source)
|
profile["segments"] = parse_segment_template(parsed_dict, item, profile, source)
|
||||||
else:
|
else:
|
||||||
profile["segments"] = parse_segment_base(representation, source)
|
profile["segments"] = parse_segment_base(representation, profile, source)
|
||||||
|
|
||||||
return profile
|
return profile
|
||||||
|
|
||||||
@@ -547,7 +547,7 @@ def create_segment_data(segment: Dict, item: dict, profile: dict, source: str, t
|
|||||||
return segment_data
|
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.
|
Parses segment base information and extracts segment data. This is used for single-segment representations.
|
||||||
|
|
||||||
@@ -563,6 +563,12 @@ def parse_segment_base(representation: dict, source: str) -> List[Dict]:
|
|||||||
if "Initialization" in segment:
|
if "Initialization" in segment:
|
||||||
start, _ = map(int, segment["Initialization"]["@range"].split("-"))
|
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 [
|
return [
|
||||||
{
|
{
|
||||||
"type": "segment",
|
"type": "segment",
|
||||||
|
|||||||
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