new version

This commit is contained in:
UrloMythus
2026-01-11 14:29:22 +01:00
parent b8a40b5afc
commit 7785e8c604
45 changed files with 5463 additions and 832 deletions

View File

@@ -19,11 +19,14 @@ class TransportConfig(BaseSettings):
proxy_url: Optional[str] = Field(
None, description="Primary proxy URL. Example: socks5://user:pass@proxy:1080 or http://proxy:8080"
)
disable_ssl_verification_globally: bool = Field(
False, description="Disable SSL verification for all requests globally."
)
all_proxy: bool = Field(False, description="Enable proxy for all routes by default")
transport_routes: Dict[str, RouteConfig] = Field(
default_factory=dict, description="Pattern-based route configuration"
)
timeout: int = Field(30, description="Timeout for HTTP requests in seconds")
timeout: int = Field(60, description="Timeout for HTTP requests in seconds")
def get_mounts(
self, async_http: bool = True
@@ -33,11 +36,13 @@ class TransportConfig(BaseSettings):
"""
mounts = {}
transport_cls = httpx.AsyncHTTPTransport if async_http else httpx.HTTPTransport
global_verify = not self.disable_ssl_verification_globally
# Configure specific routes
for pattern, route in self.transport_routes.items():
mounts[pattern] = transport_cls(
verify=route.verify_ssl, proxy=route.proxy_url or self.proxy_url if route.proxy else None
verify=route.verify_ssl if global_verify else False,
proxy=route.proxy_url or self.proxy_url if route.proxy else None,
)
# Hardcoded configuration for jxoplay.xyz domain - SSL verification disabled
@@ -45,9 +50,23 @@ class TransportConfig(BaseSettings):
verify=False, proxy=self.proxy_url if self.all_proxy else None
)
mounts["all://dlhd.dad"] = transport_cls(
verify=False, proxy=self.proxy_url if self.all_proxy else None
)
mounts["all://*.newkso.ru"] = transport_cls(
verify=False, proxy=self.proxy_url if self.all_proxy else None
)
# Apply global settings for proxy and SSL
default_proxy_url = self.proxy_url if self.all_proxy else None
if default_proxy_url or not global_verify:
mounts["all://"] = transport_cls(proxy=default_proxy_url, verify=global_verify)
# Set default proxy for all routes if enabled
if self.all_proxy:
mounts["all://"] = transport_cls(proxy=self.proxy_url)
# This part is now handled above to combine proxy and SSL settings
# if self.all_proxy:
# mounts["all://"] = transport_cls(proxy=self.proxy_url)
return mounts
@@ -78,6 +97,8 @@ class Settings(BaseSettings):
dash_prebuffer_cache_size: int = 50 # Maximum number of segments to cache in memory.
dash_prebuffer_max_memory_percent: int = 80 # Maximum percentage of system memory to use for DASH pre-buffer cache.
dash_prebuffer_emergency_threshold: int = 90 # Emergency threshold percentage to trigger aggressive cache cleanup.
mpd_live_init_cache_ttl: int = 0 # TTL (seconds) for live init segment cache; 0 disables caching.
mpd_live_playlist_depth: int = 8 # Number of recent segments to expose per live playlist variant.
user_agent: str = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36" # The user agent to use for HTTP requests.

View 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,
}

View File

@@ -1,48 +1,121 @@
from abc import ABC, abstractmethod
from typing import Dict, Optional, Any
import asyncio
import httpx
import logging
from mediaflow_proxy.configs import settings
from mediaflow_proxy.utils.http_utils import create_httpx_client
from mediaflow_proxy.utils.http_utils import create_httpx_client, DownloadError
logger = logging.getLogger(__name__)
class ExtractorError(Exception):
"""Base exception for all extractors."""
pass
class BaseExtractor(ABC):
"""Base class for all URL extractors."""
"""Base class for all URL extractors.
Improvements:
- Built-in retry/backoff for transient network errors
- Configurable timeouts and per-request overrides
- Better logging of non-200 responses and body previews for debugging
"""
def __init__(self, request_headers: dict):
self.base_headers = {
"user-agent": settings.user_agent,
}
self.mediaflow_endpoint = "proxy_stream_endpoint"
self.base_headers.update(request_headers)
# merge incoming headers (e.g. Accept-Language / Referer) with default base headers
self.base_headers.update(request_headers or {})
async def _make_request(
self, url: str, method: str = "GET", headers: Optional[Dict] = None, **kwargs
self,
url: str,
method: str = "GET",
headers: Optional[Dict] = None,
timeout: Optional[float] = None,
retries: int = 3,
backoff_factor: float = 0.5,
raise_on_status: bool = True,
**kwargs,
) -> httpx.Response:
"""Make HTTP request with error handling."""
try:
async with create_httpx_client() as client:
request_headers = self.base_headers.copy()
request_headers.update(headers or {})
response = await client.request(
method,
url,
headers=request_headers,
**kwargs,
)
response.raise_for_status()
return response
except httpx.HTTPError as e:
raise ExtractorError(f"HTTP request failed for URL {url}: {str(e)}")
except Exception as e:
raise ExtractorError(f"Request failed for URL {url}: {str(e)}")
"""
Make HTTP request with retry and timeout support.
Parameters
----------
timeout : float | None
Seconds to wait for the request (applied to httpx.Timeout). Defaults to 15s.
retries : int
Number of attempts for transient errors.
backoff_factor : float
Base for exponential backoff between retries.
raise_on_status : bool
If True, HTTP non-2xx raises DownloadError (preserves status code).
"""
attempt = 0
last_exc = None
# build request headers merging base and per-request
request_headers = self.base_headers.copy()
if headers:
request_headers.update(headers)
timeout_cfg = httpx.Timeout(timeout or 15.0)
while attempt < retries:
try:
async with create_httpx_client(timeout=timeout_cfg) as client:
response = await client.request(
method,
url,
headers=request_headers,
**kwargs,
)
if raise_on_status:
try:
response.raise_for_status()
except httpx.HTTPStatusError as e:
# Provide a short body preview for debugging
body_preview = ""
try:
body_preview = e.response.text[:500]
except Exception:
body_preview = "<unreadable body>"
logger.debug(
"HTTPStatusError for %s (status=%s) -- body preview: %s",
url,
e.response.status_code,
body_preview,
)
raise DownloadError(e.response.status_code, f"HTTP error {e.response.status_code} while requesting {url}")
return response
except DownloadError:
# Do not retry on explicit HTTP status errors (they are intentional)
raise
except (httpx.ReadTimeout, httpx.ConnectTimeout, httpx.NetworkError, httpx.TransportError) as e:
# Transient network error — retry with backoff
last_exc = e
attempt += 1
sleep_for = backoff_factor * (2 ** (attempt - 1))
logger.warning("Transient network error (attempt %s/%s) for %s: %s — retrying in %.1fs",
attempt, retries, url, e, sleep_for)
await asyncio.sleep(sleep_for)
continue
except Exception as e:
# Unexpected exception — wrap as ExtractorError to keep interface consistent
logger.exception("Unhandled exception while requesting %s: %s", url, e)
raise ExtractorError(f"Request failed for URL {url}: {str(e)}")
logger.error("All retries failed for %s: %s", url, last_exc)
raise ExtractorError(f"Request failed for URL {url}: {str(last_exc)}")
@abstractmethod
async def extract(self, url: str, **kwargs) -> Dict[str, Any]:

View File

@@ -1,543 +1,332 @@
import re
import base64
import logging
from typing import Any, Dict, Optional
from urllib.parse import urlparse, quote, urlunparse
from typing import Any, Dict, Optional, List
from urllib.parse import urlparse, quote_plus, urljoin
import httpx
from mediaflow_proxy.extractors.base import BaseExtractor, ExtractorError
logger = logging.getLogger(__name__)
# Silenzia l'errore ConnectionResetError su Windows
logging.getLogger('asyncio').setLevel(logging.CRITICAL)
class DLHDExtractor(BaseExtractor):
"""DLHD (DaddyLive) URL extractor for M3U8 streams."""
"""DLHD (DaddyLive) URL extractor for M3U8 streams.
Notes:
- Multi-domain support for daddylive.sx / dlhd.dad
- Robust extraction of auth parameters and server lookup
- Uses retries/timeouts via BaseExtractor where possible
- Multi-iframe fallback for resilience
"""
def __init__(self, request_headers: dict):
super().__init__(request_headers)
# Default to HLS proxy endpoint
self.mediaflow_endpoint = "hls_manifest_proxy"
# Cache for the resolved base URL to avoid repeated network calls
self._cached_base_url = None
# Store iframe context for newkso.ru requests
self._iframe_context = None
self._iframe_context: Optional[str] = None
def _get_headers_for_url(self, url: str, base_headers: dict) -> dict:
"""Get appropriate headers for the given URL, applying newkso.ru specific headers if needed."""
headers = base_headers.copy()
# Check if URL contains newkso.ru domain
parsed_url = urlparse(url)
if "newkso.ru" in parsed_url.netloc:
# Use iframe URL as referer if available, otherwise use the newkso domain itself
if self._iframe_context:
iframe_origin = f"https://{urlparse(self._iframe_context).netloc}"
newkso_headers = {
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36',
'Referer': self._iframe_context,
'Origin': iframe_origin
}
logger.info(f"Applied newkso.ru specific headers with iframe context for URL: {url}")
logger.debug(f"Headers applied: {newkso_headers}")
else:
# Fallback to newkso domain itself
newkso_origin = f"{parsed_url.scheme}://{parsed_url.netloc}"
newkso_headers = {
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36',
'Referer': newkso_origin,
'Origin': newkso_origin
}
logger.info(f"Applied newkso.ru specific headers (fallback) for URL: {url}")
logger.debug(f"Headers applied: {newkso_headers}")
async def _make_request(self, url: str, method: str = "GET", headers: Optional[Dict] = None, **kwargs) -> Any:
"""Override to disable SSL verification for this extractor and use fetch_with_retry if available."""
from mediaflow_proxy.utils.http_utils import create_httpx_client, fetch_with_retry
timeout = kwargs.pop("timeout", 15)
retries = kwargs.pop("retries", 3)
backoff_factor = kwargs.pop("backoff_factor", 0.5)
async with create_httpx_client(verify=False, timeout=httpx.Timeout(timeout)) as client:
try:
return await fetch_with_retry(client, method, url, headers or {}, timeout=timeout)
except Exception:
logger.debug("fetch_with_retry failed or unavailable; falling back to direct request for %s", url)
response = await client.request(method, url, headers=headers or {}, timeout=timeout)
response.raise_for_status()
return response
async def _extract_lovecdn_stream(self, iframe_url: str, iframe_content: str, headers: dict) -> Dict[str, Any]:
"""
Estrattore alternativo per iframe lovecdn.ru che usa un formato diverso.
"""
try:
# Cerca pattern di stream URL diretto
m3u8_patterns = [
r'["\']([^"\']*\.m3u8[^"\']*)["\']',
r'source[:\s]+["\']([^"\']+)["\']',
r'file[:\s]+["\']([^"\']+\.m3u8[^"\']*)["\']',
r'hlsManifestUrl[:\s]*["\']([^"\']+)["\']',
]
headers.update(newkso_headers)
return headers
stream_url = None
for pattern in m3u8_patterns:
matches = re.findall(pattern, iframe_content)
for match in matches:
if '.m3u8' in match and match.startswith('http'):
stream_url = match
logger.info(f"Found direct m3u8 URL: {stream_url}")
break
if stream_url:
break
# Pattern 2: Cerca costruzione dinamica URL
if not stream_url:
channel_match = re.search(r'(?:stream|channel)["\s:=]+["\']([^"\']+)["\']', iframe_content)
server_match = re.search(r'(?:server|domain|host)["\s:=]+["\']([^"\']+)["\']', iframe_content)
if channel_match:
channel_name = channel_match.group(1)
server = server_match.group(1) if server_match else 'newkso.ru'
stream_url = f"https://{server}/{channel_name}/mono.m3u8"
logger.info(f"Constructed stream URL: {stream_url}")
if not stream_url:
# Fallback: cerca qualsiasi URL che sembri uno stream
url_pattern = r'https?://[^\s"\'<>]+\.m3u8[^\s"\'<>]*'
matches = re.findall(url_pattern, iframe_content)
if matches:
stream_url = matches[0]
logger.info(f"Found fallback stream URL: {stream_url}")
if not stream_url:
raise ExtractorError(f"Could not find stream URL in lovecdn.ru iframe")
# Usa iframe URL come referer
iframe_origin = f"https://{urlparse(iframe_url).netloc}"
stream_headers = {
'User-Agent': headers['User-Agent'],
'Referer': iframe_url,
'Origin': iframe_origin
}
# Determina endpoint in base al dominio dello stream
endpoint = "hls_key_proxy"
async def _make_request(self, url: str, method: str = "GET", headers: dict = None, **kwargs):
"""Override _make_request to apply newkso.ru specific headers when needed."""
request_headers = headers or {}
logger.info(f"Using lovecdn.ru stream with endpoint: {endpoint}")
return {
"destination_url": stream_url,
"request_headers": stream_headers,
"mediaflow_endpoint": endpoint,
}
except Exception as e:
raise ExtractorError(f"Failed to extract lovecdn.ru stream: {e}")
async def _extract_new_auth_flow(self, iframe_url: str, iframe_content: str, headers: dict) -> Dict[str, Any]:
"""Handles the new authentication flow found in recent updates."""
# Apply newkso.ru specific headers if the URL contains newkso.ru
final_headers = self._get_headers_for_url(url, request_headers)
def _extract_params(js: str) -> Dict[str, Optional[str]]:
params = {}
patterns = {
"channel_key": r'(?:const|var|let)\s+(?:CHANNEL_KEY|channelKey)\s*=\s*["\']([^"\']+)["\']',
"auth_token": r'(?:const|var|let)\s+AUTH_TOKEN\s*=\s*["\']([^"\']+)["\']',
"auth_country": r'(?:const|var|let)\s+AUTH_COUNTRY\s*=\s*["\']([^"\']+)["\']',
"auth_ts": r'(?:const|var|let)\s+AUTH_TS\s*=\s*["\']([^"\']+)["\']',
"auth_expiry": r'(?:const|var|let)\s+AUTH_EXPIRY\s*=\s*["\']([^"\']+)["\']',
}
for key, pattern in patterns.items():
match = re.search(pattern, js)
params[key] = match.group(1) if match else None
return params
params = _extract_params(iframe_content)
return await super()._make_request(url, method, final_headers, **kwargs)
missing_params = [k for k, v in params.items() if not v]
if missing_params:
# This is not an error, just means it's not the new flow
raise ExtractorError(f"Not the new auth flow: missing params {missing_params}")
logger.info("New auth flow detected. Proceeding with POST auth.")
# 1. Initial Auth POST
auth_url = 'https://security.newkso.ru/auth2.php'
# Use files parameter to force multipart/form-data which is required by the server
# (None, value) tells httpx to send it as a form field, not a file upload
multipart_data = {
'channelKey': (None, params["channel_key"]),
'country': (None, params["auth_country"]),
'timestamp': (None, params["auth_ts"]),
'expiry': (None, params["auth_expiry"]),
'token': (None, params["auth_token"]),
}
iframe_origin = f"https://{urlparse(iframe_url).netloc}"
auth_headers = headers.copy()
auth_headers.update({
'Accept': '*/*',
'Accept-Language': 'en-US,en;q=0.9',
'Origin': iframe_origin,
'Referer': iframe_url,
'Sec-Fetch-Dest': 'empty',
'Sec-Fetch-Mode': 'cors',
'Sec-Fetch-Site': 'cross-site',
'Priority': 'u=1, i',
})
from mediaflow_proxy.utils.http_utils import create_httpx_client
try:
async with create_httpx_client(verify=False) as client:
# Note: using 'files' instead of 'data' to ensure multipart/form-data Content-Type
auth_resp = await client.post(auth_url, files=multipart_data, headers=auth_headers, timeout=12)
auth_resp.raise_for_status()
auth_data = auth_resp.json()
if not (auth_data.get("valid") or auth_data.get("success")):
raise ExtractorError(f"Initial auth failed with response: {auth_data}")
logger.info("New auth flow: Initial auth successful.")
except Exception as e:
raise ExtractorError(f"New auth flow failed during initial auth POST: {e}")
# 2. Server Lookup
server_lookup_url = f"https://{urlparse(iframe_url).netloc}/server_lookup.js?channel_id={params['channel_key']}"
try:
# Use _make_request as it handles retries and expects JSON
lookup_resp = await self._make_request(server_lookup_url, headers=headers, timeout=10)
server_data = lookup_resp.json()
server_key = server_data.get('server_key')
if not server_key:
raise ExtractorError(f"No server_key in lookup response: {server_data}")
logger.info(f"New auth flow: Server lookup successful - Server key: {server_key}")
except Exception as e:
raise ExtractorError(f"New auth flow failed during server lookup: {e}")
# 3. Build final stream URL
channel_key = params['channel_key']
auth_token = params['auth_token']
# The JS logic uses .css, not .m3u8
if server_key == 'top1/cdn':
stream_url = f'https://top1.newkso.ru/top1/cdn/{channel_key}/mono.css'
else:
stream_url = f'https://{server_key}new.newkso.ru/{server_key}/{channel_key}/mono.css'
logger.info(f'New auth flow: Constructed stream URL: {stream_url}')
stream_headers = {
'User-Agent': headers['User-Agent'],
'Referer': iframe_url,
'Origin': iframe_origin,
'Authorization': f'Bearer {auth_token}',
'X-Channel-Key': channel_key
}
return {
"destination_url": stream_url,
"request_headers": stream_headers,
"mediaflow_endpoint": "hls_manifest_proxy",
}
async def extract(self, url: str, **kwargs) -> Dict[str, Any]:
"""Extract DLHD stream URL and required headers (logica tvproxy adattata async, con fallback su endpoint alternativi)."""
from urllib.parse import urlparse, quote_plus
"""Main extraction flow: resolve base, fetch players, extract iframe, auth and final m3u8."""
baseurl = "https://dlhd.dad/"
async def get_daddylive_base_url():
if self._cached_base_url:
return self._cached_base_url
try:
resp = await self._make_request("https://daddylive.sx/")
# resp.url is the final URL after redirects
base_url = str(resp.url)
if not base_url.endswith('/'):
base_url += '/'
self._cached_base_url = base_url
return base_url
except Exception:
# Fallback to default if request fails
return "https://daddylive.sx/"
def extract_channel_id(url):
match_premium = re.search(r'/premium(\d+)/mono\.m3u8$', url)
if match_premium:
return match_premium.group(1)
# Handle both normal and URL-encoded patterns
match_player = re.search(r'/(?:watch|stream|cast|player)/stream-(\d+)\.php', url)
if match_player:
return match_player.group(1)
# Handle URL-encoded patterns like %2Fstream%2Fstream-123.php or just stream-123.php
match_encoded = re.search(r'(?:%2F|/)stream-(\d+)\.php', url, re.IGNORECASE)
if match_encoded:
return match_encoded.group(1)
# Handle direct stream- pattern without path
match_direct = re.search(r'stream-(\d+)\.php', url)
if match_direct:
return match_direct.group(1)
def extract_channel_id(u: str) -> Optional[str]:
match_watch_id = re.search(r'watch\.php\?id=(\d+)', u)
if match_watch_id:
return match_watch_id.group(1)
return None
async def try_endpoint(baseurl, endpoint, channel_id):
stream_url = f"{baseurl}{endpoint}stream-{channel_id}.php"
async def get_stream_data(initial_url: str):
daddy_origin = urlparse(baseurl).scheme + "://" + urlparse(baseurl).netloc
daddylive_headers = {
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36',
'Referer': baseurl,
'Origin': daddy_origin
}
# 1. Richiesta alla pagina stream/cast/player/watch
resp1 = await self._make_request(stream_url, headers=daddylive_headers)
# 2. Estrai link Player 2
iframes = re.findall(r'<a[^>]*href="([^"]+)"[^>]*>\s*<button[^>]*>\s*Player\s*2\s*</button>', resp1.text)
if not iframes:
raise ExtractorError("No Player 2 link found")
url2 = iframes[0]
url2 = baseurl + url2
url2 = url2.replace('//cast', '/cast')
daddylive_headers['Referer'] = url2
daddylive_headers['Origin'] = url2
# 3. Richiesta alla pagina Player 2
resp2 = await self._make_request(url2, headers=daddylive_headers)
# 4. Estrai iframe
iframes2 = re.findall(r'iframe src="([^"]*)', resp2.text)
if not iframes2:
raise ExtractorError("No iframe found in Player 2 page")
iframe_url = iframes2[0]
# Store iframe context for newkso.ru requests
self._iframe_context = iframe_url
resp3 = await self._make_request(iframe_url, headers=daddylive_headers)
iframe_content = resp3.text
# 5. Estrai parametri auth (robusto) - Handle both old and new formats
def extract_var_old_format(js, name):
# Try multiple patterns for variable extraction (old format)
patterns = [
rf'var (?:__)?{name}\s*=\s*atob\("([^"]+)"\)',
rf'var (?:__)?{name}\s*=\s*atob\(\'([^\']+)\'\)',
rf'(?:var\s+)?(?:__)?{name}\s*=\s*atob\s*\(\s*["\']([^"\']+)["\']\s*\)',
rf'(?:let|const)\s+(?:__)?{name}\s*=\s*atob\s*\(\s*["\']([^"\']+)["\']\s*\)'
]
for pattern in patterns:
m = re.search(pattern, js)
if m:
try:
return base64.b64decode(m.group(1)).decode('utf-8')
except Exception as decode_error:
logger.warning(f"Failed to decode base64 for variable {name}: {decode_error}")
continue
return None
def extract_xjz_format(js):
"""Extract parameters from the new XJZ base64-encoded JSON format."""
# 1. Request initial page
resp1 = await self._make_request(initial_url, headers=daddylive_headers, timeout=15)
player_links = re.findall(r'<button[^>]*data-url="([^"]+)"[^>]*>Player\s*\d+</button>', resp1.text)
if not player_links:
raise ExtractorError("No player links found on the page.")
# Prova tutti i player e raccogli tutti gli iframe validi
last_player_error = None
iframe_candidates = []
for player_url in player_links:
try:
# Look for the XJZ variable assignment
xjz_pattern = r'const\s+XJZ\s*=\s*["\']([^"\']+)["\']'
match = re.search(xjz_pattern, js)
if not match:
return None
xjz_b64 = match.group(1)
import json
# Decode the first base64 layer (JSON)
xjz_json = base64.b64decode(xjz_b64).decode('utf-8')
xjz_obj = json.loads(xjz_json)
# Each value is also base64-encoded, decode each
decoded = {}
for k, v in xjz_obj.items():
try:
decoded[k] = base64.b64decode(v).decode('utf-8')
except Exception as e:
logger.warning(f"Failed to decode XJZ field {k}: {e}")
decoded[k] = v
return decoded
except Exception as e:
logger.warning(f"Failed to extract XJZ format: {e}")
return None
if not player_url.startswith('http'):
player_url = baseurl + player_url.lstrip('/')
def extract_bundle_format(js):
"""Extract parameters from new BUNDLE format (legacy fallback)."""
daddylive_headers['Referer'] = player_url
daddylive_headers['Origin'] = player_url
resp2 = await self._make_request(player_url, headers=daddylive_headers, timeout=12)
iframes2 = re.findall(r'<iframe.*?src="([^"]*)"', resp2.text)
# Raccogli tutti gli iframe trovati
for iframe in iframes2:
if iframe not in iframe_candidates:
iframe_candidates.append(iframe)
logger.info(f"Found iframe candidate: {iframe}")
except Exception as e:
last_player_error = e
logger.warning(f"Failed to process player link {player_url}: {e}")
continue
if not iframe_candidates:
if last_player_error:
raise ExtractorError(f"All player links failed. Last error: {last_player_error}")
raise ExtractorError("No valid iframe found in any player page")
# Prova ogni iframe finché uno non funziona
last_iframe_error = None
for iframe_candidate in iframe_candidates:
try:
bundle_patterns = [
r'const\s+BUNDLE\s*=\s*["\']([^"\']+)["\']',
r'var\s+BUNDLE\s*=\s*["\']([^"\']+)["\']',
r'let\s+BUNDLE\s*=\s*["\']([^"\']+)["\']'
]
bundle_data = None
for pattern in bundle_patterns:
match = re.search(pattern, js)
if match:
bundle_data = match.group(1)
break
if not bundle_data:
return None
import json
bundle_json = base64.b64decode(bundle_data).decode('utf-8')
bundle_obj = json.loads(bundle_json)
decoded_bundle = {}
for key, value in bundle_obj.items():
try:
decoded_bundle[key] = base64.b64decode(value).decode('utf-8')
except Exception as e:
logger.warning(f"Failed to decode bundle field {key}: {e}")
decoded_bundle[key] = value
return decoded_bundle
logger.info(f"Trying iframe: {iframe_candidate}")
iframe_domain = urlparse(iframe_candidate).netloc
if not iframe_domain:
logger.warning(f"Invalid iframe URL format: {iframe_candidate}")
continue
self._iframe_context = iframe_candidate
resp3 = await self._make_request(iframe_candidate, headers=daddylive_headers, timeout=12)
iframe_content = resp3.text
logger.info(f"Successfully loaded iframe from: {iframe_domain}")
if 'lovecdn.ru' in iframe_domain:
logger.info("Detected lovecdn.ru iframe - using alternative extraction")
return await self._extract_lovecdn_stream(iframe_candidate, iframe_content, daddylive_headers)
else:
logger.info("Attempting new auth flow extraction.")
return await self._extract_new_auth_flow(iframe_candidate, iframe_content, daddylive_headers)
except Exception as e:
logger.warning(f"Failed to extract bundle format: {e}")
return None
# Try multiple patterns for channel key extraction
channel_key = None
channel_key_patterns = [
r'const\s+CHANNEL_KEY\s*=\s*["\']([^"\']+)["\']',
r'var\s+CHANNEL_KEY\s*=\s*["\']([^"\']+)["\']',
r'let\s+CHANNEL_KEY\s*=\s*["\']([^"\']+)["\']',
r'channelKey\s*=\s*["\']([^"\']+)["\']',
r'var\s+channelKey\s*=\s*["\']([^"\']+)["\']',
r'(?:let|const)\s+channelKey\s*=\s*["\']([^"\']+)["\']'
]
for pattern in channel_key_patterns:
match = re.search(pattern, iframe_content)
if match:
channel_key = match.group(1)
break
# Try new XJZ format first
xjz_data = extract_xjz_format(iframe_content)
if xjz_data:
logger.info("Using new XJZ format for parameter extraction")
auth_host = xjz_data.get('b_host')
auth_php = xjz_data.get('b_script')
auth_ts = xjz_data.get('b_ts')
auth_rnd = xjz_data.get('b_rnd')
auth_sig = xjz_data.get('b_sig')
logger.debug(f"XJZ data extracted: {xjz_data}")
else:
# Try bundle format (legacy fallback)
bundle_data = extract_bundle_format(iframe_content)
if bundle_data:
logger.info("Using BUNDLE format for parameter extraction")
auth_host = bundle_data.get('b_host')
auth_php = bundle_data.get('b_script')
auth_ts = bundle_data.get('b_ts')
auth_rnd = bundle_data.get('b_rnd')
auth_sig = bundle_data.get('b_sig')
logger.debug(f"Bundle data extracted: {bundle_data}")
else:
logger.info("Falling back to old format for parameter extraction")
# Fall back to old format
auth_ts = extract_var_old_format(iframe_content, 'c')
auth_rnd = extract_var_old_format(iframe_content, 'd')
auth_sig = extract_var_old_format(iframe_content, 'e')
auth_host = extract_var_old_format(iframe_content, 'a')
auth_php = extract_var_old_format(iframe_content, 'b')
logger.warning(f"Failed to process iframe {iframe_candidate}: {e}")
last_iframe_error = e
continue
# Log what we found for debugging
logger.debug(f"Extracted parameters: channel_key={channel_key}, auth_ts={auth_ts}, auth_rnd={auth_rnd}, auth_sig={auth_sig}, auth_host={auth_host}, auth_php={auth_php}")
raise ExtractorError(f"All iframe candidates failed. Last error: {last_iframe_error}")
# Check which parameters are missing
missing_params = []
if not channel_key:
missing_params.append('channel_key/CHANNEL_KEY')
if not auth_ts:
missing_params.append('auth_ts (var c / b_ts)')
if not auth_rnd:
missing_params.append('auth_rnd (var d / b_rnd)')
if not auth_sig:
missing_params.append('auth_sig (var e / b_sig)')
if not auth_host:
missing_params.append('auth_host (var a / b_host)')
if not auth_php:
missing_params.append('auth_php (var b / b_script)')
if missing_params:
logger.error(f"Missing parameters: {', '.join(missing_params)}")
# Log a portion of the iframe content for debugging (first 2000 chars)
logger.debug(f"Iframe content sample: {iframe_content[:2000]}")
raise ExtractorError(f"Error extracting parameters: missing {', '.join(missing_params)}")
auth_sig = quote_plus(auth_sig)
# 6. Richiesta auth
# Se il sito fornisce ancora /a.php ma ora serve /auth.php, sostituisci
# Normalize and robustly replace any variant of a.php with /auth.php
if auth_php:
normalized_auth_php = auth_php.strip().lstrip('/')
if normalized_auth_php == 'a.php':
logger.info("Sostituisco qualunque variante di a.php con /auth.php per compatibilità.")
auth_php = '/auth.php'
# Unisci host e script senza doppio slash
if auth_host.endswith('/') and auth_php.startswith('/'):
auth_url = f'{auth_host[:-1]}{auth_php}'
elif not auth_host.endswith('/') and not auth_php.startswith('/'):
auth_url = f'{auth_host}/{auth_php}'
else:
auth_url = f'{auth_host}{auth_php}'
auth_url = f'{auth_url}?channel_id={channel_key}&ts={auth_ts}&rnd={auth_rnd}&sig={auth_sig}'
auth_resp = await self._make_request(auth_url, headers=daddylive_headers)
# 7. Lookup server - Extract host parameter
host = None
host_patterns = [
r'(?s)m3u8 =.*?:.*?:.*?".*?".*?"([^"]*)', # Original pattern
r'm3u8\s*=.*?"([^"]*)"', # Simplified m3u8 pattern
r'host["\']?\s*[:=]\s*["\']([^"\']*)', # host: or host= pattern
r'["\']([^"\']*\.newkso\.ru[^"\']*)', # Direct newkso.ru pattern
r'["\']([^"\']*\/premium\d+[^"\']*)', # premium path pattern
r'url.*?["\']([^"\']*newkso[^"\']*)', # URL with newkso
]
for pattern in host_patterns:
matches = re.findall(pattern, iframe_content)
if matches:
host = matches[0]
logger.debug(f"Found host with pattern '{pattern}': {host}")
break
if not host:
logger.error("Failed to extract host from iframe content")
logger.debug(f"Iframe content for host extraction: {iframe_content[:2000]}")
# Try to find any newkso.ru related URLs
potential_hosts = re.findall(r'["\']([^"\']*newkso[^"\']*)', iframe_content)
if potential_hosts:
logger.debug(f"Potential host URLs found: {potential_hosts}")
raise ExtractorError("Failed to extract host parameter")
# Extract server lookup URL from fetchWithRetry call (dynamic extraction)
server_lookup = None
# Look for the server_lookup.php pattern in JavaScript
if "fetchWithRetry('/server_lookup.php?channel_id='" in iframe_content:
server_lookup = '/server_lookup.php?channel_id='
logger.debug('Found server lookup URL: /server_lookup.php?channel_id=')
elif '/server_lookup.php' in iframe_content:
# Try to extract the full path
js_lines = iframe_content.split('\n')
for js_line in js_lines:
if 'server_lookup.php' in js_line and 'fetchWithRetry' in js_line:
# Extract the URL from the fetchWithRetry call
start = js_line.find("'")
if start != -1:
end = js_line.find("'", start + 1)
if end != -1:
potential_url = js_line[start+1:end]
if 'server_lookup' in potential_url:
server_lookup = potential_url
logger.debug(f'Extracted server lookup URL: {server_lookup}')
break
if not server_lookup:
logger.error('Failed to extract server lookup URL from iframe content')
logger.debug(f'Iframe content sample: {iframe_content[:2000]}')
raise ExtractorError('Failed to extract server lookup URL')
server_lookup_url = f"https://{urlparse(iframe_url).netloc}{server_lookup}{channel_key}"
logger.debug(f"Server lookup URL: {server_lookup_url}")
try:
lookup_resp = await self._make_request(server_lookup_url, headers=daddylive_headers)
server_data = lookup_resp.json()
server_key = server_data.get('server_key')
if not server_key:
logger.error(f"No server_key in response: {server_data}")
raise ExtractorError("Failed to get server key from lookup response")
logger.info(f"Server lookup successful - Server key: {server_key}")
except Exception as lookup_error:
logger.error(f"Server lookup request failed: {lookup_error}")
raise ExtractorError(f"Server lookup failed: {str(lookup_error)}")
referer_raw = f'https://{urlparse(iframe_url).netloc}'
# Extract URL construction logic dynamically from JavaScript
# Simple approach: look for newkso.ru URLs and construct based on server_key
# Check if we have the special case server_key
if server_key == 'top1/cdn':
clean_m3u8_url = f'https://top1.newkso.ru/top1/cdn/{channel_key}/mono.m3u8'
logger.info(f'Using special case URL for server_key \'top1/cdn\': {clean_m3u8_url}')
else:
clean_m3u8_url = f'https://{server_key}new.newkso.ru/{server_key}/{channel_key}/mono.m3u8'
logger.info(f'Using general case URL for server_key \'{server_key}\': {clean_m3u8_url}')
logger.info(f'Generated stream URL: {clean_m3u8_url}')
logger.debug(f'Server key: {server_key}, Channel key: {channel_key}')
# Check if the final stream URL is on newkso.ru domain
if "newkso.ru" in clean_m3u8_url:
# For newkso.ru streams, use iframe URL as referer
stream_headers = {
'User-Agent': daddylive_headers['User-Agent'],
'Referer': iframe_url,
'Origin': referer_raw
}
logger.info(f"Applied iframe-specific headers for newkso.ru stream URL: {clean_m3u8_url}")
logger.debug(f"Stream headers for newkso.ru: {stream_headers}")
else:
# For other domains, use the original logic
stream_headers = {
'User-Agent': daddylive_headers['User-Agent'],
'Referer': referer_raw,
'Origin': referer_raw
}
return {
"destination_url": clean_m3u8_url,
"request_headers": stream_headers,
"mediaflow_endpoint": self.mediaflow_endpoint,
}
try:
clean_url = url
channel_id = extract_channel_id(clean_url)
channel_id = extract_channel_id(url)
if not channel_id:
raise ExtractorError(f"Unable to extract channel ID from {clean_url}")
raise ExtractorError(f"Unable to extract channel ID from {url}")
logger.info(f"Using base domain: {baseurl}")
return await get_stream_data(url)
baseurl = await get_daddylive_base_url()
endpoints = ["stream/", "cast/", "player/", "watch/"]
last_exc = None
for endpoint in endpoints:
try:
return await try_endpoint(baseurl, endpoint, channel_id)
except Exception as exc:
last_exc = exc
continue
raise ExtractorError(f"Extraction failed: {str(last_exc)}")
except Exception as e:
raise ExtractorError(f"Extraction failed: {str(e)}")
async def _lookup_server(
self, lookup_url_base: str, auth_url_base: str, auth_data: Dict[str, str], headers: Dict[str, str]
) -> str:
"""Lookup server information and generate stream URL."""
try:
# Construct server lookup URL
server_lookup_url = f"{lookup_url_base}/server_lookup.php?channel_id={quote(auth_data['channel_key'])}"
# Make server lookup request
server_response = await self._make_request(server_lookup_url, headers=headers)
server_data = server_response.json()
server_key = server_data.get("server_key")
if not server_key:
raise ExtractorError("Failed to get server key")
# Extract domain parts from auth URL for constructing stream URL
auth_domain_parts = urlparse(auth_url_base).netloc.split(".")
domain_suffix = ".".join(auth_domain_parts[1:]) if len(auth_domain_parts) > 1 else auth_domain_parts[0]
# Generate the m3u8 URL based on server response pattern
if "/" in server_key:
# Handle special case like "top1/cdn"
parts = server_key.split("/")
return f"https://{parts[0]}.{domain_suffix}/{server_key}/{auth_data['channel_key']}/mono.m3u8"
else:
# Handle normal case
return f"https://{server_key}new.{domain_suffix}/{server_key}/{auth_data['channel_key']}/mono.m3u8"
except Exception as e:
raise ExtractorError(f"Server lookup failed: {str(e)}")
def _extract_auth_data(self, html_content: str) -> Dict[str, str]:
"""Extract authentication data from player page."""
try:
channel_key_match = re.search(r'var\s+channelKey\s*=\s*["\']([^"\']+)["\']', html_content)
if not channel_key_match:
return {}
channel_key = channel_key_match.group(1)
# New pattern with atob
auth_ts_match = re.search(r'var\s+__c\s*=\s*atob\([\'"]([^\'"]+)[\'"]\)', html_content)
auth_rnd_match = re.search(r'var\s+__d\s*=\s*atob\([\'"]([^\'"]+)[\'"]\)', html_content)
auth_sig_match = re.search(r'var\s+__e\s*=\s*atob\([\'"]([^\'"]+)[\'"]\)', html_content)
if auth_ts_match and auth_rnd_match and auth_sig_match:
return {
"channel_key": channel_key,
"auth_ts": base64.b64decode(auth_ts_match.group(1)).decode("utf-8"),
"auth_rnd": base64.b64decode(auth_rnd_match.group(1)).decode("utf-8"),
"auth_sig": base64.b64decode(auth_sig_match.group(1)).decode("utf-8"),
}
# Original pattern
auth_ts_match = re.search(r'var\s+authTs\s*=\s*["\']([^"\']+)["\']', html_content)
auth_rnd_match = re.search(r'var\s+authRnd\s*=\s*["\']([^"\']+)["\']', html_content)
auth_sig_match = re.search(r'var\s+authSig\s*=\s*["\']([^"\']+)["\']', html_content)
if auth_ts_match and auth_rnd_match and auth_sig_match:
return {
"channel_key": channel_key,
"auth_ts": auth_ts_match.group(1),
"auth_rnd": auth_rnd_match.group(1),
"auth_sig": auth_sig_match.group(1),
}
return {}
except Exception:
return {}
def _extract_auth_url_base(self, html_content: str) -> Optional[str]:
"""Extract auth URL base from player page script content."""
try:
# New atob pattern for auth base URL
auth_url_base_match = re.search(r'var\s+__a\s*=\s*atob\([\'"]([^\'"]+)[\'"]\)', html_content)
if auth_url_base_match:
decoded_url = base64.b64decode(auth_url_base_match.group(1)).decode("utf-8")
return decoded_url.strip().rstrip("/")
# Look for auth URL or domain in fetchWithRetry call or similar patterns
auth_url_match = re.search(r'fetchWithRetry\([\'"]([^\'"]*/auth\.php)', html_content)
if auth_url_match:
auth_url = auth_url_match.group(1)
# Extract base URL up to the auth.php part
return auth_url.split("/auth.php")[0]
# Try finding domain directly
domain_match = re.search(r'[\'"]https://([^/\'\"]+)(?:/[^\'\"]*)?/auth\.php', html_content)
if domain_match:
return f"https://{domain_match.group(1)}"
return None
except Exception:
return None
def _get_origin(self, url: str) -> str:
"""Extract origin from URL."""
parsed = urlparse(url)
return f"{parsed.scheme}://{parsed.netloc}"
def _derive_auth_url_base(self, player_domain: str) -> Optional[str]:
"""Attempt to derive auth URL base from player domain."""
try:
# Typical pattern is to use a subdomain for auth domain
parsed = urlparse(player_domain)
domain_parts = parsed.netloc.split(".")
# Get the top-level domain and second-level domain
if len(domain_parts) >= 2:
base_domain = ".".join(domain_parts[-2:])
# Try common subdomains for auth
for prefix in ["auth", "api", "cdn"]:
potential_auth_domain = f"https://{prefix}.{base_domain}"
return potential_auth_domain
return None
except Exception:
return None

View File

@@ -3,17 +3,27 @@ from typing import Dict, Type
from mediaflow_proxy.extractors.base import BaseExtractor, ExtractorError
from mediaflow_proxy.extractors.dlhd import DLHDExtractor
from mediaflow_proxy.extractors.doodstream import DoodStreamExtractor
from mediaflow_proxy.extractors.sportsonline import SportsonlineExtractor
from mediaflow_proxy.extractors.filelions import FileLionsExtractor
from mediaflow_proxy.extractors.filemoon import FileMoonExtractor
from mediaflow_proxy.extractors.F16Px import F16PxExtractor
from mediaflow_proxy.extractors.livetv import LiveTVExtractor
from mediaflow_proxy.extractors.lulustream import LuluStreamExtractor
from mediaflow_proxy.extractors.maxstream import MaxstreamExtractor
from mediaflow_proxy.extractors.mixdrop import MixdropExtractor
from mediaflow_proxy.extractors.okru import OkruExtractor
from mediaflow_proxy.extractors.streamtape import StreamtapeExtractor
from mediaflow_proxy.extractors.streamwish import StreamWishExtractor
from mediaflow_proxy.extractors.supervideo import SupervideoExtractor
from mediaflow_proxy.extractors.turbovidplay import TurboVidPlayExtractor
from mediaflow_proxy.extractors.uqload import UqloadExtractor
from mediaflow_proxy.extractors.vavoo import VavooExtractor
from mediaflow_proxy.extractors.vidmoly import VidmolyExtractor
from mediaflow_proxy.extractors.vidoza import VidozaExtractor
from mediaflow_proxy.extractors.vixcloud import VixCloudExtractor
from mediaflow_proxy.extractors.fastream import FastreamExtractor
from mediaflow_proxy.extractors.voe import VoeExtractor
class ExtractorFactory:
"""Factory for creating URL extractors."""
@@ -21,17 +31,26 @@ class ExtractorFactory:
_extractors: Dict[str, Type[BaseExtractor]] = {
"Doodstream": DoodStreamExtractor,
"FileLions": FileLionsExtractor,
"FileMoon": FileMoonExtractor,
"F16Px": F16PxExtractor,
"Uqload": UqloadExtractor,
"Mixdrop": MixdropExtractor,
"Streamtape": StreamtapeExtractor,
"StreamWish": StreamWishExtractor,
"Supervideo": SupervideoExtractor,
"TurboVidPlay": TurboVidPlayExtractor,
"VixCloud": VixCloudExtractor,
"Okru": OkruExtractor,
"Maxstream": MaxstreamExtractor,
"LiveTV": LiveTVExtractor,
"LuluStream": LuluStreamExtractor,
"DLHD": DLHDExtractor,
"Vavoo": VavooExtractor,
"Fastream": FastreamExtractor
"Vidmoly": VidmolyExtractor,
"Vidoza": VidozaExtractor,
"Fastream": FastreamExtractor,
"Voe": VoeExtractor,
"Sportsonline": SportsonlineExtractor,
}
@classmethod

View File

@@ -12,7 +12,8 @@ class FileLionsExtractor(BaseExtractor):
headers = {}
patterns = [ # See https://github.com/Gujal00/ResolveURL/blob/master/script.module.resolveurl/lib/resolveurl/plugins/filelions.py
r'''sources:\s*\[{file:\s*["'](?P<url>[^"']+)''',
r'''["']hls[24]["']:\s*["'](?P<url>[^"']+)'''
r'''["']hls4["']:\s*["'](?P<url>[^"']+)''',
r'''["']hls2["']:\s*["'](?P<url>[^"']+)'''
]
final_url = await eval_solver(self, url, headers, patterns)

View 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,
}

View 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,
}

View 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)}")

View 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

View 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",
}

View File

@@ -4,24 +4,31 @@ from mediaflow_proxy.extractors.base import BaseExtractor, ExtractorError
logger = logging.getLogger(__name__)
class VavooExtractor(BaseExtractor):
"""Vavoo URL extractor for resolving vavoo.to links (solo httpx, async)."""
"""Vavoo URL extractor for resolving vavoo.to links.
Features:
- Uses BaseExtractor's retry/timeouts
- Improved headers to mimic Android okhttp client
- Robust JSON handling and logging
"""
def __init__(self, request_headers: dict):
super().__init__(request_headers)
self.mediaflow_endpoint = "proxy_stream_endpoint"
async def get_auth_signature(self) -> Optional[str]:
"""Get authentication signature for Vavoo API (async, httpx, pulito)."""
"""Get authentication signature for Vavoo API (async)."""
headers = {
"user-agent": "okhttp/4.11.0",
"accept": "application/json",
"accept": "application/json",
"content-type": "application/json; charset=utf-8",
"accept-encoding": "gzip"
"accept-encoding": "gzip",
}
import time
current_time = int(time.time() * 1000)
data = {
"token": "tosFwQCJMS8qrW_AjLoHPQ41646J5dRNha6ZWHnijoYQQQoADQoXYSo7ki7O5-CsgN4CH0uRk6EEoJ0728ar9scCRQW3ZkbfrPfeCXW2VgopSW2FWDqPOoVYIuVPAOnXCZ5g",
"reason": "app-blur",
@@ -37,23 +44,17 @@ class VavooExtractor(BaseExtractor):
},
"os": {
"name": "android",
"version": "13",
"abis": ["arm64-v8a", "armeabi-v7a", "armeabi"],
"host": "android"
"version": "13"
},
"app": {
"platform": "android",
"version": "3.1.21",
"buildId": "289515000",
"engine": "hbc85",
"signatures": ["6e8a975e3cbf07d5de823a760d4c2547f86c1403105020adee5de67ac510999e"],
"installer": "app.revanced.manager.flutter"
"version": "3.1.21"
},
"version": {
"package": "tv.vavoo.app",
"binary": "3.1.21",
"js": "3.1.21"
}
},
},
"appFocusTime": 0,
"playerActive": False,
@@ -70,7 +71,7 @@ class VavooExtractor(BaseExtractor):
"adblockEnabled": True,
"proxy": {
"supported": ["ss", "openvpn"],
"engine": "ss",
"engine": "ss",
"ssVersion": 1,
"enabled": True,
"autoServer": True,
@@ -80,44 +81,48 @@ class VavooExtractor(BaseExtractor):
"supported": False
}
}
try:
resp = await self._make_request(
"https://www.vavoo.tv/api/app/ping",
method="POST",
json=data,
headers=headers
headers=headers,
timeout=10,
retries=2,
)
result = resp.json()
addon_sig = result.get("addonSig")
try:
result = resp.json()
except Exception:
logger.warning("Vavoo ping returned non-json response (status=%s).", resp.status_code)
return None
addon_sig = result.get("addonSig") if isinstance(result, dict) else None
if addon_sig:
logger.info("Successfully obtained Vavoo authentication signature")
return addon_sig
else:
logger.warning("No addonSig in Vavoo API response")
logger.warning("No addonSig in Vavoo API response: %s", result)
return None
except Exception as e:
logger.exception(f"Failed to get Vavoo authentication signature: {str(e)}")
except ExtractorError as e:
logger.warning("Failed to get Vavoo auth signature: %s", e)
return None
async def extract(self, url: str, **kwargs) -> Dict[str, Any]:
"""Extract Vavoo stream URL (async, httpx)."""
"""Extract Vavoo stream URL (async)."""
if "vavoo.to" not in url:
raise ExtractorError("Not a valid Vavoo URL")
# Get authentication signature
signature = await self.get_auth_signature()
if not signature:
raise ExtractorError("Failed to get Vavoo authentication signature")
# Resolve the URL
resolved_url = await self._resolve_vavoo_link(url, signature)
if not resolved_url:
raise ExtractorError("Failed to resolve Vavoo URL")
# Set up headers for the resolved stream
stream_headers = {
"user-agent": self.base_headers["user-agent"],
"user-agent": self.base_headers.get("user-agent", "okhttp/4.11.0"),
"referer": "https://vavoo.to/",
}
@@ -128,17 +133,17 @@ class VavooExtractor(BaseExtractor):
}
async def _resolve_vavoo_link(self, link: str, signature: str) -> Optional[str]:
"""Resolve a Vavoo link using the MediaHubMX API (async, httpx)."""
"""Resolve a Vavoo link using the MediaHubMX API (async)."""
headers = {
"user-agent": "MediaHubMX/2",
"user-agent": "okhttp/4.11.0",
"accept": "application/json",
"content-type": "application/json; charset=utf-8",
"content-type": "application/json; charset=utf-8",
"accept-encoding": "gzip",
"mediahubmx-signature": signature
}
data = {
"language": "de",
"region": "AT",
"region": "AT",
"url": link,
"clientVersion": "3.1.21"
}
@@ -148,22 +153,34 @@ class VavooExtractor(BaseExtractor):
"https://vavoo.to/mediahubmx-resolve.json",
method="POST",
json=data,
headers=headers
headers=headers,
timeout=12,
retries=3,
backoff_factor=0.6,
)
result = resp.json()
logger.info(f"Vavoo API response: {result}")
if isinstance(result, list) and result and result[0].get("url"):
try:
result = resp.json()
except Exception:
logger.warning("Vavoo resolve returned non-json response (status=%s). Body preview: %s", resp.status_code, getattr(resp, "text", "")[:500])
return None
logger.debug("Vavoo API response: %s", result)
# Accept either list or dict with 'url'
if isinstance(result, list) and result and isinstance(result[0], dict) and result[0].get("url"):
resolved_url = result[0]["url"]
logger.info(f"Successfully resolved Vavoo URL to: {resolved_url}")
logger.info("Successfully resolved Vavoo URL to: %s", resolved_url)
return resolved_url
elif isinstance(result, dict) and result.get("url"):
resolved_url = result["url"]
logger.info(f"Successfully resolved Vavoo URL to: {resolved_url}")
logger.info("Successfully resolved Vavoo URL to: %s", resolved_url)
return resolved_url
else:
logger.warning(f"No URL found in Vavoo API response: {result}")
logger.warning("No URL found in Vavoo API response: %s", result)
return None
except Exception as e:
logger.exception(f"Vavoo resolution failed for URL {link}: {str(e)}")
except ExtractorError as e:
logger.error(f"Vavoo resolution failed for URL {link}: {e}")
raise ExtractorError(f"Vavoo resolution failed: {str(e)}") from e
except Exception as e:
logger.error(f"Unexpected error while resolving Vavoo URL {link}: {e}")
raise ExtractorError(f"Vavoo resolution failed: {str(e)}") from e

View 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,
}

View 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 doesnt 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,
}

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

View File

@@ -22,6 +22,7 @@ from .utils.http_utils import (
)
from .utils.m3u8_processor import M3U8Processor
from .utils.mpd_utils import pad_base64
from .configs import settings
logger = logging.getLogger(__name__)
@@ -102,7 +103,8 @@ async def handle_hls_stream_proxy(
# If force_playlist_proxy is enabled, skip detection and directly process as m3u8
if hls_params.force_playlist_proxy:
return await fetch_and_process_m3u8(
streamer, hls_params.destination, proxy_headers, request, hls_params.key_url, hls_params.force_playlist_proxy
streamer, hls_params.destination, proxy_headers, request,
hls_params.key_url, hls_params.force_playlist_proxy, hls_params.key_only_proxy, hls_params.no_proxy
)
parsed_url = urlparse(hls_params.destination)
@@ -111,7 +113,8 @@ async def handle_hls_stream_proxy(
0
] in ["m3u", "m3u8", "m3u_plus"]:
return await fetch_and_process_m3u8(
streamer, hls_params.destination, proxy_headers, request, hls_params.key_url, hls_params.force_playlist_proxy
streamer, hls_params.destination, proxy_headers, request,
hls_params.key_url, hls_params.force_playlist_proxy, hls_params.key_only_proxy, hls_params.no_proxy
)
# Create initial streaming response to check content type
@@ -120,7 +123,8 @@ async def handle_hls_stream_proxy(
if "mpegurl" in response_headers.get("content-type", "").lower():
return await fetch_and_process_m3u8(
streamer, hls_params.destination, proxy_headers, request, hls_params.key_url, hls_params.force_playlist_proxy
streamer, hls_params.destination, proxy_headers, request,
hls_params.key_url, hls_params.force_playlist_proxy, hls_params.key_only_proxy, hls_params.no_proxy
)
return EnhancedStreamingResponse(
@@ -224,7 +228,14 @@ async def proxy_stream(method: str, destination: str, proxy_headers: ProxyReques
async def fetch_and_process_m3u8(
streamer: Streamer, url: str, proxy_headers: ProxyRequestHeaders, request: Request, key_url: str = None, force_playlist_proxy: bool = None
streamer: Streamer,
url: str,
proxy_headers: ProxyRequestHeaders,
request: Request,
key_url: str = None,
force_playlist_proxy: bool = None,
key_only_proxy: bool = False,
no_proxy: bool = False
):
"""
Fetches and processes the m3u8 playlist on-the-fly, converting it to an HLS playlist.
@@ -236,6 +247,8 @@ async def fetch_and_process_m3u8(
request (Request): The incoming HTTP request.
key_url (str, optional): The HLS Key URL to replace the original key URL. Defaults to None.
force_playlist_proxy (bool, optional): Force all playlist URLs to be proxied through MediaFlow. Defaults to None.
key_only_proxy (bool, optional): Only proxy the key URL, leaving segment URLs direct. Defaults to False.
no_proxy (bool, optional): If True, returns the manifest without proxying any URLs. Defaults to False.
Returns:
Response: The HTTP response with the processed m3u8 playlist.
@@ -246,7 +259,7 @@ async def fetch_and_process_m3u8(
await streamer.create_streaming_response(url, proxy_headers.request)
# Initialize processor and response headers
processor = M3U8Processor(request, key_url, force_playlist_proxy)
processor = M3U8Processor(request, key_url, force_playlist_proxy, key_only_proxy, no_proxy)
response_headers = {
"content-disposition": "inline",
"accept-ranges": "none",
@@ -378,7 +391,13 @@ async def get_segment(
Response: The HTTP response with the processed segment.
"""
try:
init_content = await get_cached_init_segment(segment_params.init_url, proxy_headers.request)
live_cache_ttl = settings.mpd_live_init_cache_ttl if segment_params.is_live else None
init_content = await get_cached_init_segment(
segment_params.init_url,
proxy_headers.request,
cache_token=segment_params.key_id,
ttl=live_cache_ttl,
)
segment_content = await download_file_with_retry(segment_params.segment_url, proxy_headers.request)
except Exception as e:
return handle_exceptions(e)

View File

@@ -192,30 +192,34 @@ def build_hls_playlist(mpd_dict: dict, profiles: list[dict], request: Request) -
logger.warning(f"No segments found for profile {profile['id']}")
continue
if mpd_dict["isLive"]:
depth = max(settings.mpd_live_playlist_depth, 1)
trimmed_segments = segments[-depth:]
else:
trimmed_segments = segments
# Add headers for only the first profile
if index == 0:
first_segment = segments[0]
extinf_values = [f["extinf"] for f in segments if "extinf" in f]
first_segment = trimmed_segments[0]
extinf_values = [f["extinf"] for f in trimmed_segments if "extinf" in f]
target_duration = math.ceil(max(extinf_values)) if extinf_values else 3
# Calculate media sequence using adaptive logic for different MPD types
# Align HLS media sequence with MPD-provided numbering when available
mpd_start_number = profile.get("segment_template_start_number")
if mpd_start_number and mpd_start_number >= 1000:
# Amazon-style: Use absolute segment numbering
sequence = first_segment.get("number", mpd_start_number)
else:
# Sky-style: Use time-based calculation if available
time_val = first_segment.get("time")
duration_val = first_segment.get("duration_mpd_timescale")
if time_val is not None and duration_val and duration_val > 0:
calculated_sequence = math.floor(time_val / duration_val)
# For live streams with very large sequence numbers, use modulo to keep reasonable range
if mpd_dict.get("isLive", False) and calculated_sequence > 100000:
sequence = calculated_sequence % 100000
else:
sequence = calculated_sequence
sequence = first_segment.get("number")
if sequence is None:
# Fallback to MPD template start number
if mpd_start_number is not None:
sequence = mpd_start_number
else:
sequence = first_segment.get("number", 1)
# As a last resort, derive from timeline information
time_val = first_segment.get("time")
duration_val = first_segment.get("duration_mpd_timescale")
if time_val is not None and duration_val and duration_val > 0:
sequence = math.floor(time_val / duration_val)
else:
sequence = 1
hls.extend(
[
@@ -235,10 +239,18 @@ def build_hls_playlist(mpd_dict: dict, profiles: list[dict], request: Request) -
query_params.pop("d", None)
has_encrypted = query_params.pop("has_encrypted", False)
for segment in segments:
for segment in trimmed_segments:
program_date_time = segment.get("program_date_time")
if program_date_time:
hls.append(f"#EXT-X-PROGRAM-DATE-TIME:{program_date_time}")
hls.append(f'#EXTINF:{segment["extinf"]:.3f},')
query_params.update(
{"init_url": init_url, "segment_url": segment["media"], "mime_type": profile["mimeType"]}
{
"init_url": init_url,
"segment_url": segment["media"],
"mime_type": profile["mimeType"],
"is_live": "true" if mpd_dict.get("isLive") else "false",
}
)
hls.append(
encode_mediaflow_proxy_url(

View File

@@ -1,7 +1,7 @@
import logging
from typing import Annotated
from fastapi import APIRouter, Query, HTTPException, Request, Depends
from fastapi import APIRouter, Query, HTTPException, Request, Depends, BackgroundTasks
from fastapi.responses import RedirectResponse
from mediaflow_proxy.extractors.base import ExtractorError
@@ -9,6 +9,7 @@ from mediaflow_proxy.extractors.factory import ExtractorFactory
from mediaflow_proxy.schemas import ExtractorURLParams
from mediaflow_proxy.utils.cache_utils import get_cached_extractor_result, set_cache_extractor_result
from mediaflow_proxy.utils.http_utils import (
DownloadError,
encode_mediaflow_proxy_url,
get_original_scheme,
ProxyRequestHeaders,
@@ -19,12 +20,24 @@ from mediaflow_proxy.utils.base64_utils import process_potential_base64_url
extractor_router = APIRouter()
logger = logging.getLogger(__name__)
async def refresh_extractor_cache(cache_key: str, extractor_params: ExtractorURLParams, proxy_headers: ProxyRequestHeaders):
"""Asynchronously refreshes the extractor cache in the background."""
try:
logger.info(f"Background cache refresh started for key: {cache_key}")
extractor = ExtractorFactory.get_extractor(extractor_params.host, proxy_headers.request)
response = await extractor.extract(extractor_params.destination, **extractor_params.extra_params)
await set_cache_extractor_result(cache_key, response)
logger.info(f"Background cache refresh completed for key: {cache_key}")
except Exception as e:
logger.error(f"Background cache refresh failed for key {cache_key}: {e}")
@extractor_router.head("/video")
@extractor_router.get("/video")
async def extract_url(
extractor_params: Annotated[ExtractorURLParams, Query()],
request: Request,
background_tasks: BackgroundTasks,
proxy_headers: Annotated[ProxyRequestHeaders, Depends(get_proxy_headers)],
):
"""Extract clean links from various video hosting services."""
@@ -35,13 +48,21 @@ async def extract_url(
cache_key = f"{extractor_params.host}_{extractor_params.model_dump_json()}"
response = await get_cached_extractor_result(cache_key)
if not response:
if response:
logger.info(f"Serving from cache for key: {cache_key}")
# Schedule a background task to refresh the cache without blocking the user
background_tasks.add_task(refresh_extractor_cache, cache_key, extractor_params, proxy_headers)
else:
logger.info(f"Cache miss for key: {cache_key}. Fetching fresh data.")
extractor = ExtractorFactory.get_extractor(extractor_params.host, proxy_headers.request)
response = await extractor.extract(extractor_params.destination, **extractor_params.extra_params)
await set_cache_extractor_result(cache_key, response)
else:
response["request_headers"].update(proxy_headers.request)
# Ensure the latest request headers are used, even with cached data
if "request_headers" not in response:
response["request_headers"] = {}
response["request_headers"].update(proxy_headers.request)
response["mediaflow_proxy_url"] = str(
request.url_for(response.pop("mediaflow_endpoint")).replace(scheme=get_original_scheme(request))
)
@@ -49,6 +70,12 @@ async def extract_url(
# Add API password to query params
response["query_params"]["api_password"] = request.query_params.get("api_password")
if "max_res" in request.query_params:
response["query_params"]["max_res"] = request.query_params.get("max_res")
if "no_proxy" in request.query_params:
response["query_params"]["no_proxy"] = request.query_params.get("no_proxy")
if extractor_params.redirect_stream:
stream_url = encode_mediaflow_proxy_url(
**response,
@@ -58,6 +85,9 @@ async def extract_url(
return response
except DownloadError as e:
logger.error(f"Extraction failed: {str(e)}")
raise HTTPException(status_code=e.status_code, detail=str(e))
except ExtractorError as e:
logger.error(f"Extraction failed: {str(e)}")
raise HTTPException(status_code=400, detail=str(e))

View File

@@ -20,6 +20,7 @@ def rewrite_m3u_links_streaming(m3u_lines_iterator: Iterator[str], base_url: str
includendo gli headers da #EXTVLCOPT e #EXTHTTP. Yields rewritten lines.
"""
current_ext_headers: Dict[str, str] = {} # Dizionario per conservare gli headers dalle direttive
current_kodi_props: Dict[str, str] = {} # Dizionario per conservare le proprietà KODI
for line_with_newline in m3u_lines_iterator:
line_content = line_with_newline.rstrip('\n')
@@ -27,6 +28,9 @@ def rewrite_m3u_links_streaming(m3u_lines_iterator: Iterator[str], base_url: str
is_header_tag = False
if logical_line.startswith('#EXTVLCOPT:'):
# Yield the original line to preserve it
yield line_with_newline
is_header_tag = True
try:
option_str = logical_line.split(':', 1)[1]
@@ -43,12 +47,15 @@ def rewrite_m3u_links_streaming(m3u_lines_iterator: Iterator[str], base_url: str
current_ext_headers[header_key] = header_value
elif key_vlc.startswith('http-'):
# Gestisce http-user-agent, http-referer etc.
header_key = '-'.join(word.capitalize() for word in key_vlc[len('http-'):].split('-'))
header_key = key_vlc[len('http-'):]
current_ext_headers[header_key] = value_vlc
except Exception as e:
logger.error(f"⚠️ Error parsing #EXTVLCOPT '{logical_line}': {e}")
elif logical_line.startswith('#EXTHTTP:'):
# Yield the original line to preserve it
yield line_with_newline
is_header_tag = True
try:
json_str = logical_line.split(':', 1)[1]
@@ -57,9 +64,22 @@ def rewrite_m3u_links_streaming(m3u_lines_iterator: Iterator[str], base_url: str
except Exception as e:
logger.error(f"⚠️ Error parsing #EXTHTTP '{logical_line}': {e}")
current_ext_headers = {} # Resetta in caso di errore
elif logical_line.startswith('#KODIPROP:'):
# Yield the original line to preserve it
yield line_with_newline
is_header_tag = True
try:
prop_str = logical_line.split(':', 1)[1]
if '=' in prop_str:
key_kodi, value_kodi = prop_str.split('=', 1)
current_kodi_props[key_kodi.strip()] = value_kodi.strip()
except Exception as e:
logger.error(f"⚠️ Error parsing #KODIPROP '{logical_line}': {e}")
if is_header_tag:
yield line_with_newline
continue
if logical_line and not logical_line.startswith('#') and \
@@ -75,47 +95,55 @@ def rewrite_m3u_links_streaming(m3u_lines_iterator: Iterator[str], base_url: str
processed_url_content = f"{base_url}/proxy/hls/manifest.m3u8?d={encoded_url}"
elif 'vixsrc.to' in logical_line:
encoded_url = urllib.parse.quote(logical_line, safe='')
processed_url_content = f"{base_url}/extractor/video?host=VixCloud&redirect_stream=true&d={encoded_url}"
processed_url_content = f"{base_url}/extractor/video?host=VixCloud&redirect_stream=true&d={encoded_url}&max_res=true&no_proxy=true"
elif '.m3u8' in logical_line:
encoded_url = urllib.parse.quote(logical_line, safe='')
processed_url_content = f"{base_url}/proxy/hls/manifest.m3u8?d={encoded_url}"
elif '.mpd' in logical_line:
# Estrai parametri DRM dall'URL MPD se presenti
# Estrai parametri DRM dall'URL MPD se presenti (es. &key_id=...&key=...)
from urllib.parse import urlparse, parse_qs, urlencode, urlunparse
# Parse dell'URL per estrarre parametri
parsed_url = urlparse(logical_line)
query_params = parse_qs(parsed_url.query)
# Estrai key_id e key se presenti
# Estrai key_id e key se presenti nei parametri della query
key_id = query_params.get('key_id', [None])[0]
key = query_params.get('key', [None])[0]
# Rimuovi key_id e key dai parametri originali
clean_params = {k: v for k, v in query_params.items() if k not in ['key_id', 'key']}
clean_query_params = {k: v for k, v in query_params.items() if k not in ['key_id', 'key']}
# Ricostruisci l'URL senza i parametri DRM
clean_query = urlencode(clean_params, doseq=True) if clean_params else ''
clean_query = urlencode(clean_query_params, doseq=True)
clean_url = urlunparse((
parsed_url.scheme,
parsed_url.netloc,
parsed_url.path,
parsed_url.params,
clean_query,
parsed_url.fragment
'' # Rimuovi il frammento per evitare problemi
))
# Encode the MPD URL like other URL types
clean_url_for_param = urllib.parse.quote(clean_url, safe='')
# Costruisci l'URL MediaFlow con parametri DRM separati
processed_url_content = f"{base_url}/proxy/mpd/manifest.m3u8?d={clean_url_for_param}"
# Codifica l'URL pulito per il parametro 'd'
encoded_clean_url = urllib.parse.quote(clean_url, safe='')
# Aggiungi parametri DRM se presenti
# Costruisci l'URL MediaFlow con parametri DRM separati
processed_url_content = f"{base_url}/proxy/mpd/manifest.m3u8?d={encoded_clean_url}"
# Aggiungi i parametri DRM all'URL di MediaFlow se sono stati trovati
if key_id:
processed_url_content += f"&key_id={key_id}"
if key:
processed_url_content += f"&key={key}"
# Aggiungi chiavi da #KODIPROP se presenti
license_key = current_kodi_props.get('inputstream.adaptive.license_key')
if license_key and ':' in license_key:
key_id_kodi, key_kodi = license_key.split(':', 1)
processed_url_content += f"&key_id={key_id_kodi}"
processed_url_content += f"&key={key_kodi}"
elif '.php' in logical_line:
encoded_url = urllib.parse.quote(logical_line, safe='')
processed_url_content = f"{base_url}/proxy/hls/manifest.m3u8?d={encoded_url}"
@@ -130,6 +158,9 @@ def rewrite_m3u_links_streaming(m3u_lines_iterator: Iterator[str], base_url: str
processed_url_content += header_params_str
current_ext_headers = {}
# Resetta le proprietà KODI dopo averle usate
current_kodi_props = {}
# Aggiungi api_password sempre alla fine
if api_password:
processed_url_content += f"&api_password={api_password}"
@@ -150,7 +181,7 @@ async def async_download_m3u_playlist(url: str) -> list[str]:
}
lines = []
try:
async with httpx.AsyncClient(verify=True, timeout=30) as client:
async with httpx.AsyncClient(verify=True, timeout=30, follow_redirects=True) as client:
async with client.stream('GET', url, headers=headers) as response:
response.raise_for_status()
async for line_bytes in response.aiter_lines():
@@ -164,47 +195,135 @@ async def async_download_m3u_playlist(url: str) -> list[str]:
raise
return lines
def parse_channel_entries(lines: list[str]) -> list[list[str]]:
"""
Analizza le linee di una playlist M3U e le raggruppa in entry di canali.
Ogni entry è una lista di linee che compongono un singolo canale
(da #EXTINF fino all'URL, incluse le righe intermedie).
"""
entries = []
current_entry = []
for line in lines:
stripped_line = line.strip()
if stripped_line.startswith('#EXTINF:'):
if current_entry: # In caso di #EXTINF senza URL precedente
logger.warning(f"Found a new #EXTINF tag before a URL was found for the previous entry. Discarding: {current_entry}")
current_entry = [line]
elif current_entry:
current_entry.append(line)
if stripped_line and not stripped_line.startswith('#'):
entries.append(current_entry)
current_entry = []
return entries
async def async_generate_combined_playlist(playlist_definitions: list[str], base_url: str, api_password: Optional[str]):
"""Genera una playlist combinata da multiple definizioni, scaricando in parallelo."""
# Prepara gli URL
playlist_urls = []
# Prepara i task di download
download_tasks = []
for definition in playlist_definitions:
if '&' in definition:
parts = definition.split('&', 1)
playlist_url_str = parts[1] if len(parts) > 1 else parts[0]
should_proxy = True
playlist_url_str = definition
should_sort = False
if definition.startswith('sort:'):
should_sort = True
definition = definition[len('sort:'):]
if definition.startswith('no_proxy:'): # Può essere combinato con sort:
should_proxy = False
playlist_url_str = definition[len('no_proxy:'):]
else:
playlist_url_str = definition
playlist_urls.append(playlist_url_str)
download_tasks.append({
"url": playlist_url_str,
"proxy": should_proxy,
"sort": should_sort
})
# Scarica tutte le playlist in parallelo
results = await asyncio.gather(*[async_download_m3u_playlist(url) for url in playlist_urls], return_exceptions=True)
first_playlist_header_handled = False
for idx, lines in enumerate(results):
if isinstance(lines, Exception):
yield f"# ERROR processing playlist {playlist_urls[idx]}: {str(lines)}\n"
results = await asyncio.gather(*[async_download_m3u_playlist(task["url"]) for task in download_tasks], return_exceptions=True)
# Raggruppa le playlist da ordinare e quelle da non ordinare
sorted_playlist_lines = []
unsorted_playlists_data = []
for idx, result in enumerate(results):
task_info = download_tasks[idx]
if isinstance(result, Exception):
# Aggiungi errore come playlist non ordinata
unsorted_playlists_data.append({'lines': [f"# ERROR processing playlist {task_info['url']}: {str(result)}\n"], 'proxy': False})
continue
playlist_lines: list[str] = lines # type: ignore
current_playlist_had_lines = False
first_line_of_this_segment = True
lines_processed_for_current_playlist = 0
rewritten_lines_iter = rewrite_m3u_links_streaming(iter(playlist_lines), base_url, api_password)
for line in rewritten_lines_iter:
current_playlist_had_lines = True
is_extm3u_line = line.strip().startswith('#EXTM3U')
lines_processed_for_current_playlist += 1
if not first_playlist_header_handled:
yield line
if is_extm3u_line:
if task_info.get("sort", False):
sorted_playlist_lines.extend(result)
else:
unsorted_playlists_data.append({'lines': result, 'proxy': task_info['proxy']})
# Gestione dell'header #EXTM3U
first_playlist_header_handled = False
def yield_header_once(lines_iter):
nonlocal first_playlist_header_handled
has_header = False
for line in lines_iter:
is_extm3u = line.strip().startswith('#EXTM3U')
if is_extm3u:
has_header = True
if not first_playlist_header_handled:
first_playlist_header_handled = True
else:
if first_line_of_this_segment and is_extm3u_line:
pass
else:
yield line
first_line_of_this_segment = False
if current_playlist_had_lines and not first_playlist_header_handled:
else:
yield line
if has_header and not first_playlist_header_handled:
first_playlist_header_handled = True
# 1. Processa e ordina le playlist marcate con 'sort'
if sorted_playlist_lines:
# Estrai le entry dei canali
# Modifica: Estrai le entry e mantieni l'informazione sul proxy
channel_entries_with_proxy_info = []
for idx, result in enumerate(results):
task_info = download_tasks[idx]
if task_info.get("sort") and isinstance(result, list):
entries = parse_channel_entries(result) # result è la lista di linee della playlist
for entry_lines in entries:
# L'opzione proxy si applica a tutto il blocco del canale
channel_entries_with_proxy_info.append((entry_lines, task_info["proxy"]))
# Ordina le entry in base al nome del canale (da #EXTINF)
# La prima riga di ogni entry è sempre #EXTINF
channel_entries_with_proxy_info.sort(key=lambda x: x[0][0].split(',')[-1].strip())
# Gestisci l'header una sola volta per il blocco ordinato
if not first_playlist_header_handled:
yield "#EXTM3U\n"
first_playlist_header_handled = True
# Applica la riscrittura dei link in modo selettivo
for entry_lines, should_proxy in channel_entries_with_proxy_info:
# L'URL è l'ultima riga dell'entry
url = entry_lines[-1]
# Yield tutte le righe prima dell'URL
for line in entry_lines[:-1]:
yield line
if should_proxy:
# Usa un iteratore fittizio per processare una sola linea
rewritten_url_iter = rewrite_m3u_links_streaming(iter([url]), base_url, api_password)
yield next(rewritten_url_iter, url) # Prende l'URL riscritto, con fallback all'originale
else:
yield url # Lascia l'URL invariato
# 2. Accoda le playlist non ordinate
for playlist_data in unsorted_playlists_data:
lines_iterator = iter(playlist_data['lines'])
if playlist_data['proxy']:
lines_iterator = rewrite_m3u_links_streaming(lines_iterator, base_url, api_password)
for line in yield_header_once(lines_iterator):
yield line
@playlist_builder_router.get("/playlist")

View File

@@ -1,10 +1,13 @@
import asyncio
from typing import Annotated
from urllib.parse import quote, unquote
import re
import logging
import httpx
import time
from fastapi import Request, Depends, APIRouter, Query, HTTPException
from fastapi.responses import Response, RedirectResponse
from fastapi.responses import Response
from mediaflow_proxy.handlers import (
handle_hls_stream_proxy,
@@ -21,11 +24,22 @@ from mediaflow_proxy.schemas import (
HLSManifestParams,
MPDManifestParams,
)
from mediaflow_proxy.utils.http_utils import get_proxy_headers, ProxyRequestHeaders
from mediaflow_proxy.utils.http_utils import (
get_proxy_headers,
ProxyRequestHeaders,
create_httpx_client,
)
from mediaflow_proxy.utils.base64_utils import process_potential_base64_url
proxy_router = APIRouter()
# DLHD extraction cache: {original_url: {"data": extraction_result, "timestamp": time.time()}}
_dlhd_extraction_cache = {}
_dlhd_cache_duration = 600 # 10 minutes in seconds
_sportsonline_extraction_cache = {}
_sportsonline_cache_duration = 600 # 10 minutes in seconds
def sanitize_url(url: str) -> str:
"""
@@ -122,41 +136,146 @@ def extract_drm_params_from_url(url: str) -> tuple[str, str, str]:
return clean_url, key_id, key
def _check_and_redirect_dlhd_stream(request: Request, destination: str) -> RedirectResponse | None:
def _invalidate_dlhd_cache(destination: str):
"""Invalidate DLHD cache for a specific destination URL."""
if destination in _dlhd_extraction_cache:
del _dlhd_extraction_cache[destination]
logger = logging.getLogger(__name__)
logger.info(f"DLHD cache invalidated for: {destination}")
async def _check_and_extract_dlhd_stream(
request: Request,
destination: str,
proxy_headers: ProxyRequestHeaders,
force_refresh: bool = False
) -> dict | None:
"""
Check if destination contains stream-{numero} pattern and redirect to extractor if needed.
Check if destination contains DLHD/DaddyLive patterns and extract stream directly.
Uses caching to avoid repeated extractions (10 minute cache).
Args:
request (Request): The incoming HTTP request.
destination (str): The destination URL to check.
proxy_headers (ProxyRequestHeaders): The headers to include in the request.
force_refresh (bool): Force re-extraction even if cached data exists.
Returns:
RedirectResponse | None: RedirectResponse if redirect is needed, None otherwise.
dict | None: Extracted stream data if DLHD link detected, None otherwise.
"""
import re
from urllib.parse import urlparse
from mediaflow_proxy.extractors.factory import ExtractorFactory
from mediaflow_proxy.extractors.base import ExtractorError
from mediaflow_proxy.utils.http_utils import DownloadError
# Check for stream-{numero} pattern (e.g., stream-1, stream-123, etc.)
if re.search(r'stream-\d+', destination):
from urllib.parse import urlencode
# Check for common DLHD/DaddyLive patterns in the URL
# This includes stream-XXX pattern and domain names like dlhd.dad or daddylive.sx
is_dlhd_link = (
re.search(r'stream-\d+', destination) or
"dlhd.dad" in urlparse(destination).netloc or
"daddylive.sx" in urlparse(destination).netloc
)
if not is_dlhd_link:
return None
logger = logging.getLogger(__name__)
logger.info(f"DLHD link detected: {destination}")
# Check cache first (unless force_refresh is True)
current_time = time.time()
if not force_refresh and destination in _dlhd_extraction_cache:
cached_entry = _dlhd_extraction_cache[destination]
cache_age = current_time - cached_entry["timestamp"]
# Build redirect URL to extractor
redirect_params = {
"host": "DLHD",
"redirect_stream": "true",
"d": destination
if cache_age < _dlhd_cache_duration:
logger.info(f"Using cached DLHD data (age: {cache_age:.1f}s)")
return cached_entry["data"]
else:
logger.info(f"DLHD cache expired (age: {cache_age:.1f}s), re-extracting...")
del _dlhd_extraction_cache[destination]
# Extract stream data
try:
logger.info(f"Extracting DLHD stream data from: {destination}")
extractor = ExtractorFactory.get_extractor("DLHD", proxy_headers.request)
result = await extractor.extract(destination)
logger.info(f"DLHD extraction successful. Stream URL: {result.get('destination_url')}")
# Cache the result
_dlhd_extraction_cache[destination] = {
"data": result,
"timestamp": current_time
}
logger.info(f"DLHD data cached for {_dlhd_cache_duration}s")
# Preserve api_password if present
if "api_password" in request.query_params:
redirect_params["api_password"] = request.query_params["api_password"]
return result
# Build the redirect URL
base_url = str(request.url_for("extract_url"))
redirect_url = f"{base_url}?{urlencode(redirect_params)}"
return RedirectResponse(url=redirect_url, status_code=302)
return None
except (ExtractorError, DownloadError) as e:
logger.error(f"DLHD extraction failed: {str(e)}")
raise HTTPException(status_code=400, detail=f"DLHD extraction failed: {str(e)}")
except Exception as e:
logger.exception(f"Unexpected error during DLHD extraction: {str(e)}")
raise HTTPException(status_code=500, detail=f"DLHD extraction failed: {str(e)}")
async def _check_and_extract_sportsonline_stream(
request: Request,
destination: str,
proxy_headers: ProxyRequestHeaders,
force_refresh: bool = False
) -> dict | None:
"""
Check if destination contains Sportsonline/Sportzonline patterns and extract stream directly.
Uses caching to avoid repeated extractions (10 minute cache).
Args:
request (Request): The incoming HTTP request.
destination (str): The destination URL to check.
proxy_headers (ProxyRequestHeaders): The headers to include in the request.
force_refresh (bool): Force re-extraction even if cached data exists.
Returns:
dict | None: Extracted stream data if Sportsonline link detected, None otherwise.
"""
import re
from urllib.parse import urlparse
from mediaflow_proxy.extractors.factory import ExtractorFactory
from mediaflow_proxy.extractors.base import ExtractorError
from mediaflow_proxy.utils.http_utils import DownloadError
parsed_netloc = urlparse(destination).netloc
is_sportsonline_link = "sportzonline." in parsed_netloc or "sportsonline." in parsed_netloc
if not is_sportsonline_link:
return None
logger = logging.getLogger(__name__)
logger.info(f"Sportsonline link detected: {destination}")
current_time = time.time()
if not force_refresh and destination in _sportsonline_extraction_cache:
cached_entry = _sportsonline_extraction_cache[destination]
if current_time - cached_entry["timestamp"] < _sportsonline_cache_duration:
logger.info(f"Using cached Sportsonline data (age: {current_time - cached_entry['timestamp']:.1f}s)")
return cached_entry["data"]
else:
logger.info("Sportsonline cache expired, re-extracting...")
del _sportsonline_extraction_cache[destination]
try:
logger.info(f"Extracting Sportsonline stream data from: {destination}")
extractor = ExtractorFactory.get_extractor("Sportsonline", proxy_headers.request)
result = await extractor.extract(destination)
logger.info(f"Sportsonline extraction successful. Stream URL: {result.get('destination_url')}")
_sportsonline_extraction_cache[destination] = {"data": result, "timestamp": current_time}
logger.info(f"Sportsonline data cached for {_sportsonline_cache_duration}s")
return result
except (ExtractorError, DownloadError, Exception) as e:
logger.error(f"Sportsonline extraction failed: {str(e)}")
raise HTTPException(status_code=400, detail=f"Sportsonline extraction failed: {str(e)}")
@proxy_router.head("/hls/manifest.m3u8")
@@ -179,12 +298,197 @@ async def hls_manifest_proxy(
Response: The HTTP response with the processed m3u8 playlist or streamed content.
"""
# Sanitize destination URL to fix common encoding issues
original_destination = hls_params.destination
hls_params.destination = sanitize_url(hls_params.destination)
# Check if destination contains stream-{numero} pattern and redirect to extractor
redirect_response = _check_and_redirect_dlhd_stream(request, hls_params.destination)
if redirect_response:
return redirect_response
# Check if this is a retry after 403 error (dlhd_retry parameter)
force_refresh = request.query_params.get("dlhd_retry") == "1"
# Check if destination contains DLHD pattern and extract stream directly
dlhd_result = await _check_and_extract_dlhd_stream(
request, hls_params.destination, proxy_headers, force_refresh=force_refresh
)
dlhd_original_url = None
if dlhd_result:
# Store original DLHD URL for cache invalidation on 403 errors
dlhd_original_url = hls_params.destination
# Update destination and headers with extracted stream data
hls_params.destination = dlhd_result["destination_url"]
extracted_headers = dlhd_result.get("request_headers", {})
proxy_headers.request.update(extracted_headers)
# Check if extractor wants key-only proxy (DLHD uses hls_key_proxy endpoint)
if dlhd_result.get("mediaflow_endpoint") == "hls_key_proxy":
hls_params.key_only_proxy = True
# Also add headers to query params so they propagate to key/segment requests
# This is necessary because M3U8Processor encodes headers as h_* query params
from fastapi.datastructures import QueryParams
query_dict = dict(request.query_params)
for header_name, header_value in extracted_headers.items():
# Add header with h_ prefix to query params
query_dict[f"h_{header_name}"] = header_value
# Add DLHD original URL to track for cache invalidation
if dlhd_original_url:
query_dict["dlhd_original"] = dlhd_original_url
# Remove retry flag from subsequent requests
query_dict.pop("dlhd_retry", None)
# Update request query params
request._query_params = QueryParams(query_dict)
# Check if destination contains Sportsonline pattern and extract stream directly
sportsonline_result = await _check_and_extract_sportsonline_stream(
request, hls_params.destination, proxy_headers
)
if sportsonline_result:
# Update destination and headers with extracted stream data
hls_params.destination = sportsonline_result["destination_url"]
extracted_headers = sportsonline_result.get("request_headers", {})
proxy_headers.request.update(extracted_headers)
# Check if extractor wants key-only proxy
if sportsonline_result.get("mediaflow_endpoint") == "hls_key_proxy":
hls_params.key_only_proxy = True
# Also add headers to query params so they propagate to key/segment requests
from fastapi.datastructures import QueryParams
query_dict = dict(request.query_params)
for header_name, header_value in extracted_headers.items():
# Add header with h_ prefix to query params
query_dict[f"h_{header_name}"] = header_value
# Remove retry flag from subsequent requests
query_dict.pop("dlhd_retry", None)
# Update request query params
request._query_params = QueryParams(query_dict)
# Wrap the handler to catch 403 errors and retry with cache invalidation
try:
result = await _handle_hls_with_dlhd_retry(request, hls_params, proxy_headers, dlhd_original_url)
return result
except HTTPException:
raise
except Exception as e:
logger = logging.getLogger(__name__)
logger.exception(f"Unexpected error in hls_manifest_proxy: {e}")
raise HTTPException(status_code=500, detail=str(e))
async def _handle_hls_with_dlhd_retry(
request: Request,
hls_params: HLSManifestParams,
proxy_headers: ProxyRequestHeaders,
dlhd_original_url: str | None
):
"""
Handle HLS request with automatic retry on 403 errors for DLHD streams.
"""
logger = logging.getLogger(__name__)
if hls_params.max_res:
from mediaflow_proxy.utils.hls_utils import parse_hls_playlist
from mediaflow_proxy.utils.m3u8_processor import M3U8Processor
async with create_httpx_client(
headers=proxy_headers.request,
follow_redirects=True,
) as client:
try:
response = await client.get(hls_params.destination)
response.raise_for_status()
playlist_content = response.text
except httpx.HTTPStatusError as e:
raise HTTPException(
status_code=502,
detail=f"Failed to fetch HLS manifest from origin: {e.response.status_code} {e.response.reason_phrase}",
) from e
except httpx.TimeoutException as e:
raise HTTPException(
status_code=504,
detail=f"Timeout while fetching HLS manifest: {e}",
) from e
except httpx.RequestError as e:
raise HTTPException(status_code=502, detail=f"Network error fetching HLS manifest: {e}") from e
streams = parse_hls_playlist(playlist_content, base_url=hls_params.destination)
if not streams:
raise HTTPException(
status_code=404, detail="No streams found in the manifest."
)
highest_res_stream = max(
streams,
key=lambda s: s.get("resolution", (0, 0))[0]
* s.get("resolution", (0, 0))[1],
)
if highest_res_stream.get("resolution", (0, 0)) == (0, 0):
logging.warning("Selected stream has resolution (0, 0); resolution parsing may have failed or not be available in the manifest.")
# Rebuild the manifest preserving master-level directives
# but removing non-selected variant blocks
lines = playlist_content.splitlines()
highest_variant_index = streams.index(highest_res_stream)
variant_index = -1
new_manifest_lines = []
i = 0
while i < len(lines):
line = lines[i]
if line.startswith("#EXT-X-STREAM-INF"):
variant_index += 1
next_line = ""
if i + 1 < len(lines) and not lines[i + 1].startswith("#"):
next_line = lines[i + 1]
# Only keep the selected variant
if variant_index == highest_variant_index:
new_manifest_lines.append(line)
if next_line:
new_manifest_lines.append(next_line)
# Skip variant block (stream-inf + optional url)
i += 2 if next_line else 1
continue
# Preserve all other lines (master directives, media tags, etc.)
new_manifest_lines.append(line)
i += 1
new_manifest = "\n".join(new_manifest_lines)
# Process the new manifest to proxy all URLs within it
processor = M3U8Processor(request, hls_params.key_url, hls_params.force_playlist_proxy, hls_params.key_only_proxy, hls_params.no_proxy)
processed_manifest = await processor.process_m3u8(new_manifest, base_url=hls_params.destination)
return Response(content=processed_manifest, media_type="application/vnd.apple.mpegurl")
return await handle_hls_stream_proxy(request, hls_params, proxy_headers)
@proxy_router.head("/hls/key_proxy/manifest.m3u8", name="hls_key_proxy")
@proxy_router.get("/hls/key_proxy/manifest.m3u8", name="hls_key_proxy")
async def hls_key_proxy(
request: Request,
hls_params: Annotated[HLSManifestParams, Query()],
proxy_headers: Annotated[ProxyRequestHeaders, Depends(get_proxy_headers)],
):
"""
Proxify HLS stream requests, but only proxy the key URL, leaving segment URLs direct.
Args:
request (Request): The incoming HTTP request.
hls_params (HLSManifestParams): The parameters for the HLS stream request.
proxy_headers (ProxyRequestHeaders): The headers to include in the request.
Returns:
Response: The HTTP response with the processed m3u8 playlist.
"""
# Sanitize destination URL to fix common encoding issues
hls_params.destination = sanitize_url(hls_params.destination)
# Set the key_only_proxy flag to True
hls_params.key_only_proxy = True
return await handle_hls_stream_proxy(request, hls_params, proxy_headers)
@@ -208,7 +512,7 @@ async def hls_segment_proxy(
"""
from mediaflow_proxy.utils.hls_prebuffer import hls_prebuffer
from mediaflow_proxy.configs import settings
# Sanitize segment URL to fix common encoding issues
segment_url = sanitize_url(segment_url)
@@ -217,11 +521,13 @@ async def hls_segment_proxy(
for key, value in request.query_params.items():
if key.startswith("h_"):
headers[key[2:]] = value
# Try to get segment from pre-buffer cache first
if settings.enable_hls_prebuffer:
cached_segment = await hls_prebuffer.get_segment(segment_url, headers)
if cached_segment:
# Avvia prebuffer dei successivi in background
asyncio.create_task(hls_prebuffer.prebuffer_from_segment(segment_url, headers))
return Response(
content=cached_segment,
media_type="video/mp2t",
@@ -231,8 +537,11 @@ async def hls_segment_proxy(
"Access-Control-Allow-Origin": "*"
}
)
# Fallback to direct streaming if not in cache
# Fallback to direct streaming se non in cache:
# prima di restituire, prova comunque a far partire il prebuffer dei successivi
if settings.enable_hls_prebuffer:
asyncio.create_task(hls_prebuffer.prebuffer_from_segment(segment_url, headers))
return await handle_stream_request("GET", segment_url, proxy_headers)
@@ -308,16 +617,21 @@ async def proxy_stream_endpoint(
# Sanitize destination URL to fix common encoding issues
destination = sanitize_url(destination)
# Check if destination contains stream-{numero} pattern and redirect to extractor
redirect_response = _check_and_redirect_dlhd_stream(request, destination)
if redirect_response:
return redirect_response
# Check if destination contains DLHD pattern and extract stream directly
dlhd_result = await _check_and_extract_dlhd_stream(request, destination, proxy_headers)
if dlhd_result:
# Update destination and headers with extracted stream data
destination = dlhd_result["destination_url"]
proxy_headers.request.update(dlhd_result.get("request_headers", {}))
if proxy_headers.request.get("range", "").strip() == "":
proxy_headers.request.pop("range", None)
if proxy_headers.request.get("if-range", "").strip() == "":
proxy_headers.request.pop("if-range", None)
if "range" not in proxy_headers.request:
proxy_headers.request["range"] = "bytes=0-"
content_range = proxy_headers.request.get("range", "bytes=0-")
if "nan" in content_range.casefold():
# Handle invalid range requests "bytes=NaN-NaN"
raise HTTPException(status_code=416, detail="Invalid Range Header")
proxy_headers.request.update({"range": content_range})
if filename:
# If a filename is provided, set it in the headers using RFC 6266 format
try:
@@ -430,4 +744,4 @@ async def get_mediaflow_proxy_public_ip():
Returns:
Response: The HTTP response with the public IP address in the form of a JSON object. {"ip": "xxx.xxx.xxx.xxx"}
"""
return await get_public_ip()
return await get_public_ip()

View File

@@ -71,6 +71,18 @@ class HLSManifestParams(GenericParams):
None,
description="Force all playlist URLs to be proxied through MediaFlow regardless of m3u8_content_routing setting. Useful for IPTV m3u/m3u_plus formats that don't have clear URL indicators.",
)
key_only_proxy: Optional[bool] = Field(
False,
description="Only proxy the key URL, leaving segment URLs direct.",
)
no_proxy: bool = Field(
False,
description="If true, returns the manifest content without proxying any internal URLs (segments, keys, playlists).",
)
max_res: bool = Field(
False,
description="If true, redirects to the highest resolution stream in the manifest.",
)
class MPDManifestParams(GenericParams):
@@ -92,11 +104,12 @@ class MPDSegmentParams(GenericParams):
mime_type: str = Field(..., description="The MIME type of the segment.")
key_id: Optional[str] = Field(None, description="The DRM key ID (optional).")
key: Optional[str] = Field(None, description="The DRM key (optional).")
is_live: Optional[bool] = Field(None, alias="is_live", description="Whether the parent MPD is live.")
class ExtractorURLParams(GenericParams):
host: Literal[
"Doodstream", "FileLions", "Mixdrop", "Uqload", "Streamtape", "Supervideo", "VixCloud", "Okru", "Maxstream", "LiveTV", "DLHD", "Fastream"
"Doodstream", "FileLions", "FileMoon", "F16Px", "Mixdrop", "Uqload", "Streamtape", "StreamWish", "Supervideo", "VixCloud", "Okru", "Maxstream", "LiveTV", "LuluStream", "DLHD", "Fastream", "TurboVidPlay", "Vidmoly", "Vidoza", "Voe", "Sportsonline"
] = Field(..., description="The host to extract the URL from.")
destination: str = Field(..., description="The URL of the stream.", alias="d")
redirect_stream: bool = Field(False, description="Whether to redirect to the stream endpoint automatically.")

View File

@@ -1,11 +1,8 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>App Status</title>
</head>
<body>
<h1>Your App is running</h1>
</body>
</html>

View File

@@ -11,6 +11,7 @@
h2 { color: #2c5aa0; border-bottom: 2px solid #2c5aa0; padding-bottom: 5px; text-align: left; margin-top: 30px; }
.form-group { margin-bottom: 15px; }
label { display: block; margin-bottom: 5px; font-weight: bold; color: #555; }
.proxy-label { display: inline-block; margin-left: 5px; font-weight: normal; }
input[type="text"], input[type="url"] { width: 100%; padding: 10px; border: 1px solid #ccc; border-radius: 4px; box-sizing: border-box; }
.btn { display: inline-block; padding: 10px 20px; background: #2c5aa0; color: white; text-decoration: none; border-radius: 5px; margin: 5px; cursor: pointer; border: none; font-size: 16px; }
.btn:hover { background: #1e3d6f; }
@@ -60,6 +61,14 @@
<label>M3U Playlist URL</label>
<input type="url" class="playlist-url" placeholder="Ex: http://provider.com/playlist.m3u">
</div>
<div class="form-group">
<input type="checkbox" class="proxy-playlist" checked>
<label class="proxy-label">Proxy this playlist</label>
</div>
<div class="form-group">
<input type="checkbox" class="sort-playlist">
<label class="proxy-label">Sort this playlist</label>
</div>
</div>
</template>
@@ -94,8 +103,14 @@
for (const entry of entries) {
const playlistUrl = entry.querySelector('.playlist-url').value.trim();
if (playlistUrl) {
const shouldProxy = entry.querySelector('.proxy-playlist').checked;
const shouldSort = entry.querySelector('.sort-playlist').checked;
let definition = (shouldSort ? 'sort:' : '') + (shouldProxy ? '' : 'no_proxy:') + playlistUrl;
if (playlistUrl.startsWith('http://') || playlistUrl.startsWith('https://')) {
definitions.push(playlistUrl);
// Se l'URL non ha proxy, ma ha sort, il prefisso sarà 'sort:no_proxy:'
definitions.push(definition);
} else {
alert('Invalid URL: ' + playlistUrl + '. URLs must start with http:// or https://');
return;

View File

@@ -425,12 +425,14 @@ class MediaFlowSpeedTest {
'Content-Type': 'application/json',
};
// Add current API password to headers if provided
// Build URL with api_password as query parameter if provided
// This is more reliable than headers when behind reverse proxies
let configUrl = '/speedtest/config';
if (currentApiPassword) {
headers['api_password'] = currentApiPassword;
configUrl += `?api_password=${encodeURIComponent(currentApiPassword)}`;
}
const response = await fetch('/speedtest/config', {
const response = await fetch(configUrl, {
method: 'POST',
headers: headers,
body: JSON.stringify(requestBody)

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

View 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,
]

View File

@@ -175,12 +175,27 @@ class HybridCache:
if not isinstance(data, (bytes, bytearray, memoryview)):
raise ValueError("Data must be bytes, bytearray, or memoryview")
expires_at = time.time() + (ttl or self.ttl)
ttl_seconds = self.ttl if ttl is None else ttl
key = self._get_md5_hash(key)
if ttl_seconds <= 0:
# Explicit request to avoid caching - remove any previous entry and return success
self.memory_cache.remove(key)
try:
file_path = self._get_file_path(key)
await aiofiles.os.remove(file_path)
except FileNotFoundError:
pass
except Exception as e:
logger.error(f"Error removing cache file: {e}")
return True
expires_at = time.time() + ttl_seconds
# Create cache entry
entry = CacheEntry(data=data, expires_at=expires_at, access_count=0, last_access=time.time(), size=len(data))
key = self._get_md5_hash(key)
# Update memory cache
self.memory_cache.set(key, entry)
file_path = self._get_file_path(key)
@@ -210,10 +225,11 @@ class HybridCache:
async def delete(self, key: str) -> bool:
"""Delete item from both caches."""
self.memory_cache.remove(key)
hashed_key = self._get_md5_hash(key)
self.memory_cache.remove(hashed_key)
try:
file_path = self._get_file_path(key)
file_path = self._get_file_path(hashed_key)
await aiofiles.os.remove(file_path)
return True
except FileNotFoundError:
@@ -237,7 +253,13 @@ class AsyncMemoryCache:
async def set(self, key: str, data: Union[bytes, bytearray, memoryview], ttl: Optional[int] = None) -> bool:
"""Set value in cache."""
try:
expires_at = time.time() + (ttl or 3600) # Default 1 hour TTL if not specified
ttl_seconds = 3600 if ttl is None else ttl
if ttl_seconds <= 0:
self.memory_cache.remove(key)
return True
expires_at = time.time() + ttl_seconds
entry = CacheEntry(
data=data, expires_at=expires_at, access_count=0, last_access=time.time(), size=len(data)
)
@@ -276,18 +298,35 @@ EXTRACTOR_CACHE = HybridCache(
# Specific cache implementations
async def get_cached_init_segment(init_url: str, headers: dict) -> Optional[bytes]:
"""Get initialization segment from cache or download it."""
# Try cache first
cached_data = await INIT_SEGMENT_CACHE.get(init_url)
if cached_data is not None:
return cached_data
async def get_cached_init_segment(
init_url: str,
headers: dict,
cache_token: str | None = None,
ttl: Optional[int] = None,
) -> Optional[bytes]:
"""Get initialization segment from cache or download it.
cache_token allows differentiating entries that share the same init_url but
rely on different DRM keys or initialization payloads (e.g. key rotation).
ttl overrides the default cache TTL; pass a value <= 0 to skip caching entirely.
"""
use_cache = ttl is None or ttl > 0
cache_key = f"{init_url}|{cache_token}" if cache_token else init_url
if use_cache:
cached_data = await INIT_SEGMENT_CACHE.get(cache_key)
if cached_data is not None:
return cached_data
else:
# Remove any previously cached entry when caching is disabled
await INIT_SEGMENT_CACHE.delete(cache_key)
# Download if not cached
try:
init_content = await download_file_with_retry(init_url, headers)
if init_content:
await INIT_SEGMENT_CACHE.set(init_url, init_content)
if init_content and use_cache:
await INIT_SEGMENT_CACHE.set(cache_key, init_content, ttl=ttl)
return init_content
except Exception as e:
logger.error(f"Error downloading init segment: {e}")

View 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

View 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

View 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

View 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

View 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

View File

@@ -6,6 +6,9 @@ from urllib.parse import urlparse
import httpx
from mediaflow_proxy.utils.http_utils import create_httpx_client
from mediaflow_proxy.configs import settings
from collections import OrderedDict
import time
from urllib.parse import urljoin
logger = logging.getLogger(__name__)
@@ -23,12 +26,21 @@ class HLSPreBuffer:
max_cache_size (int): Maximum number of segments to cache (uses config if None)
prebuffer_segments (int): Number of segments to pre-buffer ahead (uses config if None)
"""
from collections import OrderedDict
import time
from urllib.parse import urljoin
self.max_cache_size = max_cache_size or settings.hls_prebuffer_cache_size
self.prebuffer_segments = prebuffer_segments or settings.hls_prebuffer_segments
self.max_memory_percent = settings.hls_prebuffer_max_memory_percent
self.emergency_threshold = settings.hls_prebuffer_emergency_threshold
self.segment_cache: Dict[str, bytes] = {}
# Cache LRU
self.segment_cache: "OrderedDict[str, bytes]" = OrderedDict()
# Mappa playlist -> lista segmenti
self.segment_urls: Dict[str, List[str]] = {}
# Mappa inversa segmento -> (playlist_url, index)
self.segment_to_playlist: Dict[str, tuple[str, int]] = {}
# Stato per playlist: {headers, last_access, refresh_task, target_duration}
self.playlist_state: Dict[str, dict] = {}
self.client = create_httpx_client()
async def prebuffer_playlist(self, playlist_url: str, headers: Dict[str, str]) -> None:
@@ -41,37 +53,44 @@ class HLSPreBuffer:
"""
try:
logger.debug(f"Starting pre-buffer for playlist: {playlist_url}")
# Download and parse playlist
response = await self.client.get(playlist_url, headers=headers)
response.raise_for_status()
playlist_content = response.text
# Check if this is a master playlist (contains variants)
# Se master playlist: prendi la prima variante (fix relativo)
if "#EXT-X-STREAM-INF" in playlist_content:
logger.debug(f"Master playlist detected, finding first variant")
# Extract variant URLs
variant_urls = self._extract_variant_urls(playlist_content, playlist_url)
if variant_urls:
# Pre-buffer the first variant
first_variant_url = variant_urls[0]
logger.debug(f"Pre-buffering first variant: {first_variant_url}")
await self.prebuffer_playlist(first_variant_url, headers)
else:
logger.warning("No variants found in master playlist")
return
# Extract segment URLs
# Media playlist: estrai segmenti, salva stato e lancia refresh loop
segment_urls = self._extract_segment_urls(playlist_content, playlist_url)
# Store segment URLs for this playlist
self.segment_urls[playlist_url] = segment_urls
# Pre-buffer first few segments
# aggiorna mappa inversa
for idx, u in enumerate(segment_urls):
self.segment_to_playlist[u] = (playlist_url, idx)
# prebuffer iniziale
await self._prebuffer_segments(segment_urls[:self.prebuffer_segments], headers)
logger.info(f"Pre-buffered {min(self.prebuffer_segments, len(segment_urls))} segments for {playlist_url}")
# setup refresh loop se non già attivo
target_duration = self._parse_target_duration(playlist_content) or 6
st = self.playlist_state.get(playlist_url, {})
if not st.get("refresh_task") or st["refresh_task"].done():
task = asyncio.create_task(self._refresh_playlist_loop(playlist_url, headers, target_duration))
self.playlist_state[playlist_url] = {
"headers": headers,
"last_access": asyncio.get_event_loop().time(),
"refresh_task": task,
"target_duration": target_duration,
}
except Exception as e:
logger.warning(f"Failed to pre-buffer playlist {playlist_url}: {e}")
@@ -124,34 +143,24 @@ class HLSPreBuffer:
def _extract_variant_urls(self, playlist_content: str, base_url: str) -> List[str]:
"""
Extract variant URLs from master playlist content.
Args:
playlist_content (str): Content of the master playlist
base_url (str): Base URL for resolving relative URLs
Returns:
List[str]: List of variant URLs
Estrae le varianti dal master playlist. Corretto per gestire URI relativi:
prende la riga non-commento successiva a #EXT-X-STREAM-INF e la risolve rispetto a base_url.
"""
from urllib.parse import urljoin
variant_urls = []
lines = playlist_content.split('\n')
lines = [l.strip() for l in playlist_content.split('\n')]
take_next_uri = False
for line in lines:
line = line.strip()
if line and not line.startswith('#') and ('http://' in line or 'https://' in line):
# Resolve relative URLs
if line.startswith('http'):
variant_urls.append(line)
else:
# Join with base URL for relative paths
parsed_base = urlparse(base_url)
variant_url = f"{parsed_base.scheme}://{parsed_base.netloc}{line}"
variant_urls.append(variant_url)
if line.startswith("#EXT-X-STREAM-INF"):
take_next_uri = True
continue
if take_next_uri:
take_next_uri = False
if line and not line.startswith('#'):
variant_urls.append(urljoin(base_url, line))
logger.debug(f"Extracted {len(variant_urls)} variant URLs from master playlist")
if variant_urls:
logger.debug(f"First variant URL: {variant_urls[0]}")
return variant_urls
async def _prebuffer_segments(self, segment_urls: List[str], headers: Dict[str, str]) -> None:
@@ -166,7 +175,6 @@ class HLSPreBuffer:
for url in segment_urls:
if url not in self.segment_cache:
tasks.append(self._download_segment(url, headers))
if tasks:
await asyncio.gather(*tasks, return_exceptions=True)
@@ -196,16 +204,16 @@ class HLSPreBuffer:
def _emergency_cache_cleanup(self) -> None:
"""
Perform emergency cache cleanup when memory usage is high.
Esegue cleanup LRU rimuovendo il 50% più vecchio.
"""
if self._check_memory_threshold():
logger.warning("Emergency cache cleanup triggered due to high memory usage")
# Clear 50% of cache
cache_size = len(self.segment_cache)
keys_to_remove = list(self.segment_cache.keys())[:cache_size // 2]
for key in keys_to_remove:
del self.segment_cache[key]
logger.info(f"Emergency cleanup removed {len(keys_to_remove)} segments from cache")
to_remove = max(1, len(self.segment_cache) // 2)
removed = 0
while removed < to_remove and self.segment_cache:
self.segment_cache.popitem(last=False) # rimuovi LRU
removed += 1
logger.info(f"Emergency cleanup removed {removed} segments from cache")
async def _download_segment(self, segment_url: str, headers: Dict[str, str]) -> None:
"""
@@ -216,29 +224,26 @@ class HLSPreBuffer:
headers (Dict[str, str]): Headers to use for request
"""
try:
# Check memory usage before downloading
memory_percent = self._get_memory_usage_percent()
if memory_percent > self.max_memory_percent:
logger.warning(f"Memory usage {memory_percent}% exceeds limit {self.max_memory_percent}%, skipping download")
return
response = await self.client.get(segment_url, headers=headers)
response.raise_for_status()
# Cache the segment
# Cache LRU
self.segment_cache[segment_url] = response.content
# Check for emergency cleanup
self.segment_cache.move_to_end(segment_url, last=True)
if self._check_memory_threshold():
self._emergency_cache_cleanup()
# Maintain cache size
elif len(self.segment_cache) > self.max_cache_size:
# Remove oldest entries (simple FIFO)
oldest_key = next(iter(self.segment_cache))
del self.segment_cache[oldest_key]
# Evict LRU finché non rientra
while len(self.segment_cache) > self.max_cache_size:
self.segment_cache.popitem(last=False)
logger.debug(f"Cached segment: {segment_url}")
except Exception as e:
logger.warning(f"Failed to download segment {segment_url}: {e}")
@@ -256,38 +261,64 @@ class HLSPreBuffer:
# Check cache first
if segment_url in self.segment_cache:
logger.debug(f"Cache hit for segment: {segment_url}")
return self.segment_cache[segment_url]
# Check memory usage before downloading
# LRU touch
data = self.segment_cache[segment_url]
self.segment_cache.move_to_end(segment_url, last=True)
# aggiorna last_access per la playlist se mappata
pl = self.segment_to_playlist.get(segment_url)
if pl:
st = self.playlist_state.get(pl[0])
if st:
st["last_access"] = asyncio.get_event_loop().time()
return data
memory_percent = self._get_memory_usage_percent()
if memory_percent > self.max_memory_percent:
logger.warning(f"Memory usage {memory_percent}% exceeds limit {self.max_memory_percent}%, skipping download")
return None
# Download if not in cache
try:
response = await self.client.get(segment_url, headers=headers)
response.raise_for_status()
segment_data = response.content
# Cache the segment
# Cache LRU
self.segment_cache[segment_url] = segment_data
# Check for emergency cleanup
self.segment_cache.move_to_end(segment_url, last=True)
if self._check_memory_threshold():
self._emergency_cache_cleanup()
# Maintain cache size
elif len(self.segment_cache) > self.max_cache_size:
oldest_key = next(iter(self.segment_cache))
del self.segment_cache[oldest_key]
while len(self.segment_cache) > self.max_cache_size:
self.segment_cache.popitem(last=False)
# aggiorna last_access per playlist
pl = self.segment_to_playlist.get(segment_url)
if pl:
st = self.playlist_state.get(pl[0])
if st:
st["last_access"] = asyncio.get_event_loop().time()
logger.debug(f"Downloaded and cached segment: {segment_url}")
return segment_data
except Exception as e:
logger.warning(f"Failed to get segment {segment_url}: {e}")
return None
async def prebuffer_from_segment(self, segment_url: str, headers: Dict[str, str]) -> None:
"""
Dato un URL di segmento, prebuffer i successivi in base alla playlist e all'indice mappato.
"""
mapped = self.segment_to_playlist.get(segment_url)
if not mapped:
return
playlist_url, idx = mapped
# aggiorna access time
st = self.playlist_state.get(playlist_url)
if st:
st["last_access"] = asyncio.get_event_loop().time()
await self.prebuffer_next_segments(playlist_url, idx, headers)
async def prebuffer_next_segments(self, playlist_url: str, current_segment_index: int, headers: Dict[str, str]) -> None:
"""
Pre-buffer next segments based on current playback position.
@@ -299,10 +330,8 @@ class HLSPreBuffer:
"""
if playlist_url not in self.segment_urls:
return
segment_urls = self.segment_urls[playlist_url]
next_segments = segment_urls[current_segment_index + 1:current_segment_index + 1 + self.prebuffer_segments]
if next_segments:
await self._prebuffer_segments(next_segments, headers)
@@ -310,6 +339,8 @@ class HLSPreBuffer:
"""Clear the segment cache."""
self.segment_cache.clear()
self.segment_urls.clear()
self.segment_to_playlist.clear()
self.playlist_state.clear()
logger.info("HLS pre-buffer cache cleared")
async def close(self) -> None:
@@ -318,4 +349,142 @@ class HLSPreBuffer:
# Global pre-buffer instance
hls_prebuffer = HLSPreBuffer()
hls_prebuffer = HLSPreBuffer()
class HLSPreBuffer:
def _parse_target_duration(self, playlist_content: str) -> Optional[int]:
"""
Parse EXT-X-TARGETDURATION from a media playlist and return duration in seconds.
Returns None if not present or unparsable.
"""
for line in playlist_content.splitlines():
line = line.strip()
if line.startswith("#EXT-X-TARGETDURATION:"):
try:
value = line.split(":", 1)[1].strip()
return int(float(value))
except Exception:
return None
return None
async def _refresh_playlist_loop(self, playlist_url: str, headers: Dict[str, str], target_duration: int) -> None:
"""
Aggiorna periodicamente la playlist per seguire la sliding window e mantenere la cache coerente.
Interrompe e pulisce dopo inattività prolungata.
"""
sleep_s = max(2, min(15, int(target_duration)))
inactivity_timeout = 600 # 10 minuti
while True:
try:
st = self.playlist_state.get(playlist_url)
now = asyncio.get_event_loop().time()
if not st:
return
if now - st.get("last_access", now) > inactivity_timeout:
# cleanup specifico della playlist
urls = set(self.segment_urls.get(playlist_url, []))
if urls:
# rimuovi dalla cache solo i segmenti di questa playlist
for u in list(self.segment_cache.keys()):
if u in urls:
self.segment_cache.pop(u, None)
# rimuovi mapping
for u in urls:
self.segment_to_playlist.pop(u, None)
self.segment_urls.pop(playlist_url, None)
self.playlist_state.pop(playlist_url, None)
logger.info(f"Stopped HLS prebuffer for inactive playlist: {playlist_url}")
return
# refresh manifest
resp = await self.client.get(playlist_url, headers=headers)
resp.raise_for_status()
content = resp.text
new_target = self._parse_target_duration(content)
if new_target:
sleep_s = max(2, min(15, int(new_target)))
new_urls = self._extract_segment_urls(content, playlist_url)
if new_urls:
self.segment_urls[playlist_url] = new_urls
# rebuild reverse map per gli ultimi N (limita la memoria)
for idx, u in enumerate(new_urls[-(self.max_cache_size * 2):]):
# rimappiando sovrascrivi eventuali entry
real_idx = len(new_urls) - (self.max_cache_size * 2) + idx if len(new_urls) > (self.max_cache_size * 2) else idx
self.segment_to_playlist[u] = (playlist_url, real_idx)
# tenta un prebuffer proattivo: se conosciamo l'ultimo segmento accessibile, anticipa i successivi
# Non conosciamo l'indice di riproduzione corrente qui, quindi non facciamo nulla di aggressivo.
except Exception as e:
logger.debug(f"Playlist refresh error for {playlist_url}: {e}")
await asyncio.sleep(sleep_s)
def _extract_segment_urls(self, playlist_content: str, base_url: str) -> List[str]:
"""
Extract segment URLs from HLS playlist content.
Args:
playlist_content (str): Content of the HLS playlist
base_url (str): Base URL for resolving relative URLs
Returns:
List[str]: List of segment URLs
"""
segment_urls = []
lines = playlist_content.split('\n')
logger.debug(f"Analyzing playlist with {len(lines)} lines")
for line in lines:
line = line.strip()
if line and not line.startswith('#'):
# Check if line contains a URL (http/https) or is a relative path
if 'http://' in line or 'https://' in line:
segment_urls.append(line)
logger.debug(f"Found absolute URL: {line}")
elif line and not line.startswith('#'):
# This might be a relative path to a segment
parsed_base = urlparse(base_url)
# Ensure proper path joining
if line.startswith('/'):
segment_url = f"{parsed_base.scheme}://{parsed_base.netloc}{line}"
else:
# Get the directory path from base_url
base_path = parsed_base.path.rsplit('/', 1)[0] if '/' in parsed_base.path else ''
segment_url = f"{parsed_base.scheme}://{parsed_base.netloc}{base_path}/{line}"
segment_urls.append(segment_url)
logger.debug(f"Found relative path: {line} -> {segment_url}")
logger.debug(f"Extracted {len(segment_urls)} segment URLs from playlist")
if segment_urls:
logger.debug(f"First segment URL: {segment_urls[0]}")
else:
logger.debug("No segment URLs found in playlist")
# Log first few lines for debugging
for i, line in enumerate(lines[:10]):
logger.debug(f"Line {i}: {line}")
return segment_urls
def _extract_variant_urls(self, playlist_content: str, base_url: str) -> List[str]:
"""
Estrae le varianti dal master playlist. Corretto per gestire URI relativi:
prende la riga non-commento successiva a #EXT-X-STREAM-INF e la risolve rispetto a base_url.
"""
from urllib.parse import urljoin
variant_urls = []
lines = [l.strip() for l in playlist_content.split('\n')]
take_next_uri = False
for line in lines:
if line.startswith("#EXT-X-STREAM-INF"):
take_next_uri = True
continue
if take_next_uri:
take_next_uri = False
if line and not line.startswith('#'):
variant_urls.append(urljoin(base_url, line))
logger.debug(f"Extracted {len(variant_urls)} variant URLs from master playlist")
if variant_urls:
logger.debug(f"First variant URL: {variant_urls[0]}")
return variant_urls

View 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

View File

@@ -3,7 +3,7 @@ import typing
from dataclasses import dataclass
from functools import partial
from urllib import parse
from urllib.parse import urlencode
from urllib.parse import urlencode, urlparse
import anyio
import h11
@@ -81,6 +81,10 @@ async def fetch_with_retry(client, method, url, headers, follow_redirects=True,
class Streamer:
# PNG signature and IEND marker for fake PNG header detection (StreamWish/FileMoon)
_PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
_PNG_IEND_MARKER = b"\x49\x45\x4E\x44\xAE\x42\x60\x82"
def __init__(self, client):
"""
Initializes the Streamer with an HTTP client.
@@ -132,13 +136,48 @@ class Streamer:
logger.error(f"Error creating streaming response: {e}")
raise RuntimeError(f"Error creating streaming response: {e}")
@staticmethod
def _strip_fake_png_wrapper(chunk: bytes) -> bytes:
"""
Strip fake PNG wrapper from chunk data.
Some streaming services (StreamWish, FileMoon) prepend a fake PNG image
to video data to evade detection. This method detects and removes it.
Args:
chunk: The raw chunk data that may contain a fake PNG header.
Returns:
The chunk with fake PNG wrapper removed, or original chunk if not present.
"""
if not chunk.startswith(Streamer._PNG_SIGNATURE):
return chunk
# Find the IEND marker that signals end of PNG data
iend_pos = chunk.find(Streamer._PNG_IEND_MARKER)
if iend_pos == -1:
# IEND not found in this chunk - return as-is to avoid data corruption
logger.debug("PNG signature detected but IEND marker not found in chunk")
return chunk
# Calculate position after IEND marker
content_start = iend_pos + len(Streamer._PNG_IEND_MARKER)
# Skip any padding bytes (null or 0xFF) between PNG and actual content
while content_start < len(chunk) and chunk[content_start] in (0x00, 0xFF):
content_start += 1
stripped_bytes = content_start
logger.debug(f"Stripped {stripped_bytes} bytes of fake PNG wrapper from stream")
return chunk[content_start:]
async def stream_content(self) -> typing.AsyncGenerator[bytes, None]:
"""
Streams the content from the response.
"""
if not self.response:
raise RuntimeError("No response available for streaming")
is_first_chunk = True
try:
self.parse_content_range()
@@ -154,15 +193,19 @@ class Streamer:
mininterval=1,
) as self.progress_bar:
async for chunk in self.response.aiter_bytes():
if is_first_chunk:
is_first_chunk = False
chunk = self._strip_fake_png_wrapper(chunk)
yield chunk
chunk_size = len(chunk)
self.bytes_transferred += chunk_size
self.progress_bar.set_postfix_str(
f"📥 : {self.format_bytes(self.bytes_transferred)}", refresh=False
)
self.progress_bar.update(chunk_size)
self.bytes_transferred += len(chunk)
self.progress_bar.update(len(chunk))
else:
async for chunk in self.response.aiter_bytes():
if is_first_chunk:
is_first_chunk = False
chunk = self._strip_fake_png_wrapper(chunk)
yield chunk
self.bytes_transferred += len(chunk)
@@ -187,10 +230,19 @@ class Streamer:
raise DownloadError(502, f"Protocol error while streaming: {e}")
except GeneratorExit:
logger.info("Streaming session stopped by the user")
except httpx.ReadError as e:
# Handle network read errors gracefully - these occur when upstream connection drops
logger.warning(f"ReadError while streaming: {e}")
if self.bytes_transferred > 0:
logger.info(f"Partial content received ({self.bytes_transferred} bytes) before ReadError. Graceful termination.")
return
else:
raise DownloadError(502, f"ReadError while streaming: {e}")
except Exception as e:
logger.error(f"Error streaming content: {e}")
raise
@staticmethod
def format_bytes(size) -> str:
power = 2**10
@@ -490,6 +542,23 @@ def get_proxy_headers(request: Request) -> ProxyRequestHeaders:
"""
request_headers = {k: v for k, v in request.headers.items() if k in SUPPORTED_REQUEST_HEADERS}
request_headers.update({k[2:].lower(): v for k, v in request.query_params.items() if k.startswith("h_")})
request_headers.setdefault("user-agent", settings.user_agent)
# Handle common misspelling of referer
if "referrer" in request_headers:
if "referer" not in request_headers:
request_headers["referer"] = request_headers.pop("referrer")
dest = request.query_params.get("d", "")
host = urlparse(dest).netloc.lower()
if "vidoza" in host or "videzz" in host:
# Remove ALL empty headers
for h in list(request_headers.keys()):
v = request_headers[h]
if v is None or v.strip() == "":
request_headers.pop(h, None)
response_headers = {k[2:].lower(): v for k, v in request.query_params.items() if k.startswith("r_")}
return ProxyRequestHeaders(request_headers, response_headers)
@@ -527,21 +596,14 @@ class EnhancedStreamingResponse(Response):
logger.error(f"Error in listen_for_disconnect: {str(e)}")
async def stream_response(self, send: Send) -> None:
# Track if response headers have been sent to prevent duplicate headers
response_started = False
# Track if response finalization (more_body: False) has been sent to prevent ASGI protocol violation
finalization_sent = False
try:
# Initialize headers
headers = list(self.raw_headers)
# Set the transfer-encoding to chunked for streamed responses with content-length
# when content-length is present. This ensures we don't hit protocol errors
# if the upstream connection is closed prematurely.
for i, (name, _) in enumerate(headers):
if name.lower() == b"content-length":
# Replace content-length with transfer-encoding: chunked for streaming
headers[i] = (b"transfer-encoding", b"chunked")
headers = [h for h in headers if h[0].lower() != b"content-length"]
logger.debug("Switched from content-length to chunked transfer-encoding for streaming")
break
# Start the response
await send(
{
@@ -550,6 +612,7 @@ class EnhancedStreamingResponse(Response):
"headers": headers,
}
)
response_started = True
# Track if we've sent any data
data_sent = False
@@ -568,27 +631,29 @@ class EnhancedStreamingResponse(Response):
# Successfully streamed all content
await send({"type": "http.response.body", "body": b"", "more_body": False})
except (httpx.RemoteProtocolError, h11._util.LocalProtocolError) as e:
# Handle connection closed errors
finalization_sent = True
except (httpx.RemoteProtocolError, httpx.ReadError, h11._util.LocalProtocolError) as e:
# Handle connection closed / read errors gracefully
if data_sent:
# We've sent some data to the client, so try to complete the response
logger.warning(f"Remote protocol error after partial streaming: {e}")
logger.warning(f"Upstream connection error after partial streaming: {e}")
try:
await send({"type": "http.response.body", "body": b"", "more_body": False})
finalization_sent = True
logger.info(
f"Response finalized after partial content ({self.actual_content_length} bytes transferred)"
)
except Exception as close_err:
logger.warning(f"Could not finalize response after remote error: {close_err}")
logger.warning(f"Could not finalize response after upstream error: {close_err}")
else:
# No data was sent, re-raise the error
logger.error(f"Protocol error before any data was streamed: {e}")
logger.error(f"Upstream error before any data was streamed: {e}")
raise
except Exception as e:
logger.exception(f"Error in stream_response: {str(e)}")
if not isinstance(e, (ConnectionResetError, anyio.BrokenResourceError)):
if not isinstance(e, (ConnectionResetError, anyio.BrokenResourceError)) and not response_started:
# Only attempt to send error response if headers haven't been sent yet
try:
# Try to send an error response if client is still connected
await send(
{
"type": "http.response.start",
@@ -598,36 +663,39 @@ class EnhancedStreamingResponse(Response):
)
error_message = f"Streaming error: {str(e)}".encode("utf-8")
await send({"type": "http.response.body", "body": error_message, "more_body": False})
finalization_sent = True
except Exception:
# If we can't send an error response, just log it
pass
elif response_started and not finalization_sent:
# Response already started but not finalized - gracefully close the stream
try:
await send({"type": "http.response.body", "body": b"", "more_body": False})
finalization_sent = True
except Exception:
pass
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
async with anyio.create_task_group() as task_group:
streaming_completed = False
stream_func = partial(self.stream_response, send)
listen_func = partial(self.listen_for_disconnect, receive)
async def wrap(func: typing.Callable[[], typing.Awaitable[None]]) -> None:
try:
await func()
# If this is the stream_response function and it completes successfully, mark as done
if func == stream_func:
nonlocal streaming_completed
streaming_completed = True
except Exception as e:
if isinstance(e, (httpx.RemoteProtocolError, h11._util.LocalProtocolError)):
# Handle protocol errors more gracefully
logger.warning(f"Protocol error during streaming: {e}")
elif not isinstance(e, anyio.get_cancelled_exc_class()):
logger.exception("Error in streaming task")
# Only re-raise if it's not a protocol error or cancellation
# Note: stream_response and listen_for_disconnect handle their own exceptions
# internally. This is a safety net for any unexpected exceptions that might
# escape due to future code changes.
if not isinstance(e, anyio.get_cancelled_exc_class()):
logger.exception(f"Unexpected error in streaming task: {type(e).__name__}: {e}")
# Re-raise unexpected errors to surface bugs rather than silently swallowing them
raise
finally:
# Only cancel the task group if we're in disconnect listener or
# if streaming_completed is True (meaning we finished normally)
if func == listen_func or streaming_completed:
task_group.cancel_scope.cancel()
# Cancel task group when either task completes or fails:
# - stream_func finished (success or failure) -> stop listening for disconnect
# - listen_func finished (client disconnected) -> stop streaming
task_group.cancel_scope.cancel()
# Start the streaming response in a separate task
task_group.start_soon(wrap, stream_func)

View File

@@ -11,7 +11,7 @@ from mediaflow_proxy.utils.hls_prebuffer import hls_prebuffer
class M3U8Processor:
def __init__(self, request, key_url: str = None, force_playlist_proxy: bool = None):
def __init__(self, request, key_url: str = None, force_playlist_proxy: bool = None, key_only_proxy: bool = False, no_proxy: bool = False):
"""
Initializes the M3U8Processor with the request and URL prefix.
@@ -19,9 +19,13 @@ class M3U8Processor:
request (Request): The incoming HTTP request.
key_url (HttpUrl, optional): The URL of the key server. Defaults to None.
force_playlist_proxy (bool, optional): Force all playlist URLs to be proxied through MediaFlow. Defaults to None.
key_only_proxy (bool, optional): Only proxy the key URL, leaving segment URLs direct. Defaults to False.
no_proxy (bool, optional): If True, returns the manifest without proxying any URLs. Defaults to False.
"""
self.request = request
self.key_url = parse.urlparse(key_url) if key_url else None
self.key_only_proxy = key_only_proxy
self.no_proxy = no_proxy
self.force_playlist_proxy = force_playlist_proxy
self.mediaflow_proxy_url = str(
request.url_for("hls_manifest_proxy").replace(scheme=get_original_scheme(request))
@@ -174,6 +178,15 @@ class M3U8Processor:
Returns:
str: The processed key line.
"""
# If no_proxy is enabled, just resolve relative URLs without proxying
if self.no_proxy:
uri_match = re.search(r'URI="([^"]+)"', line)
if uri_match:
original_uri = uri_match.group(1)
full_url = parse.urljoin(base_url, original_uri)
line = line.replace(f'URI="{original_uri}"', f'URI="{full_url}"')
return line
uri_match = re.search(r'URI="([^"]+)"', line)
if uri_match:
original_uri = uri_match.group(1)
@@ -197,6 +210,14 @@ class M3U8Processor:
"""
full_url = parse.urljoin(base_url, url)
# If no_proxy is enabled, return the direct URL without any proxying
if self.no_proxy:
return full_url
# If key_only_proxy is enabled, return the direct URL for segments
if self.key_only_proxy and not url.endswith((".m3u", ".m3u8")):
return full_url
# Determine routing strategy based on configuration
routing_strategy = settings.m3u8_content_routing

View File

@@ -270,7 +270,7 @@ def parse_representation(
if item:
profile["segments"] = parse_segment_template(parsed_dict, item, profile, source)
else:
profile["segments"] = parse_segment_base(representation, source)
profile["segments"] = parse_segment_base(representation, profile, source)
return profile
@@ -547,7 +547,7 @@ def create_segment_data(segment: Dict, item: dict, profile: dict, source: str, t
return segment_data
def parse_segment_base(representation: dict, source: str) -> List[Dict]:
def parse_segment_base(representation: dict, profile: dict, source: str) -> List[Dict]:
"""
Parses segment base information and extracts segment data. This is used for single-segment representations.
@@ -562,6 +562,12 @@ def parse_segment_base(representation: dict, source: str) -> List[Dict]:
start, end = map(int, segment["@indexRange"].split("-"))
if "Initialization" in segment:
start, _ = map(int, segment["Initialization"]["@range"].split("-"))
# Set initUrl for SegmentBase
if not representation['BaseURL'].startswith("http"):
profile["initUrl"] = f"{source}/{representation['BaseURL']}"
else:
profile["initUrl"] = representation['BaseURL']
return [
{

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

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

File diff suppressed because it is too large Load Diff

View 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

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