new version

This commit is contained in:
UrloMythus
2026-04-15 19:23:14 +02:00
parent 5120b19d0b
commit 8134936d59
135 changed files with 3013 additions and 1589 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+15 -3
View File
@@ -64,14 +64,21 @@ class Settings(BaseSettings):
dash_prebuffer_emergency_threshold: int = 90 # Emergency threshold percentage to trigger aggressive cache cleanup.
dash_prebuffer_inactivity_timeout: int = 60 # Seconds of inactivity before cleaning up stream state.
dash_segment_cache_ttl: int = 60 # TTL (seconds) for cached media segments; longer = better for slow playback.
dash_player_lock_timeout: float = 2.5 # Max wait (seconds) for player requests when a segment lock is busy.
dash_prebuffer_lock_timeout: float = 0.25 # Max wait (seconds) for background prebuffer lock acquisition.
dash_prefetch_max_concurrent: int = 1 # Max concurrent live DASH prefetch downloads to reduce lock contention.
dash_live_initial_media_prebuffer: bool = (
False # Whether manifest-time prebuffer should fetch live media segments (init segments are still prewarmed).
)
mpd_live_init_cache_ttl: int = 60 # 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.
remux_to_ts: bool = False # Remux fMP4 segments to MPEG-TS for ExoPlayer/VLC compatibility.
processed_segment_cache_ttl: int = 60 # TTL (seconds) for caching processed (decrypted/remuxed) segments.
# FlareSolverr settings (for Cloudflare bypass)
flaresolverr_url: str | None = None # FlareSolverr service URL. Example: http://localhost:8191
flaresolverr_timeout: int = 60 # Timeout (seconds) for FlareSolverr requests.
# Byparr settings — Firefox/Camoufox-based solver for Cloudflare bypass and chevy IP whitelist.
# https://github.com/ThePhaseless/Byparr (drop-in FlareSolverr-compatible API)
byparr_url: str | None = None # Byparr service URL. Example: http://localhost:8192
byparr_timeout: int = 60 # Timeout (seconds) for Byparr requests.
# Acestream settings
enable_acestream: bool = False # Whether to enable Acestream proxy support.
@@ -89,6 +96,8 @@ class Settings(BaseSettings):
telegram_session_string: SecretStr | None = None # Persistent session string (avoids re-authentication).
telegram_max_connections: int = 8 # Max parallel DC connections for downloads (max 20, careful of floods).
telegram_request_timeout: int = 30 # Request timeout in seconds.
telegram_document_scan_limit: int = 500 # Max recent messages to scan when resolving chat_id+document_id.
telegram_document_cache_ttl: int = 3600 # TTL (seconds) for cached document_id->message_id mappings.
# Transcode settings
enable_transcode: bool = True # Whether to enable on-the-fly transcoding endpoints (MKV→fMP4, HLS VOD).
@@ -105,6 +114,9 @@ class Settings(BaseSettings):
upstream_retry_delay: float = 1.0 # Delay (seconds) between retry attempts.
graceful_stream_end: bool = True # Return valid empty playlist instead of error when upstream fails.
# EPG proxy settings
epg_cache_ttl: int = 3600 # TTL (seconds) for cached EPG/XMLTV data. Default 1 hour.
# Redis settings
redis_url: str | None = None # Redis URL for distributed locking and caching. None = disabled.
cache_namespace: str | None = (
+36 -9
View File
@@ -645,17 +645,23 @@ class MP4Decrypter:
return sample_info
def _get_key_for_track(self, track_id: int) -> bytes:
def _get_key_for_track(self, track_id: int) -> Optional[bytes]:
"""
Retrieves the decryption key for a given track ID from the key map.
Uses the KID extracted from the tenc box if available, otherwise falls back to
using the first key if only one key is provided.
Returns None (rather than raising) when no matching key can be found — the
caller is expected to handle None by skipping decryption for that track
and passing the encrypted data through unchanged. This mirrors the Rust
implementation and avoids crashing the whole moof/mdat pipeline when
content uses slightly different KID byte-ordering than the URL parameters.
Args:
track_id (int): The track ID.
Returns:
bytes: The decryption key for the specified track ID.
Optional[bytes]: The decryption key, or None if no key found.
"""
# If we have an extracted KID for this track, use it to look up the key
if track_id in self.extracted_kids:
@@ -668,13 +674,31 @@ class MP4Decrypter:
if len(self.key_map) == 1:
return next(iter(self.key_map.values()))
else:
# Use the extracted KID to look up the key
# Direct lookup: tenc KID matches a provided key_id byte-for-byte
key = self.key_map.get(extracted_kid)
if key:
return key
# If KID doesn't match, try fallback
# Note: This is expected when KID in file doesn't match provided key_id
# The provided key_id should still work if it's the correct decryption key
# PlayReady GUID fallback: some content packagers store the KID in
# the tenc box using little-endian byte order for the first three UUID
# components (the PlayReady GUID format), while the MPD advertises
# @cenc:default_KID in standard big-endian UUID order.
#
# UUID: AABBCCDD-EEFF-GGHH-II...
# LE GUID: DDCCBBAA-FFEE-HHGG-II... (first 4, next 2, next 2 swapped)
#
# Try both directions so that audio-only or video-only init segments
# whose tenc KID was written in the opposite format can still match.
if len(extracted_kid) == 16:
swapped = (
extracted_kid[3::-1] # bytes 0-3 reversed
+ extracted_kid[5:3:-1] # bytes 4-5 reversed
+ extracted_kid[7:5:-1] # bytes 6-7 reversed
+ extracted_kid[8:] # bytes 8-15 unchanged
)
key = self.key_map.get(bytes(swapped))
if key:
return key
# Fallback: if only one key provided, use it (backward compatibility)
if len(self.key_map) == 1:
@@ -683,9 +707,12 @@ class MP4Decrypter:
# Try using track_id as KID (for multi-key scenarios)
track_id_bytes = track_id.to_bytes(4, "big")
key = self.key_map.get(track_id_bytes)
if not key:
raise ValueError(f"No key found for track ID {track_id}")
return key
if key:
return key
# No key found — return None so callers can pass encrypted data through
# rather than aborting the entire segment stream.
return None
@staticmethod
def _process_sample(
+11 -1
View File
@@ -9,7 +9,7 @@ import json
import logging
from mediaflow_proxy.configs import settings
from mediaflow_proxy.utils.http_client import create_aiohttp_session
from mediaflow_proxy.utils.http_client import create_aiohttp_session, _ensure_routing_initialized, get_routing_config
from mediaflow_proxy.utils.http_utils import DownloadError
logger = logging.getLogger(__name__)
@@ -65,6 +65,16 @@ class BaseExtractor(ABC):
# merge incoming headers (e.g. Accept-Language / Referer) with default base headers
self.base_headers.update(request_headers or {})
@staticmethod
def _get_proxy(url: str) -> str | None:
"""Return the configured proxy URL for *url*, or None if no proxy applies."""
try:
_ensure_routing_initialized()
route = get_routing_config().match_url(url)
return route.proxy_url
except Exception:
return None
async def _make_request(
self,
url: str,
+158
View File
@@ -0,0 +1,158 @@
import re
import json
import base64
from typing import Dict, Any
from urllib.parse import urlparse, parse_qs
from bs4 import BeautifulSoup
from mediaflow_proxy.extractors.base import BaseExtractor, ExtractorError
class CityExtractor(BaseExtractor):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.mediaflow_endpoint = "hls_manifest_proxy"
def atob_fixed(self, data: str) -> str:
try:
return base64.b64decode(data).decode("utf-8", errors="ignore")
except Exception:
return ""
def extract_json_array(self, decoded: str):
start = decoded.find("file:")
if start == -1:
start = decoded.find("sources:")
if start == -1:
return None
start = decoded.find("[", start)
if start == -1:
return None
depth = 0
for i in range(start, len(decoded)):
if decoded[i] == "[":
depth += 1
elif decoded[i] == "]":
depth -= 1
if depth == 0:
return decoded[start : i + 1]
return None
def pick_stream(self, file_data, season: int = 1, episode: int = 1):
if isinstance(file_data, str):
return file_data
if isinstance(file_data, list):
if all(isinstance(x, dict) and "file" in x for x in file_data):
idx = max(0, episode - 1)
return file_data[idx]["file"]
selected_season = None
for s in file_data:
if not isinstance(s, dict):
continue
folder = s.get("folder")
if not folder:
continue
title = (s.get("title") or "").lower()
if re.search(rf"(season|s)\s*0*{season}\b", title):
selected_season = folder
break
if not selected_season:
for s in file_data:
folder = s.get("folder")
if folder:
selected_season = folder
break
if not selected_season:
return None
idx = max(0, episode - 1)
return selected_season[idx].get("file") if idx < len(selected_season) else selected_season[0].get("file")
return None
async def extract(self, url: str, season: int = 1, episode: int = 1, **kwargs) -> Dict[str, Any]:
"""Main extraction entry point"""
parsed = urlparse(url)
query = parse_qs(parsed.query)
if "s" in query:
try:
season = int(query["s"][0])
except Exception:
pass
if "e" in query:
try:
episode = int(query["e"][0])
except Exception:
pass
clean_url = f"{parsed.scheme}://{parsed.netloc}{parsed.path}"
cookie_b64 = "ZGxlX3VzZXJfaWQ9MzI3Mjk7IGRsZV9wYXNzd29yZD04OTQxNzFjNmE4ZGFiMThlZTU5NGQ1YzY1MjAwOWEzNTs="
cookie = base64.b64decode(cookie_b64).decode()
headers = {
"User-Agent": self.base_headers.get("user-agent"),
"Referer": clean_url,
"Cookie": cookie,
}
response = await self._make_request(clean_url, headers=headers)
if response.status != 200:
raise ExtractorError("Failed to load City page")
soup = BeautifulSoup(response.text, "lxml")
file_data = None
for script in soup.find_all("script"):
if file_data:
break
script_html = script.string or script.text or ""
if "atob" not in script_html:
continue
matches = re.finditer(r'atob\(\s*[\'"](.*?)[\'"]\s*\)', script_html)
for match in matches:
encoded = match.group(1)
decoded = self.atob_fixed(encoded)
if not decoded:
continue
raw_json = self.extract_json_array(decoded)
if raw_json:
try:
raw_json = re.sub(r"\\(.)", r"\1", raw_json)
file_data = json.loads(raw_json)
except Exception:
file_data = raw_json
break
file_match = re.search(r'file\s*:\s*[\'"](.*?)[\'"]', decoded, re.S)
if file_match:
file_data = file_match.group(1)
break
if not file_data:
raise ExtractorError("No stream found")
stream_url = self.pick_stream(file_data, season=season, episode=episode)
if not stream_url:
raise ExtractorError("Stream extraction failed")
return {
"destination_url": stream_url,
"request_headers": {
"Referer": clean_url,
"User-Agent": self.base_headers.get("user-agent"),
},
"mediaflow_endpoint": self.mediaflow_endpoint,
}
-704
View File
@@ -1,704 +0,0 @@
import hashlib
import hmac
import re
import time
import logging
from typing import Any, Dict, Optional
from urllib.parse import urlparse
import aiohttp
from mediaflow_proxy.extractors.base import BaseExtractor, ExtractorError, HttpResponse
from mediaflow_proxy.utils.http_client import create_aiohttp_session
from mediaflow_proxy.configs import settings
logger = logging.getLogger(__name__)
# Silenzia l'errore ConnectionResetError su Windows
logging.getLogger("asyncio").setLevel(logging.CRITICAL)
# Default fingerprint parameters
DEFAULT_DLHD_USER_AGENT = "Mozilla/5.0 (X11; Linux x86_64; rv:146.0) Gecko/20100101 Firefox/146.0"
DEFAULT_DLHD_SCREEN_RESOLUTION = "1920x1080"
DEFAULT_DLHD_TIMEZONE = "UTC"
DEFAULT_DLHD_LANGUAGE = "en"
def compute_fingerprint(
user_agent: str = DEFAULT_DLHD_USER_AGENT,
screen_resolution: str = DEFAULT_DLHD_SCREEN_RESOLUTION,
timezone: str = DEFAULT_DLHD_TIMEZONE,
language: str = DEFAULT_DLHD_LANGUAGE,
) -> str:
"""
Compute the X-Fingerprint header value.
Algorithm:
fingerprint = SHA256(useragent + screen_resolution + timezone + language).hex()[:16]
Args:
user_agent: The user agent string
screen_resolution: The screen resolution (e.g., "1920x1080")
timezone: The timezone (e.g., "UTC")
language: The language code (e.g., "en")
Returns:
The 16-character fingerprint
"""
combined = f"{user_agent}{screen_resolution}{timezone}{language}"
return hashlib.sha256(combined.encode("utf-8")).hexdigest()[:16]
def compute_key_path(resource: str, number: str, timestamp: int, fingerprint: str, secret_key: str) -> str:
"""
Compute the X-Key-Path header value.
Algorithm:
key_path = HMAC-SHA256("resource|number|timestamp|fingerprint", secret_key).hex()[:16]
Args:
resource: The resource from the key URL
number: The number from the key URL
timestamp: The Unix timestamp
fingerprint: The fingerprint value
secret_key: The HMAC secret key (channel_salt)
Returns:
The 16-character key path
"""
combined = f"{resource}|{number}|{timestamp}|{fingerprint}"
hmac_hash = hmac.new(secret_key.encode("utf-8"), combined.encode("utf-8"), hashlib.sha256).hexdigest()
return hmac_hash[:16]
def compute_key_headers(key_url: str, secret_key: str) -> tuple[int, int, str, str] | None:
"""
Compute X-Key-Timestamp, X-Key-Nonce, X-Key-Path, and X-Fingerprint for a /key/ URL.
Algorithm:
1. Extract resource and number from URL pattern /key/{resource}/{number}
2. ts = Unix timestamp in seconds
3. hmac_hash = HMAC-SHA256(resource, secret_key).hex()
4. nonce = proof-of-work: find i where MD5(hmac+resource+number+ts+i)[:4] < 0x1000
5. fingerprint = compute_fingerprint()
6. key_path = HMAC-SHA256("resource|number|ts|fingerprint", secret_key).hex()[:16]
Args:
key_url: The key URL containing /key/{resource}/{number}
secret_key: The HMAC secret key (channel_salt)
Returns:
Tuple of (timestamp, nonce, key_path, fingerprint) or None if URL doesn't match pattern
"""
# Extract resource and number from URL
pattern = r"/key/([^/]+)/(\d+)"
match = re.search(pattern, key_url)
if not match:
return None
resource = match.group(1)
number = match.group(2)
ts = int(time.time())
# Compute HMAC-SHA256
hmac_hash = hmac.new(secret_key.encode("utf-8"), resource.encode("utf-8"), hashlib.sha256).hexdigest()
# Proof-of-work loop
nonce = 0
for i in range(100000):
combined = f"{hmac_hash}{resource}{number}{ts}{i}"
md5_hash = hashlib.md5(combined.encode("utf-8")).hexdigest()
prefix_value = int(md5_hash[:4], 16)
if prefix_value < 0x1000: # < 4096
nonce = i
break
fingerprint = compute_fingerprint()
key_path = compute_key_path(resource, number, ts, fingerprint, secret_key)
return ts, nonce, key_path, fingerprint
class DLHDExtractor(BaseExtractor):
"""DLHD (DaddyLive) URL extractor for M3U8 streams.
Supports the new authentication flow with:
- EPlayerAuth extraction (auth_token, channel_key, channel_salt)
- Server lookup for dynamic server selection
- Dynamic key header computation for AES-128 encrypted streams
"""
def __init__(self, request_headers: dict):
super().__init__(request_headers)
self.mediaflow_endpoint = "hls_key_proxy"
self._iframe_context: Optional[str] = None
self._flaresolverr_cookies: Optional[str] = None
self._flaresolverr_user_agent: Optional[str] = None
async def _fetch_via_flaresolverr(self, url: str) -> HttpResponse:
"""Fetch a URL using FlareSolverr to bypass Cloudflare protection."""
if not settings.flaresolverr_url:
raise ExtractorError("FlareSolverr URL not configured. Set FLARESOLVERR_URL in environment.")
flaresolverr_endpoint = f"{settings.flaresolverr_url.rstrip('/')}/v1"
payload = {
"cmd": "request.get",
"url": url,
"maxTimeout": settings.flaresolverr_timeout * 1000,
}
logger.info(f"Using FlareSolverr to fetch: {url}")
async with aiohttp.ClientSession() as session:
async with session.post(
flaresolverr_endpoint,
json=payload,
timeout=aiohttp.ClientTimeout(total=settings.flaresolverr_timeout + 10),
) as response:
if response.status != 200:
raise ExtractorError(f"FlareSolverr returned status {response.status}")
data = await response.json()
if data.get("status") != "ok":
raise ExtractorError(f"FlareSolverr failed: {data.get('message', 'Unknown error')}")
solution = data.get("solution", {})
html_content = solution.get("response", "")
final_url = solution.get("url", url)
status = solution.get("status", 200)
# Store cookies and user-agent for subsequent requests
cookies = solution.get("cookies", [])
if cookies:
cookie_str = "; ".join([f"{c['name']}={c['value']}" for c in cookies])
self._flaresolverr_cookies = cookie_str
logger.info(f"FlareSolverr provided {len(cookies)} cookies")
user_agent = solution.get("userAgent")
if user_agent:
self._flaresolverr_user_agent = user_agent
logger.info(f"FlareSolverr user-agent: {user_agent}")
logger.info(f"FlareSolverr successfully bypassed Cloudflare for: {url}")
return HttpResponse(
status=status,
headers={},
text=html_content,
content=html_content.encode("utf-8", errors="replace"),
url=final_url,
)
async def _make_request(
self, url: str, method: str = "GET", headers: Optional[Dict] = None, use_flaresolverr: bool = False, **kwargs
) -> HttpResponse:
"""Override to disable SSL verification and optionally use FlareSolverr."""
# Use FlareSolverr for Cloudflare-protected pages
if use_flaresolverr and settings.flaresolverr_url:
return await self._fetch_via_flaresolverr(url)
timeout = kwargs.pop("timeout", 15)
kwargs.pop("retries", 3) # consumed but not used directly
kwargs.pop("backoff_factor", 0.5) # consumed but not used directly
# Merge headers
request_headers = self.base_headers.copy()
if headers:
request_headers.update(headers)
# Add FlareSolverr cookies if available
if self._flaresolverr_cookies:
existing_cookies = request_headers.get("Cookie", "")
if existing_cookies:
request_headers["Cookie"] = f"{existing_cookies}; {self._flaresolverr_cookies}"
else:
request_headers["Cookie"] = self._flaresolverr_cookies
# Use FlareSolverr user-agent if available
if self._flaresolverr_user_agent:
request_headers["User-Agent"] = self._flaresolverr_user_agent
# Use create_aiohttp_session with verify=False for SSL bypass
async with create_aiohttp_session(url, timeout=timeout, verify=False) as (session, proxy_url):
async with session.request(method, url, headers=request_headers, proxy=proxy_url, **kwargs) as response:
content = await response.read()
final_url = str(response.url)
status = response.status
resp_headers = dict(response.headers)
if status >= 400:
raise ExtractorError(f"HTTP error {status} while requesting {url}")
return HttpResponse(
status=status,
headers=resp_headers,
text=content.decode("utf-8", errors="replace"),
content=content,
url=final_url,
)
async def _extract_session_data(self, iframe_url: str, main_url: str) -> dict | None:
"""
Fetch the iframe URL and extract auth_token, channel_key, and channel_salt.
Args:
iframe_url: The iframe URL to fetch
main_url: The main site domain for Referer header
Returns:
Dict with auth_token, channel_key, channel_salt, or None if not found
"""
headers = {
"User-Agent": self._flaresolverr_user_agent or DEFAULT_DLHD_USER_AGENT,
"Referer": f"https://{main_url}/",
}
try:
resp = await self._make_request(iframe_url, headers=headers, timeout=12)
html = resp.text
except Exception as e:
logger.warning(f"Error fetching iframe URL: {e}")
return None
# Pattern to extract EPlayerAuth.init block with authToken, channelKey, channelSalt
# Matches: EPlayerAuth.init({ authToken: '...', channelKey: '...', ..., channelSalt: '...' });
auth_pattern = r"EPlayerAuth\.init\s*\(\s*\{\s*authToken:\s*'([^']+)'"
channel_key_pattern = r"channelKey:\s*'([^']+)'"
channel_salt_pattern = r"channelSalt:\s*'([^']+)'"
# Pattern to extract server lookup base URL from fetchWithRetry call
lookup_pattern = r"fetchWithRetry\s*\(\s*'([^']+server_lookup\?channel_id=)"
auth_match = re.search(auth_pattern, html)
channel_key_match = re.search(channel_key_pattern, html)
channel_salt_match = re.search(channel_salt_pattern, html)
lookup_match = re.search(lookup_pattern, html)
if auth_match and channel_key_match and channel_salt_match:
result = {
"auth_token": auth_match.group(1),
"channel_key": channel_key_match.group(1),
"channel_salt": channel_salt_match.group(1),
}
if lookup_match:
result["server_lookup_url"] = lookup_match.group(1) + result["channel_key"]
return result
return None
async def _get_server_key(self, server_lookup_url: str, iframe_url: str) -> str | None:
"""
Fetch the server lookup URL and extract the server_key.
Args:
server_lookup_url: The server lookup URL
iframe_url: The iframe URL for extracting the host for headers
Returns:
The server_key or None if not found
"""
parsed = urlparse(iframe_url)
iframe_host = parsed.netloc
headers = {
"User-Agent": self._flaresolverr_user_agent or DEFAULT_DLHD_USER_AGENT,
"Referer": f"https://{iframe_host}/",
"Origin": f"https://{iframe_host}",
}
try:
resp = await self._make_request(server_lookup_url, headers=headers, timeout=10)
data = resp.json()
return data.get("server_key")
except Exception as e:
logger.warning(f"Error fetching server lookup: {e}")
return None
def _build_m3u8_url(self, server_key: str, channel_key: str) -> str:
"""
Build the m3u8 URL based on the server_key.
Args:
server_key: The server key from server lookup
channel_key: The channel key
Returns:
The m3u8 URL (with .css extension as per the original implementation)
"""
if server_key == "top1/cdn":
return f"https://top1.dvalna.ru/top1/cdn/{channel_key}/mono.css"
else:
return f"https://{server_key}new.dvalna.ru/{server_key}/{channel_key}/mono.css"
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."""
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)
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"
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",
}
)
# Build form data for multipart/form-data
form_data = aiohttp.FormData()
form_data.add_field("channelKey", params["channel_key"])
form_data.add_field("country", params["auth_country"])
form_data.add_field("timestamp", params["auth_ts"])
form_data.add_field("expiry", params["auth_expiry"])
form_data.add_field("token", params["auth_token"])
try:
async with create_aiohttp_session(auth_url, timeout=12, verify=False) as (session, proxy_url):
async with session.post(
auth_url,
headers=auth_headers,
data=form_data,
proxy=proxy_url,
) as response:
content = await response.read()
response.raise_for_status()
import json
auth_data = json.loads(content.decode("utf-8"))
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 ExtractorError:
raise
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
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 ExtractorError:
raise
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_lovecdn_stream(self, iframe_url: str, iframe_content: str, headers: dict) -> Dict[str, Any]:
"""
Alternative extractor for lovecdn.ru iframe that uses a different format.
"""
try:
# Look for direct stream URL patterns
m3u8_patterns = [
r'["\']([^"\']*\.m3u8[^"\']*)["\']',
r'source[:\s]+["\']([^"\']+)["\']',
r'file[:\s]+["\']([^"\']+\.m3u8[^"\']*)["\']',
r'hlsManifestUrl[:\s]*["\']([^"\']+)["\']',
]
stream_url = None
for pattern in m3u8_patterns:
matches = re.findall(pattern, iframe_content)
for match in matches:
if ".m3u8" in match and match.startswith("http"):
stream_url = match
logger.info(f"Found direct m3u8 URL: {stream_url}")
break
if stream_url:
break
# Pattern 2: Look for dynamic URL construction
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: look for any URL that looks like a 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("Could not find stream URL in lovecdn.ru iframe")
# Use iframe URL as referer
iframe_origin = f"https://{urlparse(iframe_url).netloc}"
stream_headers = {"User-Agent": headers["User-Agent"], "Referer": iframe_url, "Origin": iframe_origin}
# Determine endpoint based on the stream domain
endpoint = "hls_key_proxy"
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_direct_stream(self, channel_id: str) -> Dict[str, Any]:
"""
Direct stream extraction using server lookup API with the new auth flow.
This extracts auth_token, channel_key, channel_salt and computes key headers.
"""
# Common iframe domains for DLHD
iframe_domains = ["lefttoplay.xyz"]
for iframe_domain in iframe_domains:
try:
iframe_url = f"https://{iframe_domain}/premiumtv/daddyhd.php?id={channel_id}"
logger.info(f"Attempting extraction via {iframe_domain}")
session_data = await self._extract_session_data(iframe_url, "dlhd.link")
if not session_data:
logger.debug(f"No session data from {iframe_domain}")
continue
logger.info(f"Got session data from {iframe_domain}: channel_key={session_data['channel_key']}")
# Get server key
if "server_lookup_url" not in session_data:
logger.debug(f"No server lookup URL from {iframe_domain}")
continue
server_key = await self._get_server_key(session_data["server_lookup_url"], iframe_url)
if not server_key:
logger.debug(f"No server key from {iframe_domain}")
continue
logger.info(f"Got server key: {server_key}")
# Build m3u8 URL
m3u8_url = self._build_m3u8_url(server_key, session_data["channel_key"])
logger.info(f"M3U8 URL: {m3u8_url}")
# Build stream headers with auth
iframe_origin = f"https://{iframe_domain}"
stream_headers = {
"User-Agent": self._flaresolverr_user_agent or DEFAULT_DLHD_USER_AGENT,
"Referer": iframe_url,
"Origin": iframe_origin,
"Authorization": f"Bearer {session_data['auth_token']}",
}
# Return the result with key header parameters
# These will be used to compute headers when fetching keys
return {
"destination_url": m3u8_url,
"request_headers": stream_headers,
"mediaflow_endpoint": "hls_key_proxy",
# Force playlist processing since DLHD uses .css extension for m3u8
"force_playlist_proxy": True,
# Key header computation parameters
"dlhd_key_params": {
"channel_salt": session_data["channel_salt"],
"auth_token": session_data["auth_token"],
"iframe_url": iframe_url,
},
}
except Exception as e:
logger.warning(f"Failed extraction via {iframe_domain}: {e}")
continue
raise ExtractorError(f"Failed to extract stream from all iframe domains for channel {channel_id}")
async def extract(self, url: str, **kwargs) -> Dict[str, Any]:
"""Main extraction flow - uses direct server lookup with new auth flow."""
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)
# Also try stream-XXX pattern
match_stream = re.search(r"stream-(\d+)", u)
if match_stream:
return match_stream.group(1)
return None
try:
channel_id = extract_channel_id(url)
if not channel_id:
raise ExtractorError(f"Unable to extract channel ID from {url}")
logger.info(f"Extracting DLHD stream for channel ID: {channel_id}")
# Try direct stream extraction with new auth flow
try:
return await self._extract_direct_stream(channel_id)
except ExtractorError as e:
logger.warning(f"Direct stream extraction failed: {e}")
# Fallback to legacy iframe-based extraction if direct fails
logger.info("Falling back to iframe-based extraction...")
return await self._extract_via_iframe(url, channel_id)
except Exception as e:
raise ExtractorError(f"Extraction failed: {str(e)}")
async def _extract_via_iframe(self, url: str, channel_id: str) -> Dict[str, Any]:
"""Legacy iframe-based extraction flow - used as fallback."""
baseurl = "https://dlhd.dad/"
daddy_origin = urlparse(baseurl).scheme + "://" + urlparse(baseurl).netloc
daddylive_headers = {
"User-Agent": self._flaresolverr_user_agent
or "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. Request initial page - use FlareSolverr if available to bypass Cloudflare
use_flaresolverr = settings.flaresolverr_url is not None
resp1 = await self._make_request(url, headers=daddylive_headers, timeout=15, use_flaresolverr=use_flaresolverr)
resp1_text = resp1.text
# Update headers with FlareSolverr user-agent after initial request
if self._flaresolverr_user_agent:
daddylive_headers["User-Agent"] = self._flaresolverr_user_agent
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.")
# Try all players and collect all valid iframes
last_player_error = None
iframe_candidates = []
for player_url in player_links:
try:
if not player_url.startswith("http"):
player_url = baseurl + player_url.lstrip("/")
daddylive_headers["Referer"] = player_url
daddylive_headers["Origin"] = player_url
resp2 = await self._make_request(player_url, headers=daddylive_headers, timeout=12)
resp2_text = resp2.text
iframes2 = re.findall(r'<iframe.*?src="([^"]*)"', resp2_text)
# Collect all found iframes
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")
# Try each iframe until one works
last_iframe_error = None
for iframe_candidate in iframe_candidates:
try:
logger.info(f"Trying iframe: {iframe_candidate}")
iframe_domain = urlparse(iframe_candidate).netloc
if not iframe_domain:
logger.warning(f"Invalid iframe URL format: {iframe_candidate}")
continue
self._iframe_context = iframe_candidate
resp3 = await self._make_request(iframe_candidate, headers=daddylive_headers, timeout=12)
iframe_content = resp3.text
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 process iframe {iframe_candidate}: {e}")
last_iframe_error = e
continue
raise ExtractorError(f"All iframe candidates failed. Last error: {last_iframe_error}")
+181 -21
View File
@@ -1,49 +1,209 @@
import logging
import re
import time
from urllib.parse import urlparse, urljoin
import aiohttp
from curl_cffi.requests import AsyncSession
from mediaflow_proxy.extractors.base import BaseExtractor, ExtractorError
from mediaflow_proxy.configs import settings
logger = logging.getLogger(__name__)
_DOOD_UA = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
)
class DoodStreamExtractor(BaseExtractor):
"""
Dood / MyVidPlay extractor
Resolves to direct CDN MP4
"""
DoodStream / PlayMogo extractor.
def __init__(self, request_headers: dict):
super().__init__(request_headers)
self.base_url = "https://myvidplay.com"
All DoodStream mirror domains (dsvplay.com, myvidplay.com, dood.to, …) now
redirect to playmogo.com which sits behind Cloudflare and may require a
Turnstile CAPTCHA before serving the pass_md5 URL.
Extraction order:
1. Byparr — set BYPARR_URL (Firefox/Camoufox → Turnstile auto-validates,
not blocked by DisableDevtool.js)
2. curl_cffi — Chrome impersonation; works when Turnstile is not triggered,
raises a descriptive error if captcha is detected.
"""
async def extract(self, url: str, **kwargs):
parsed = urlparse(url)
video_id = parsed.path.rstrip("/").split("/")[-1]
if not video_id:
raise ExtractorError("Invalid Dood URL")
raise ExtractorError("Invalid DoodStream URL: no video ID found")
headers = {
"User-Agent": self.base_headers.get("User-Agent") or "Mozilla/5.0",
"Referer": f"{self.base_url}/",
if settings.byparr_url:
try:
return await self._extract_via_byparr(url, video_id)
except ExtractorError:
raise
return await self._extract_via_curl_cffi(url, video_id)
# ------------------------------------------------------------------
# Path 1 Byparr (Firefox/Camoufox → Turnstile auto-validates)
# ------------------------------------------------------------------
async def _extract_via_byparr(self, url: str, video_id: str) -> dict:
"""
Use Byparr to bypass Cloudflare protection on the DoodStream embed page.
Strategy: fetch the embed page without any injected script. Byparr's
Firefox/Camoufox browser auto-passes Cloudflare's bot checks and often
bypasses the Turnstile CAPTCHA gate directly, returning the embed HTML
with pass_md5. If the response doesn't contain pass_md5, reuse the CF
cookies + UA from Byparr in a follow-up curl_cffi request (which avoids
re-triggering the bot check).
"""
endpoint = f"{settings.byparr_url.rstrip('/')}/v1"
embed_url = url if "/e/" in url else f"https://{urlparse(url).netloc}/e/{video_id}"
payload = {
"cmd": "request.get",
"url": embed_url,
"maxTimeout": settings.byparr_timeout * 1000,
}
embed_url = f"{self.base_url}/e/{video_id}"
html = (await self._make_request(embed_url, headers=headers)).text
async with aiohttp.ClientSession() as session:
async with session.post(
endpoint,
json=payload,
timeout=aiohttp.ClientTimeout(total=settings.byparr_timeout + 15),
) as resp:
if resp.status != 200:
raise ExtractorError(f"Byparr HTTP {resp.status}")
data = await resp.json()
match = re.search(r"(\/pass_md5\/[^']+)", html)
if not match:
raise ExtractorError("Dood: pass_md5 not found")
if data.get("status") != "ok":
raise ExtractorError(f"Byparr: {data.get('message', 'unknown error')}")
pass_url = urljoin(self.base_url, match.group(1))
solution = data.get("solution", {})
final_url = solution.get("url", embed_url)
if not final_url.startswith("http"):
final_url = embed_url
base_url = f"https://{urlparse(final_url).netloc}"
html = solution.get("response", "")
base_stream = (await self._make_request(pass_url, headers=headers)).text.strip()
if "pass_md5" not in html:
# Byparr may not have the pass_md5 in the initial response.
# Try two recovery strategies in order:
#
# 1. Cookie reuse — if Byparr collected CF clearance cookies before
# the page loaded fully, inject them into a curl_cffi request.
# 2. Plain curl_cffi — Chrome TLS impersonation without JS execution.
raw_cookies = solution.get("cookies", [])
cookies = {c["name"]: c["value"] for c in raw_cookies}
ua = solution.get("userAgent", _DOOD_UA)
token_match = re.search(r"token=([^&]+)", html)
if cookies:
cf_domain = (
next(
(c.get("domain", "").lstrip(".") for c in raw_cookies if c.get("name") == "cf_clearance"),
None,
)
or "playmogo.com"
)
retry_url = f"https://{cf_domain}/e/{video_id}"
logger.debug(
"Byparr response lacked pass_md5 (final_url=%s); retrying %s with CF cookies via curl_cffi",
final_url,
retry_url,
)
proxy = self._get_proxy(retry_url)
async with AsyncSession() as s:
r = await s.get(
retry_url,
impersonate="chrome",
cookies=cookies,
headers={"User-Agent": ua, "Referer": f"https://{cf_domain}/"},
timeout=20,
**({"proxy": proxy} if proxy else {}),
)
html = r.text
final_url = str(r.url)
base_url = f"https://{urlparse(final_url).netloc}"
if "pass_md5" not in html:
logger.debug("Byparr cookie reuse also failed; falling back to curl_cffi for %s", embed_url)
return await self._extract_via_curl_cffi(embed_url, video_id)
return await self._parse_embed_html(html, base_url)
# ------------------------------------------------------------------
# Path 2 curl_cffi (bypasses CF bot protection; Turnstile may block)
# ------------------------------------------------------------------
async def _extract_via_curl_cffi(self, url: str, video_id: str) -> dict:
proxy = self._get_proxy(url)
async with AsyncSession() as s:
r = await s.get(
url,
impersonate="chrome",
headers={"Referer": f"https://{urlparse(url).netloc}/"},
timeout=30,
allow_redirects=True,
**({"proxy": proxy} if proxy else {}),
)
final_url = str(r.url)
html = r.text
base_url = f"https://{urlparse(final_url).netloc}"
if "pass_md5" not in html:
if "turnstile" in html.lower() or "captcha_l" in html:
raise ExtractorError(
"DoodStream: site is serving a Turnstile CAPTCHA that requires "
"browser interaction — cannot be bypassed automatically from this "
"network location. Try a residential IP or a VPN/proxy."
)
raise ExtractorError(f"DoodStream: pass_md5 not found in embed HTML ({final_url})")
return await self._parse_embed_html(html, base_url)
# ------------------------------------------------------------------
# Common HTML parser
# ------------------------------------------------------------------
async def _parse_embed_html(self, html: str, base_url: str) -> dict:
pass_match = re.search(r"(/pass_md5/[^'\"<>\s]+)", html)
if not pass_match:
raise ExtractorError("DoodStream: pass_md5 path not found in embed HTML")
pass_url = urljoin(base_url, pass_match.group(1))
ua = self.base_headers.get("user-agent") or _DOOD_UA
headers = {
"user-agent": ua,
"referer": f"{base_url}/",
}
proxy = self._get_proxy(pass_url)
async with AsyncSession() as s:
r = await s.get(
pass_url,
impersonate="chrome",
headers=headers,
timeout=20,
**({"proxy": proxy} if proxy else {}),
)
base_stream = r.text.strip()
if not base_stream or "RELOAD" in base_stream:
raise ExtractorError(
"DoodStream: pass_md5 endpoint returned no stream URL "
"(captcha session may have expired). "
"Ensure BYPARR_URL is set for reliable extraction."
)
token_match = re.search(r"token=([^&\s'\"]+)", html)
if not token_match:
raise ExtractorError("Dood: token missing")
raise ExtractorError("DoodStream: token not found in embed HTML")
token = token_match.group(1)
final_url = f"{base_stream}123456789?token={token}&expiry={int(time.time())}"
expiry = int(time.time())
final_url = f"{base_stream}123456789?token={token}&expiry={expiry}"
return {
"destination_url": final_url,
+4 -2
View File
@@ -1,8 +1,8 @@
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.city import CityExtractor
from mediaflow_proxy.extractors.sportsonline import SportsonlineExtractor
from mediaflow_proxy.extractors.filelions import FileLionsExtractor
from mediaflow_proxy.extractors.filemoon import FileMoonExtractor
@@ -24,12 +24,14 @@ 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
from mediaflow_proxy.extractors.vidfast import VidFastExtractor
class ExtractorFactory:
"""Factory for creating URL extractors."""
_extractors: Dict[str, Type[BaseExtractor]] = {
"City": CityExtractor,
"Doodstream": DoodStreamExtractor,
"FileLions": FileLionsExtractor,
"FileMoon": FileMoonExtractor,
@@ -46,13 +48,13 @@ class ExtractorFactory:
"Maxstream": MaxstreamExtractor,
"LiveTV": LiveTVExtractor,
"LuluStream": LuluStreamExtractor,
"DLHD": DLHDExtractor,
"Vavoo": VavooExtractor,
"Vidmoly": VidmolyExtractor,
"Vidoza": VidozaExtractor,
"Fastream": FastreamExtractor,
"Voe": VoeExtractor,
"Sportsonline": SportsonlineExtractor,
"VidFast": VidFastExtractor,
}
@classmethod
+22 -3
View File
@@ -1,23 +1,42 @@
import re
from typing import Dict, Any
from curl_cffi.requests import AsyncSession
from mediaflow_proxy.extractors.base import BaseExtractor, ExtractorError
class LuluStreamExtractor(BaseExtractor):
"""LuluStream URL extractor.
Uses curl_cffi + Chrome impersonation to bypass Cloudflare protection.
lulustream.com embeds are served via luluvdo.com.
"""
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)
proxy = self._get_proxy(url)
async with AsyncSession() as session:
response = await session.get(
url,
impersonate="chrome",
timeout=30,
allow_redirects=True,
**({"proxy": proxy} if proxy else {}),
)
if response.status_code >= 400:
raise ExtractorError(f"HTTP {response.status_code} while fetching {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)
raise ExtractorError("LuluStream: Failed to extract source URL")
final_url = match.group("url")
self.base_headers["referer"] = url
return {
+116 -70
View File
@@ -1,7 +1,7 @@
import re
import logging
from typing import Any, Dict
from urllib.parse import urlparse
from urllib.parse import urljoin, urlparse
from mediaflow_proxy.extractors.base import BaseExtractor, ExtractorError
from mediaflow_proxy.utils.packed import unpack
@@ -14,7 +14,7 @@ class SportsonlineExtractor(BaseExtractor):
Strategy:
1. Fetch page -> find first <iframe src="...">
2. Fetch iframe with Referer=https://sportzonline.st/
2. Fetch iframe with dynamic source-page Referer/Origin
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.
@@ -33,56 +33,125 @@ class SportsonlineExtractor(BaseExtractor):
"""
Detect and extract packed eval blocks from HTML.
"""
# 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)
raw_matches: list[str] = []
strict_eval_pattern = re.compile(r"eval\(function\(p,a,c,k,e,.*?\}\(.*?\)\)", re.DOTALL)
relaxed_eval_pattern = re.compile(r"eval\(function\(p,a,c,k,e,[dr]\).*?\}\(.*?\)\)", re.DOTALL)
# Prefer script-body extraction first. This is more resilient when the packed
# code has nested parentheses/semicolons that are hard to capture with a
# single regex.
script_pattern = re.compile(r"<script[^>]*>(.*?)</script>", re.IGNORECASE | re.DOTALL)
for script_body in script_pattern.findall(html):
if "eval(function(p,a,c,k,e" in script_body:
strict_matches = strict_eval_pattern.findall(script_body)
if strict_matches:
raw_matches.extend(strict_matches)
continue
relaxed_matches = relaxed_eval_pattern.findall(script_body)
if relaxed_matches:
raw_matches.extend(relaxed_matches)
if raw_matches:
return raw_matches
# Fallback: direct eval(...) extraction from raw HTML.
raw_matches = strict_eval_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)
raw_matches = relaxed_eval_pattern.findall(html)
return raw_matches
@staticmethod
def _extract_m3u8_candidate(text: str) -> str | None:
patterns = [
r"var\s+src\s*=\s*[\"']([^\"']+\.m3u8[^\"']*)[\"']",
r"src\s*=\s*[\"']([^\"']+\.m3u8[^\"']*)[\"']",
r"file\s*:\s*[\"']([^\"']+\.m3u8[^\"']*)[\"']",
r"[\"']([^\"']*https?://[^\"']+\.m3u8[^\"']*)[\"']",
r"(https?://[^\s\"'>]+\.m3u8[^\s\"'>]*)",
r"(//[^\s\"'>]+\.m3u8[^\s\"'>]*)",
r"(/[^\s\"'>]+\.m3u8[^\s\"'>]*)",
]
for pattern in patterns:
match = re.search(pattern, text)
if match:
return match.group(1)
return None
@staticmethod
def _normalize_stream_url(stream_url: str, base_url: str) -> str:
cleaned = stream_url.strip().strip("\"'").replace("\\/", "/")
if cleaned.startswith("//"):
parsed_base = urlparse(base_url)
return f"{parsed_base.scheme or 'https'}:{cleaned}"
if not urlparse(cleaned).scheme:
return urljoin(base_url, cleaned)
return cleaned
async def extract(self, url: str, **kwargs) -> Dict[str, Any]:
"""Main extraction flow: fetch page, extract iframe, unpack and find m3u8."""
try:
parsed_source = urlparse(url)
source_origin = f"{parsed_source.scheme}://{parsed_source.netloc}"
source_referer = self.base_headers.get("Referer") or self.base_headers.get("referer") or f"{source_origin}/"
user_agent = self.base_headers.get("User-Agent") or self.base_headers.get("user-agent") or "Mozilla/5.0"
# Step 1: Fetch main page
logger.info(f"Fetching main page: {url}")
main_response = await self._make_request(url, timeout=15)
main_response = await self._make_request(
url,
headers={
"Referer": source_referer,
"Origin": source_origin,
"User-Agent": user_agent,
"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",
},
timeout=15,
)
main_html = main_response.text
parsed_main = urlparse(main_response.url)
main_origin = f"{parsed_main.scheme}://{parsed_main.netloc}"
# 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")
# Extract first iframe (src can appear in any attribute order)
iframe_match = re.search(r'<iframe[^>]+(?<!data-)src=["\']([^"\']+)["\']', main_html, re.IGNORECASE)
iframe_url = main_response.url
iframe_html = main_html
iframe_url = iframe_match.group(1)
if iframe_match:
iframe_url = self._normalize_stream_url(iframe_match.group(1), main_response.url)
logger.info(f"Found iframe URL: {iframe_url}")
# 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}"
# Step 2: Fetch iframe with source page as referer
iframe_headers = {
"Referer": main_response.url,
"Origin": main_origin,
"User-Agent": user_agent,
"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",
}
logger.info(f"Found iframe URL: {iframe_url}")
iframe_response = await self._make_request(iframe_url, headers=iframe_headers, timeout=15)
iframe_html = iframe_response.text
iframe_url = iframe_response.url
logger.debug(f"Iframe HTML length: {len(iframe_html)}")
else:
logger.warning("No iframe found on page, attempting extraction from main HTML")
# 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",
parsed_iframe = urlparse(iframe_url)
playback_headers = {
"Referer": iframe_url,
"Origin": f"{parsed_iframe.scheme}://{parsed_iframe.netloc}",
"User-Agent": user_agent,
}
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)
@@ -91,21 +160,19 @@ class SportsonlineExtractor(BaseExtractor):
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)
direct_match = self._extract_m3u8_candidate(iframe_html)
if direct_match:
m3u8_url = direct_match.group(1)
m3u8_url = self._normalize_stream_url(direct_match, iframe_url)
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"]},
"request_headers": playback_headers,
"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
@@ -123,22 +190,7 @@ class SportsonlineExtractor(BaseExtractor):
# 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
m3u8_url = self._extract_m3u8_candidate(unpacked_code)
# If not found, try all other blocks
if not m3u8_url:
@@ -148,36 +200,30 @@ class SportsonlineExtractor(BaseExtractor):
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
m3u8_url = self._extract_m3u8_candidate(unpacked_code)
if m3u8_url:
logger.info(f"Found m3u8 in block {i}")
break
except Exception as e:
logger.debug(f"Failed to process block {i}: {e}")
continue
if not m3u8_url:
fallback_candidate = self._extract_m3u8_candidate(iframe_html)
if fallback_candidate:
m3u8_url = fallback_candidate
if not m3u8_url:
raise ExtractorError("Could not extract m3u8 URL from packed code")
m3u8_url = self._normalize_stream_url(m3u8_url, iframe_url)
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"]},
"request_headers": playback_headers,
"mediaflow_endpoint": self.mediaflow_endpoint,
}
+7 -2
View File
@@ -24,12 +24,17 @@ class SupervideoExtractor(BaseExtractor):
Uses curl_cffi with Chrome impersonation to bypass Cloudflare.
"""
patterns = [r'file:"(.*?)"']
proxy = self._get_proxy(url)
try:
async with AsyncSession() as session:
response = await session.get(url, impersonate="chrome")
response = await session.get(
url,
impersonate="chrome",
timeout=30,
**({"proxy": proxy} if proxy else {}),
)
if response.status_code != 200:
raise ExtractorError(f"HTTP {response.status_code} while fetching {url}")
+25 -8
View File
@@ -1,22 +1,39 @@
import re
from typing import Dict
from typing import Dict, Any
from urllib.parse import urljoin
from curl_cffi.requests import AsyncSession
from mediaflow_proxy.extractors.base import BaseExtractor, ExtractorError
class UqloadExtractor(BaseExtractor):
"""Uqload URL extractor."""
"""Uqload URL extractor.
async def extract(self, url: str, **kwargs) -> Dict[str, str]:
"""Extract Uqload URL."""
response = await self._make_request(url)
Uses curl_cffi + Chrome impersonation to handle Cloudflare protection.
Follows redirects automatically (uqload.bz/co/io all redirect to uqload.is).
"""
video_url_match = re.search(r'sources: \["(.*?)"]', response.text)
async def extract(self, url: str, **kwargs) -> Dict[str, Any]:
proxy = self._get_proxy(url)
async with AsyncSession() as session:
response = await session.get(
url,
impersonate="chrome",
timeout=30,
allow_redirects=True,
**({"proxy": proxy} if proxy else {}),
)
if response.status_code >= 400:
raise ExtractorError(f"HTTP {response.status_code} while fetching {url}")
video_url_match = re.search(r'sources:\s*\["(https?://[^"]+)"', response.text)
if not video_url_match:
raise ExtractorError("Failed to extract video URL")
raise ExtractorError("Uqload: video URL not found in page source")
self.base_headers["referer"] = urljoin(url, "/")
final_url = str(response.url)
self.base_headers["referer"] = urljoin(final_url, "/")
return {
"destination_url": video_url_match.group(1),
"request_headers": self.base_headers,
+244 -174
View File
@@ -1,5 +1,9 @@
import logging
import time
import re
import uuid
from typing import Any, Dict, Optional
from urllib.parse import quote, urlparse
from mediaflow_proxy.extractors.base import BaseExtractor, ExtractorError
@@ -7,71 +11,29 @@ logger = logging.getLogger(__name__)
class VavooExtractor(BaseExtractor):
"""Vavoo URL extractor for resolving vavoo.to links.
"""Vavoo URL extractor per risolvere link vavoo.to"""
Supports two URL formats:
1. Web-VOD API links: https://vavoo.to/web-vod/api/get?link=...
These redirect (302) to external video hosts (Doodstream, etc.)
2. Legacy mediahubmx format (currently broken on Vavoo's end)
Features:
- Uses BaseExtractor's retry/timeouts
- Improved headers to mimic Android okhttp client
- Robust JSON handling and logging
"""
API_UA = "okhttp/4.11.0"
RESOLVE_UA = "MediaHubMX/2"
TS_UA = "VAVOO/2.6"
def __init__(self, request_headers: dict):
super().__init__(request_headers)
# Endpoint is resolved dynamically per-extraction based on the stream URL type.
self.mediaflow_endpoint = "proxy_stream_endpoint"
async def _resolve_web_vod_link(self, url: str) -> str:
"""Resolve a web-vod API link by getting the redirect Location header."""
import aiohttp
try:
# Use aiohttp directly with allow_redirects=False to get the Location header
timeout = aiohttp.ClientTimeout(total=10)
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.get(
url,
headers={"Accept": "application/json"},
allow_redirects=False,
) as resp:
# Check for redirect
if resp.status in (301, 302, 303, 307, 308):
location = resp.headers.get("Location") or resp.headers.get("location")
if location:
logger.info(f"Vavoo web-vod redirected to: {location}")
return location
# If we got a 200, the response might contain the URL
if resp.status == 200:
text = await resp.text()
if text and text.startswith("http"):
logger.info(f"Vavoo web-vod resolved to: {text.strip()}")
return text.strip()
raise ExtractorError(f"Vavoo web-vod API returned unexpected status {resp.status}")
except ExtractorError:
raise
except Exception as e:
raise ExtractorError(f"Failed to resolve Vavoo web-vod link: {e}")
async def get_auth_signature(self) -> Optional[str]:
"""Get authentication signature for Vavoo API (async)."""
async def _get_auth_signature(self) -> Optional[str]:
"""Get authentication signature via lokke.app/api/app/ping (aligned with working plugin)."""
unique_id = uuid.uuid4().hex[:16]
now_ms = int(time.time() * 1000)
headers = {
"user-agent": "okhttp/4.11.0",
"user-agent": self.API_UA,
"accept": "application/json",
"content-type": "application/json; charset=utf-8",
"accept-encoding": "gzip",
}
import time
current_time = int(time.time() * 1000)
data = {
"token": "",
body = {
"token": "ldCvE092e7gER0rVIajfsXIvRhwlrAzP6_1oEJ4q6HH89QHt24v6NNL_jQJO219hiLOXF2hqEfsUuEWitEIGN4EaHHEHb7Cd7gojc5SQYRFzU3XWo_kMeryAUbcwWnQrnf0-",
"reason": "app-blur",
"locale": "de",
"theme": "dark",
@@ -79,174 +41,282 @@ class VavooExtractor(BaseExtractor):
"device": {
"type": "Handset",
"brand": "google",
"model": "Pixel",
"name": "sdk_gphone64_arm64",
"uniqueId": "d10e5d99ab665233",
"model": "Nexus",
"name": "21081111RG",
"uniqueId": unique_id,
},
"os": {"name": "android", "version": "7.1.2", "abis": ["arm64-v8a"], "host": "android"},
"app": {
"platform": "android",
"version": "1.1.0",
"buildId": "97215000",
"engine": "hbc85",
"signatures": ["6e8a975e3cbf07d5de823a760d4c2547f86c1403105020adee5de67ac510999e"],
"installer": "com.android.vending",
},
"version": {"package": "app.lokke.main", "binary": "1.1.0", "js": "1.1.0"},
"platform": {
"isAndroid": True,
"isIOS": False,
"isTV": False,
"isWeb": False,
"isMobile": True,
"isWebTV": False,
"isElectron": False,
},
"os": {"name": "android", "version": "13"},
"app": {"platform": "android", "version": "3.1.21"},
"version": {"package": "tv.vavoo.app", "binary": "3.1.21", "js": "3.1.21"},
},
"appFocusTime": 0,
"playerActive": False,
"playDuration": 0,
"devMode": False,
"devMode": True,
"hasAddon": True,
"castConnected": False,
"package": "tv.vavoo.app",
"version": "3.1.21",
"package": "app.lokke.main",
"version": "1.1.0",
"process": "app",
"firstAppStart": current_time,
"lastAppStart": current_time,
"ipLocation": "",
"adblockEnabled": True,
"firstAppStart": now_ms - 86400000,
"lastAppStart": now_ms,
"ipLocation": None,
"adblockEnabled": False,
"proxy": {
"supported": ["ss", "openvpn"],
"engine": "ss",
"engine": "openvpn",
"ssVersion": 1,
"enabled": True,
"enabled": False,
"autoServer": True,
"id": "de-fra",
"id": "fi-hel",
},
"iap": {"supported": False},
"iap": {"supported": True},
}
try:
resp = await self._make_request(
"https://www.vavoo.tv/api/app/ping",
"https://www.lokke.app/api/app/ping",
method="POST",
json=data,
json=body,
headers=headers,
timeout=10,
timeout=15,
retries=2,
)
try:
result = resp.json()
except Exception:
logger.warning("Vavoo ping returned non-json response (status=%s).", resp.status)
logger.warning("Lokke ping returned non-json response (status=%s).", resp.status)
return None
addon_sig = result.get("addonSig") if isinstance(result, dict) else None
if addon_sig:
logger.info("Successfully obtained Vavoo authentication signature")
logger.info("Successfully obtained auth signature from lokke.app")
return addon_sig
else:
logger.warning("No addonSig in Vavoo API response: %s", result)
return None
except ExtractorError as e:
logger.warning("Failed to get Vavoo auth signature: %s", e)
logger.warning("No addonSig in lokke API response: %s", result)
return None
except Exception as e:
logger.debug("_get_auth_signature error: %s", e)
return None
async def extract(self, url: str, **kwargs) -> Dict[str, Any]:
"""Extract Vavoo stream URL (async).
async def _get_ts_signature(self) -> Optional[str]:
"""Get TS-based signature via /api/box/ping2 (fallback)."""
vec = "9frjpxPjxSNilxJPCJ0XGYs6scej3dW/h/VWlnKUiLSG8IP7mfyDU7NirOlld+VtCKGj03XjetfliDMhIev7wcARo+YTU8KPFuVQP9E2DVXzY2BFo1NhE6qEmPfNDnm74eyl/7iFJ0EETm6XbYyz8IKBkAqPN/Spp3PZ2ulKg3QBSDxcVN4R5zRn7OsgLJ2CNTuWkd/h451lDCp+TtTuvnAEhcQckdsydFhTZCK5IiWrrTIC/d4qDXEd+GtOP4hPdoIuCaNzYfX3lLCwFENC6RZoTBYLrcKVVgbqyQZ7DnLqfLqvf3z0FVUWx9H21liGFpByzdnoxyFkue3NzrFtkRL37xkx9ITucepSYKzUVEfyBh+/3mtzKY26VIRkJFkpf8KVcCRNrTRQn47Wuq4gC7sSwT7eHCAydKSACcUMMdpPSvbvfOmIqeBNA83osX8FPFYUMZsjvYNEE3arbFiGsQlggBKgg1V3oN+5ni3Vjc5InHg/xv476LHDFnNdAJx448ph3DoAiJjr2g4ZTNynfSxdzA68qSuJY8UjyzgDjG0RIMv2h7DlQNjkAXv4k1BrPpfOiOqH67yIarNmkPIwrIV+W9TTV/yRyE1LEgOr4DK8uW2AUtHOPA2gn6P5sgFyi68w55MZBPepddfYTQ+E1N6R/hWnMYPt/i0xSUeMPekX47iucfpFBEv9Uh9zdGiEB+0P3LVMP+q+pbBU4o1NkKyY1V8wH1Wilr0a+q87kEnQ1LWYMMBhaP9yFseGSbYwdeLsX9uR1uPaN+u4woO2g8sw9Y5ze5XMgOVpFCZaut02I5k0U4WPyN5adQjG8sAzxsI3KsV04DEVymj224iqg2Lzz53Xz9yEy+7/85ILQpJ6llCyqpHLFyHq/kJxYPhDUF755WaHJEaFRPxUqbparNX+mCE9Xzy7Q/KTgAPiRS41FHXXv+7XSPp4cy9jli0BVnYf13Xsp28OGs/D8Nl3NgEn3/eUcMN80JRdsOrV62fnBVMBNf36+LbISdvsFAFr0xyuPGmlIETcFyxJkrGZnhHAxwzsvZ+Uwf8lffBfZFPRrNv+tgeeLpatVcHLHZGeTgWWml6tIHwWUqv2TVJeMkAEL5PPS4Gtbscau5HM+FEjtGS+KClfX1CNKvgYJl7mLDEf5ZYQv5kHaoQ6RcPaR6vUNn02zpq5/X3EPIgUKF0r/0ctmoT84B2J1BKfCbctdFY9br7JSJ6DvUxyde68jB+Il6qNcQwTFj4cNErk4x719Y42NoAnnQYC2/qfL/gAhJl8TKMvBt3Bno+va8ve8E0z8yEuMLUqe8OXLce6nCa+L5LYK1aBdb60BYbMeWk1qmG6Nk9OnYLhzDyrd9iHDd7X95OM6X5wiMVZRn5ebw4askTTc50xmrg4eic2U1w1JpSEjdH/u/hXrWKSMWAxaj34uQnMuWxPZEXoVxzGyuUbroXRfkhzpqmqqqOcypjsWPdq5BOUGL/Riwjm6yMI0x9kbO8+VoQ6RYfjAbxNriZ1cQ+AW1fqEgnRWXmjt4Z1M0ygUBi8w71bDML1YG6UHeC2cJ2CCCxSrfycKQhpSdI1QIuwd2eyIpd4LgwrMiY3xNWreAF+qobNxvE7ypKTISNrz0iYIhU0aKNlcGwYd0FXIRfKVBzSBe4MRK2pGLDNO6ytoHxvJweZ8h1XG8RWc4aB5gTnB7Tjiqym4b64lRdj1DPHJnzD4aqRixpXhzYzWVDN2kONCR5i2quYbnVFN4sSfLiKeOwKX4JdmzpYixNZXjLkG14seS6KR0Wl8Itp5IMIWFpnNokjRH76RYRZAcx0jP0V5/GfNNTi5QsEU98en0SiXHQGXnROiHpRUDXTl8FmJORjwXc0AjrEMuQ2FDJDmAIlKUSLhjbIiKw3iaqp5TVyXuz0ZMYBhnqhcwqULqtFSuIKpaW8FgF8QJfP2frADf4kKZG1bQ99MrRrb2A="
try:
resp = await self._make_request(
"https://www.vavoo.tv/api/box/ping2",
method="POST",
data={"vec": vec},
timeout=15,
retries=2,
)
try:
result = resp.json()
except Exception:
return None
return (result.get("response") or {}).get("signed")
except Exception as e:
logger.debug("_get_ts_signature error: %s", e)
return None
Supports:
- Direct play URLs: https://vavoo.to/play/{id}/index.m3u8 (Live TV)
- Web-VOD API links: https://vavoo.to/web-vod/api/get?link=...
- Legacy mediahubmx links (may not work due to Vavoo API changes)
"""
if "vavoo.to" not in url:
raise ExtractorError("Not a valid Vavoo URL")
# Check if this is a direct play URL (Live TV)
# These URLs are already m3u8 streams but need auth signature
if "/play/" in url and url.endswith(".m3u8"):
signature = await self.get_auth_signature()
if not signature:
raise ExtractorError("Failed to get Vavoo authentication signature for Live TV")
stream_headers = {
"user-agent": "okhttp/4.11.0",
"referer": "https://vavoo.to/",
"mediahubmx-signature": signature,
}
return {
"destination_url": url,
"request_headers": stream_headers,
"mediaflow_endpoint": "hls_manifest_proxy",
}
# Check if this is a web-vod API link (new format)
if "/web-vod/api/get" in url:
resolved_url = await self._resolve_web_vod_link(url)
stream_headers = {
"user-agent": self.base_headers.get("user-agent", "Mozilla/5.0"),
"referer": "https://vavoo.to/",
}
return {
"destination_url": resolved_url,
"request_headers": stream_headers,
"mediaflow_endpoint": self.mediaflow_endpoint,
}
# Legacy mediahubmx flow
signature = await self.get_auth_signature()
if not signature:
raise ExtractorError("Failed to get Vavoo authentication signature")
resolved_url = await self._resolve_vavoo_link(url, signature)
if not resolved_url:
raise ExtractorError("Failed to resolve Vavoo URL")
stream_headers = {
"user-agent": self.base_headers.get("user-agent", "okhttp/4.11.0"),
"referer": "https://vavoo.to/",
}
return {
"destination_url": resolved_url,
"request_headers": stream_headers,
"mediaflow_endpoint": self.mediaflow_endpoint,
}
async def _resolve_vavoo_link(self, link: str, signature: str) -> Optional[str]:
"""Resolve a Vavoo link using the MediaHubMX API (async)."""
async def _resolve_with_auth(self, url: str, signature: str) -> Optional[str]:
"""Resolve a Vavoo link using the MediaHubMX API with auth signature."""
headers = {
"user-agent": "okhttp/4.11.0",
"user-agent": self.RESOLVE_UA,
"accept": "application/json",
"content-type": "application/json; charset=utf-8",
"accept-encoding": "gzip",
"mediahubmx-signature": signature,
}
data = {"language": "de", "region": "AT", "url": link, "clientVersion": "3.1.21"}
payload = {"language": "de", "region": "AT", "url": url, "clientVersion": "3.0.2"}
try:
logger.info(f"Attempting to resolve Vavoo URL: {link}")
resp = await self._make_request(
"https://vavoo.to/mediahubmx-resolve.json",
method="POST",
json=data,
json=payload,
headers=headers,
timeout=12,
timeout=15,
retries=3,
backoff_factor=0.6,
)
try:
result = resp.json()
except Exception:
logger.warning(
"Vavoo resolve returned non-json response (status=%s). Body preview: %s",
resp.status,
getattr(resp, "text", "")[:500],
)
logger.warning("Vavoo resolve returned non-json (status=%s)", resp.status)
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("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("Successfully resolved Vavoo URL to: %s", resolved_url)
return resolved_url
else:
logger.warning("No URL found in Vavoo API response: %s", result)
return None
except ExtractorError as e:
logger.error(f"Vavoo resolution failed for URL {link}: {e}")
raise ExtractorError(f"Vavoo resolution failed: {str(e)}") from e
return str(result[0]["url"])
if isinstance(result, dict):
if result.get("url"):
return str(result["url"])
if isinstance(result.get("data"), dict) and result["data"].get("url"):
return str(result["data"]["url"])
logger.warning("No URL found in Vavoo API response: %s", result)
return None
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
logger.debug("_resolve_with_auth error: %s", e)
return None
async def _follow_stream_url(self, url: str) -> str:
"""Follow redirects and extract final stream URL."""
stream_headers = {
"User-Agent": self.API_UA,
"Accept": "*/*",
"Accept-Encoding": "gzip, deflate",
"Connection": "close",
}
try:
resp = await self._make_request(url, method="HEAD", headers=stream_headers, timeout=15, retries=1)
final_url = str(getattr(resp, "url", url))
ctype = (getattr(resp, "headers", {}).get("Content-Type") or "").lower()
if "text/html" in ctype:
resp2 = await self._make_request(url, method="GET", headers=stream_headers, timeout=15, retries=1)
text = getattr(resp2, "text", "") or ""
m3u8 = re.findall(r'(https?://[^\s"\'<>]+\.m3u8[^\s"\'<>]*)', text)
if m3u8:
return m3u8[0]
generic = re.findall(
r'(https?://[^\s"\'<>]+(?:\.ts|/live/|/stream/|/playlist|/index)[^\s"\'<>]*)', text
)
if generic:
return generic[0]
return final_url
except Exception:
return url
async def _build_ts_fallback(self, url: str) -> Optional[str]:
"""Build a .ts fallback URL for vavoo-iptv streams using ping2 signature."""
if "vavoo-iptv" not in url:
return None
ts_sig = await self._get_ts_signature()
if not ts_sig:
return None
base = re.sub(r"/index\.m3u8(?:\?.*)?$", "", url.replace("vavoo-iptv", "live2")).rstrip("/")
ts_url = f"{base}.ts?n=1&b=5&vavoo_auth={quote(ts_sig, safe='')}"
try:
resp = await self._make_request(
ts_url, method="GET", headers={"User-Agent": self.TS_UA}, timeout=15, retries=1
)
if getattr(resp, "status", 400) < 400:
return ts_url
except Exception:
pass
return None
async def _resolve_web_vod_link(self, url: str) -> str:
"""Resolve a web-vod API link by getting the redirect Location header."""
try:
resp = await self._make_request(
url,
method="GET",
headers={"Accept": "application/json"},
timeout=10,
retries=2,
allow_redirects=False,
)
status = getattr(resp, "status", 0)
if status in (301, 302, 303, 307, 308):
location = getattr(resp, "headers", {}).get("Location") or getattr(resp, "headers", {}).get("location")
if location:
logger.info("Vavoo web-vod redirected to: %s", location)
return location
if status == 200:
text = getattr(resp, "text", "") or ""
if text and text.startswith("http"):
logger.info("Vavoo web-vod resolved to: %s", text.strip())
return text.strip()
raise ExtractorError(f"Vavoo web-vod API returned unexpected status {status}")
except ExtractorError:
raise
except Exception as e:
raise ExtractorError(f"Failed to resolve Vavoo web-vod link: {e}")
async def extract(self, url: str, **kwargs) -> Dict[str, Any]:
"""Extract Vavoo stream URL.
Flow:
1. Auth Resolve Mode: electron-mode signature → mediahubmx-resolve
2. TS Fallback Mode: ping2 signature → live2 .ts URL
3. Direct Fallback: raw URL with VAVOO UA
"""
if "vavoo.to" not in url:
raise ExtractorError("Not a valid Vavoo URL")
# Web-VOD links (new format)
if "/web-vod/api/get" in url:
resolved_url = await self._resolve_web_vod_link(url)
stream_headers = {
"user-agent": self.API_UA,
"referer": "https://vavoo.to/",
}
wv_path = urlparse(resolved_url).path.lower()
wv_endpoint = (
"hls_manifest_proxy" if wv_path.endswith((".m3u8", ".m3u", ".m3u_plus")) else self.mediaflow_endpoint
)
return {
"destination_url": resolved_url,
"request_headers": stream_headers,
"mediaflow_endpoint": wv_endpoint,
}
resolved_url = None
stream_headers = None
# Mode 1: Auth Resolve (electron signature + mediahubmx)
sig = await self._get_auth_signature()
if sig:
candidate = await self._resolve_with_auth(url, sig)
if candidate:
candidate = await self._follow_stream_url(candidate)
resolved_url = candidate
stream_headers = {
"user-agent": self.RESOLVE_UA,
"referer": "https://vavoo.to/",
"origin": "https://vavoo.to",
}
logger.info("Using Auth Resolve Mode: %s", resolved_url)
# Mode 2: TS Fallback (ping2 + live2 .ts)
if not resolved_url:
ts_url = await self._build_ts_fallback(url)
if ts_url:
resolved_url = ts_url
stream_headers = {"user-agent": self.TS_UA}
logger.info("Using TS Fallback Mode: %s", resolved_url)
# Mode 3: Direct Fallback
if not resolved_url:
resolved_url = url
stream_headers = {
"user-agent": self.TS_UA,
"referer": "https://vavoo.to/",
}
logger.info("Using Direct Fallback Mode: %s", resolved_url)
# Use HLS manifest proxy when the resolved URL is an M3U8 playlist so
# the proxy rewrites relative segment URLs before the player sees them.
# TS / raw stream URLs go through the stream proxy as-is.
path = urlparse(resolved_url).path.lower()
m3u8_endpoint = (
"hls_manifest_proxy" if path.endswith((".m3u8", ".m3u", ".m3u_plus")) else self.mediaflow_endpoint
)
return {
"destination_url": resolved_url,
"request_headers": stream_headers,
"mediaflow_endpoint": m3u8_endpoint,
}
+110
View File
@@ -0,0 +1,110 @@
import re
from typing import Dict, Any
from urllib.parse import urlparse
import aiohttp
from mediaflow_proxy.extractors.base import BaseExtractor, ExtractorError
class VidFastExtractor(BaseExtractor):
"""
Extractor for vidfast.pro (movies and TV via ythd.org → cloudnestra.com).
URL formats accepted:
https://vidfast.pro/movie/{tmdb_id}
https://vidfast.pro/tv/{tmdb_id}/{season}/{episode}
Extraction flow:
1. Parse TMDB ID from the URL path.
2. Fetch https://ythd.org/embed/{tmdb_id} → grab first data-hash.
3. Fetch https://cloudnestra.com/rcp/{hash} (carrying ythd cookies)
→ grab /prorcp/ hash from the inline iframe src.
4. Fetch https://cloudnestra.com/prorcp/{prorcp_hash}
→ grab Playerjs `file:` parameter (HLS master playlist URL).
5. Replace the {v1} CDN placeholder with cloudnestra.com and return
the resolved HLS URL for MediaFlow's hls_manifest_proxy endpoint.
"""
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]:
parsed = urlparse(url)
parts = parsed.path.strip("/").split("/")
if len(parts) < 2:
raise ExtractorError(f"VidFast: cannot parse TMDB ID from path: {parsed.path!r}")
tmdb_id = parts[1]
ua = self.base_headers.get(
"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",
)
ythd_url = f"https://ythd.org/embed/{tmdb_id}"
# A single aiohttp session preserves cookies across the three hops.
cookie_jar = aiohttp.CookieJar()
timeout = aiohttp.ClientTimeout(total=30)
async with aiohttp.ClientSession(cookie_jar=cookie_jar, timeout=timeout) as session:
# ── Step 1: ythd.org embed page ───────────────────────────────
async with session.get(ythd_url, headers={"User-Agent": ua}) as resp:
if resp.status >= 400:
raise ExtractorError(f"VidFast: ythd.org returned HTTP {resp.status}")
ythd_html = await resp.text()
hash_match = re.search(r'data-hash="([^"]+)"', ythd_html)
if not hash_match:
raise ExtractorError("VidFast: no data-hash attribute on ythd.org page")
data_hash = hash_match.group(1)
# ── Step 2: cloudnestra /rcp/ (needs ythd.org cookies) ────────
rcp_url = f"https://cloudnestra.com/rcp/{data_hash}"
async with session.get(
rcp_url,
headers={"User-Agent": ua, "Referer": ythd_url},
) as resp:
if resp.status >= 400:
raise ExtractorError(f"VidFast: cloudnestra /rcp/ returned HTTP {resp.status}")
rcp_html = await resp.text()
prorcp_match = re.search(r"src:\s*'/prorcp/([^']+)'", rcp_html)
if not prorcp_match:
raise ExtractorError("VidFast: /prorcp/ hash not found in cloudnestra page")
prorcp_hash = prorcp_match.group(1)
# ── Step 3: cloudnestra /prorcp/ (actual player page) ─────────
prorcp_url = f"https://cloudnestra.com/prorcp/{prorcp_hash}"
async with session.get(
prorcp_url,
headers={"User-Agent": ua, "Referer": rcp_url},
) as resp:
if resp.status >= 400:
raise ExtractorError(f"VidFast: cloudnestra /prorcp/ returned HTTP {resp.status}")
prorcp_html = await resp.text()
# ── Step 4: extract the HLS URL from Playerjs({…, file:"…"}) ──────
file_match = re.search(r'file:\s*"(https://[^"]+)"', prorcp_html)
if not file_match:
raise ExtractorError("VidFast: Playerjs file URL not found in /prorcp/ page")
# The file value may contain multiple fallback URLs separated by " or ".
first_url = file_match.group(1).split(" or ")[0].strip()
# {v1} is the primary CDN; tmstr4.cloudnestra.com hosts the proxied HLS.
stream_url = first_url.replace("{v1}", "cloudnestra.com")
if not stream_url.startswith("https://"):
raise ExtractorError(f"VidFast: unexpected stream URL: {stream_url[:120]!r}")
return {
"destination_url": stream_url,
"request_headers": {
"user-agent": ua,
"referer": "https://cloudnestra.com/",
},
"mediaflow_endpoint": self.mediaflow_endpoint,
}
+11 -1
View File
@@ -46,7 +46,17 @@ class VixCloudExtractor(BaseExtractor):
iframe = soup.find("iframe").get("src")
response = await self._make_request(iframe, headers={"x-inertia": "true", "x-inertia-version": version})
elif "movie" in url or "tv" in url:
response = await self._make_request(url)
marker = "/movie" if "/movie" in url else "/tv"
site_url = (url.split(marker))[0]
parts = url.split(site_url)
headers = {
"Referer": f"{site_url}/",
"Origin": f"{site_url}",
}
response = await self._make_request(site_url + '/api' + parts[1])
response = await self._make_request(site_url + '/' + response.json()['src'],headers=headers)
if response.status != 200:
raise ExtractorError("Failed to extract URL components, Invalid Request")
+234 -14
View File
@@ -2,12 +2,14 @@ import asyncio
import base64
import logging
import time
from typing import Optional
from typing import Optional, AsyncGenerator
from urllib.parse import urlparse, parse_qs
import aiohttp
from aiohttp import ClientTimeout
import tenacity
from fastapi import Request, Response, HTTPException
from fastapi.responses import StreamingResponse
from starlette.background import BackgroundTask
from .const import SUPPORTED_RESPONSE_HEADERS
@@ -394,10 +396,9 @@ async def handle_stream_request(
range_header = proxy_headers.request.get("range", "not set")
logger.info(f"[handle_stream] Starting upstream {method} request - range: {range_header}")
# Track if this is an auto-added "bytes=0-" range (client didn't send range)
# We detect this by checking if range equals exactly "bytes=0-" which indicates
# a proxy-added default range, not a client seeking request
auto_added_range = proxy_headers.request.get("range") == "bytes=0-"
# Check if the range header was auto-added by proxy (not from client)
# This is set in proxy.py when we add bytes=0- because client didn't send a range
auto_added_range = getattr(proxy_headers, "auto_added_range", False)
# Use the same HTTP method for upstream request (HEAD for HEAD, GET for GET)
# This prevents unnecessary data download when client just wants headers
@@ -415,9 +416,16 @@ async def handle_stream_request(
# When client didn't send a Range header but upstream returns 206 Partial Content:
# - Convert status to 200 (full content, not partial)
# - Remove content-range header to avoid confusing the client
# - BUT keep Accept-Ranges: bytes so client knows seeking is supported
# This handles cases where we added bytes=0- range but upstream still treats it as a range request
status_code = streamer.response.status
if status_code == 206:
# Always ensure Accept-Ranges: bytes is present for 206 responses
# This tells clients like ExoPlayer that they can seek by sending Range requests
# Some upstream servers (like TorBox) don't send this header even though they support ranges
if "accept-ranges" not in [h.lower() for h in response_headers.keys()]:
response_headers["accept-ranges"] = "bytes"
if "content-range" in [h.lower() for h in proxy_headers.remove]:
# Explicitly requested to remove content-range
status_code = 200
@@ -510,10 +518,21 @@ def prepare_response_headers(
remove_set = set(h.lower() for h in (remove_headers or []))
response_headers = {}
# aiohttp transparently decompresses gzip/deflate/br responses. When it does,
# the original Content-Length (which was the *compressed* size) is no longer
# valid for the decompressed body we are forwarding. Forwarding that stale
# Content-Length causes h11 to raise "Too much data for declared Content-Length"
# when the decompressed body is larger than the declared length.
upstream_content_encoding = any(k.lower() == "content-encoding" for k in original_headers.keys())
# Handle aiohttp CIMultiDictProxy
for k, v in original_headers.items():
k_lower = k.lower()
if k_lower in SUPPORTED_RESPONSE_HEADERS and k_lower not in remove_set:
# Drop Content-Length when the upstream compressed the body — the
# decompressed size differs and the header would be wrong.
if k_lower == "content-length" and upstream_content_encoding:
continue
response_headers[k_lower] = v
# Apply propagate headers first (for segments), then response headers (response takes precedence)
@@ -660,6 +679,26 @@ async def fetch_and_process_m3u8(
return handle_exceptions(e)
def _parse_combined_key_param(key_id: str | None, key: str | None) -> tuple[str | None, str | None]:
"""
Support the combined ``kid:key,kid:key`` format commonly passed as a single ``key`` param.
When ``key_id`` is absent and ``key`` looks like ``kid1:key1,kid2:key2`` (every
comma-separated part contains exactly one colon), split it into separate
``key_id`` and ``key`` values so the rest of the pipeline receives the canonical
``key_id=kid1,kid2`` / ``key=key1,key2`` format.
This format is convenient when using tools like mpv that express ClearKey
pairs as ``kid:key`` strings.
"""
if key and not key_id:
pairs = [p.strip() for p in key.split(",") if p.strip()]
if pairs and all(":" in p for p in pairs):
key_id = ",".join(p.split(":", 1)[0] for p in pairs)
key = ",".join(p.split(":", 1)[1] for p in pairs)
return key_id, key
def _normalize_drm_key_value(value: str) -> str:
"""
Normalize a DRM key_id or key value to lowercase hex.
@@ -786,7 +825,9 @@ async def get_manifest(
request, mpd_dict, proxy_headers, None, None, manifest_params.resolution, skip_segments
)
key_id, key = await handle_drm_key_data(manifest_params.key_id, manifest_params.key, drm_info)
# Support combined kid:key,kid:key format passed as a single key= param
key_id_param, key_param = _parse_combined_key_param(manifest_params.key_id, manifest_params.key)
key_id, key = await handle_drm_key_data(key_id_param, key_param, drm_info)
# Normalize key_id and key: convert from base64 to hex when needed.
# Each value may be a comma-separated list for multi-key DRM; each part is
@@ -835,6 +876,139 @@ async def get_playlist(
)
async def _stream_segment_base_drm(
init_url: str,
init_range: Optional[str],
init_headers: dict,
init_cache_token: str,
init_cache_ttl: Optional[int],
segment_url: str,
segment_headers: dict,
key_id: str,
key: str,
use_map: bool,
) -> AsyncGenerator[bytes, None]:
"""
Async generator for streaming a DRM-protected SegmentBase segment.
The generator is started AFTER FastAPI has already sent HTTP 200 response
headers to the player (StreamingResponse sends headers before iterating the
body). All blocking CDN I/O — both the init-segment fetch and the large
media-segment fetch — lives here, so zero network work happens before the
player receives its 200 OK.
Stream layout (use_map=False):
[yield] processed moov (~600 B) — sent after init fetch, player gets codec info
[yield …] decrypted moof + mdat per fragment — streamed as they arrive
"""
import struct
from mediaflow_proxy.drm.decrypter import MP4Decrypter, MP4Atom, _build_key_map
from mediaflow_proxy.utils.http_client import create_aiohttp_session
from mediaflow_proxy.utils.http_utils import fetch_with_retry
# Fetch the init segment inside the generator — this runs after HTTP 200
# headers are already in-flight, so the player never times out waiting for
# the response line.
try:
init_content = await get_cached_init_segment(
init_url,
init_headers,
cache_token=init_cache_token,
ttl=init_cache_ttl,
byte_range=init_range,
)
except Exception as e:
logger.warning(f"SegmentBase init fetch failed in stream: {e}")
return
if not init_content:
logger.warning(f"SegmentBase init segment empty for {init_url}")
return
# Build a single decrypter instance primed with the init segment so that
# track_encryption_settings / iv_size / encryption_scheme are all populated
# before we start processing media boxes.
key_map = _build_key_map(key_id, key)
decrypter = MP4Decrypter(key_map)
# Yield cleaned init immediately. Calling process_init_only() also primes
# the decrypter.
if not use_map:
try:
processed_init = decrypter.process_init_only(init_content)
yield processed_init
except Exception as e:
logger.warning(f"SegmentBase init processing failed: {e}")
return
else:
# EXT-X-MAP: init served separately; still prime the decrypter
try:
decrypter.process_init_only(init_content)
except Exception as e:
logger.warning(f"SegmentBase init priming failed: {e}")
return
# Stream the media range from the CDN and process each MP4 box as it
# arrives. We maintain a rolling byte buffer and parse 8-byte box headers
# (4-byte big-endian size + 4-byte type) to know when a complete box has
# accumulated, then hand it to the decrypter immediately.
seg_timeout = ClientTimeout(connect=10, sock_read=60, total=None)
buf = bytearray()
try:
async with create_aiohttp_session(segment_url, timeout=seg_timeout) as (session, proxy_url):
response = await fetch_with_retry(session, "GET", segment_url, segment_headers, proxy=proxy_url)
async for chunk in response.content.iter_chunked(65536):
buf.extend(chunk)
# Drain all complete boxes from the front of the buffer
while len(buf) >= 8:
box_size = struct.unpack_from(">I", buf, 0)[0]
# box_size == 1 means a 64-bit extended size follows (rare in
# streaming segments); box_size == 0 means "to end of file".
# Neither is expected here — fall through to flush at the end.
if box_size < 8:
break
if len(buf) < box_size:
break # Incomplete box — wait for more data
atom_type = bytes(buf[4:8])
atom_data = bytearray(buf[8:box_size])
del buf[:box_size]
# Drop sidx: its byte-offset references point to the original
# encrypted stream and become incorrect after senc/saiz/saio
# stripping. Omitting it lets the demuxer fall back to scanning
# moof boxes sequentially, which is always correct.
if atom_type == b"sidx":
continue
# When use_map=True the moov is served separately via EXT-X-MAP.
# If the CDN ignores the Range header and returns the full file,
# the stream would contain a moov we must not re-emit.
if use_map and atom_type == b"moov":
continue
atom = MP4Atom(atom_type, box_size, atom_data)
try:
processed = decrypter._process_atom(atom_type, atom)
yield processed.pack()
except Exception as e:
logger.warning(f"Box processing error ({atom_type!r}): {e}")
# Yield the original box so the stream isn't truncated
yield struct.pack(">I", box_size) + atom_type + bytes(atom_data)
# Flush any trailing bytes (should not happen for a well-formed MP4)
if buf:
yield bytes(buf)
except Exception as e:
logger.warning(f"SegmentBase media streaming failed for {segment_url}: {e}")
async def get_segment(
segment_params: MPDSegmentParams,
proxy_headers: ProxyRequestHeaders,
@@ -861,32 +1035,78 @@ async def get_segment(
segment_url = segment_params.segment_url
should_remux = force_remux_ts if force_remux_ts is not None else settings.remux_to_ts
# For SegmentBase MPDs, segment_range specifies the byte range of media data
# (e.g. "658-" meaning everything after the init segment). Apply it as a
# Range header so we never try to download a whole large file.
segment_headers = dict(proxy_headers.request)
if segment_params.segment_range:
segment_headers["Range"] = f"bytes={segment_params.segment_range}"
# Check processed segment cache first (avoids re-decrypting/re-remuxing)
is_processed = bool(segment_params.key_id or should_remux)
if is_processed:
processed_content = await get_cached_processed_segment(segment_url, segment_params.key_id, should_remux)
cache_key = f"{segment_url}|{segment_params.segment_range or ''}"
processed_content = await get_cached_processed_segment(cache_key, segment_params.key_id, should_remux)
if processed_content:
logger.info(f"Serving processed segment from cache: {segment_url}")
mimetype = "video/mp2t" if should_remux else segment_params.mime_type
response_headers = apply_header_manipulation({}, proxy_headers)
return Response(content=processed_content, media_type=mimetype, headers=response_headers)
# SegmentBase + DRM (non-remux): streaming path.
# Return StreamingResponse with zero blocking — all CDN I/O (init fetch
# + media stream) happens inside the generator, AFTER FastAPI has already
# sent HTTP 200 + headers to the player. This prevents ffmpeg/mpv from
# reporting "Immediate exit requested" / "Operation timed out" when the
# CDN connection is slow. Connection: close tells the player not to
# reuse this socket after the long-lived streaming response finishes.
if segment_params.segment_range and segment_params.key_id and not should_remux:
response_headers = apply_header_manipulation({}, proxy_headers)
response_headers["connection"] = "close"
return StreamingResponse(
_stream_segment_base_drm(
init_url=segment_params.init_url,
init_range=segment_params.init_range,
init_headers=dict(proxy_headers.request),
init_cache_token=segment_params.key_id,
init_cache_ttl=live_cache_ttl,
segment_url=segment_url,
segment_headers=segment_headers,
key_id=segment_params.key_id,
key=segment_params.key,
use_map=segment_params.use_map,
),
media_type=segment_params.mime_type,
headers=response_headers,
)
# SegmentBase without DRM, or with TS remux: buffer then process.
# Use sock_read timeout so large files don't hit the 60 s total cap.
if segment_params.segment_range:
try:
seg_timeout = ClientTimeout(connect=10, sock_read=60, total=None)
segment_content = await download_file_with_retry(segment_url, segment_headers, timeout=seg_timeout)
except Exception as dl_err:
logger.warning(f"SegmentBase range download failed for {segment_url}: {dl_err}")
segment_content = None
# Use event-based coordination for segment download
# get_or_download() handles:
# - Cache check
# - Waiting for existing downloads (via asyncio.Event)
# - Starting new download if needed
# - Caching the result
# Use a short timeout (1s) for player requests to avoid blocking if prebuffer is busy
# This ensures players get fast responses even when background prefetching is active
if settings.enable_dash_prebuffer:
segment_content = await dash_prebuffer.get_or_download(segment_url, proxy_headers.request, timeout=1.0)
# Player requests should get priority over background prebuffer activity.
# Use a configurable lock timeout to balance responsiveness and cache reuse.
elif settings.enable_dash_prebuffer:
segment_content = await dash_prebuffer.get_or_download(
segment_url, segment_headers, timeout=settings.dash_player_lock_timeout
)
else:
# Prebuffer disabled - check cache then download directly
segment_content = await get_cached_segment(segment_url)
if not segment_content:
try:
segment_content = await download_file_with_retry(segment_url, proxy_headers.request)
segment_content = await download_file_with_retry(segment_url, segment_headers)
# Cache for future requests (synchronous to ensure it's cached before returning)
if segment_content and segment_params.is_live:
# Use create_task for non-blocking cache write, but segment is already downloaded
@@ -902,7 +1122,7 @@ async def get_segment(
# Then fall back to a direct download if still not cached.
# This is critical for live streams where the prebuffer may be busy
# downloading other segments/profiles.
if not segment_content:
if not segment_content and not segment_params.segment_range:
# Final cache check - download may have completed during lock wait
segment_content = await get_cached_segment(segment_url)
if segment_content:
@@ -910,7 +1130,7 @@ async def get_segment(
else:
logger.info(f"Prebuffer returned no content, falling back to direct download: {segment_url}")
try:
segment_content = await download_file_with_retry(segment_url, proxy_headers.request)
segment_content = await download_file_with_retry(segment_url, segment_headers)
# Cache on success for future requests
if segment_content and segment_params.is_live:
asyncio.create_task(
+28 -33
View File
@@ -12,23 +12,17 @@ from starlette.staticfiles import StaticFiles
from mediaflow_proxy.configs import settings
from mediaflow_proxy.middleware import UIAccessControlMiddleware
from mediaflow_proxy.routes import (
proxy_router,
extractor_router,
speedtest_router,
playlist_builder_router,
xtream_root_router,
acestream_router,
telegram_router,
)
from mediaflow_proxy.routes.proxy import proxy_router
from mediaflow_proxy.routes.epg import epg_router
from mediaflow_proxy.routes.extractor import extractor_router
from mediaflow_proxy.routes.speedtest import speedtest_router
from mediaflow_proxy.routes.playlist_builder import playlist_builder_router
from mediaflow_proxy.routes.xtream import xtream_root_router
from mediaflow_proxy.schemas import GenerateUrlRequest, GenerateMultiUrlRequest, MultiUrlRequestItem
from mediaflow_proxy.utils.crypto_utils import EncryptionHandler, EncryptionMiddleware
from mediaflow_proxy.utils import redis_utils
from mediaflow_proxy.utils.http_utils import encode_mediaflow_proxy_url
from mediaflow_proxy.utils.base64_utils import encode_url_to_base64, decode_base64_url, is_base64_url
from mediaflow_proxy.utils.acestream import acestream_manager
from mediaflow_proxy.remuxer.video_transcoder import get_hw_capability, HWAccelType
from mediaflow_proxy.utils.telegram import telegram_manager
logging.basicConfig(level=settings.log_level, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s")
logger = logging.getLogger(__name__)
@@ -61,30 +55,22 @@ async def lifespan(app: FastAPI):
# use redis-cli KEYS "mfp:*" | xargs redis-cli DEL
logger.info("Cache clearing note: Redis entries will expire via TTL")
# Log transcoding capability
hw = get_hw_capability()
if hw.accel_type != HWAccelType.NONE and settings.transcode_prefer_gpu:
logger.info(
"Transcode ready: GPU %s (encoder=%s) | PyAV pipeline",
hw.accel_type.value,
hw.h264_encoder,
)
else:
logger.info(
"Transcode ready: CPU (%s) | PyAV pipeline",
hw.h264_encoder,
)
yield
# Shutdown
logger.info("Shutting down...")
# Close acestream sessions
await acestream_manager.close()
logger.info("Acestream manager closed")
if settings.enable_acestream:
from mediaflow_proxy.utils.acestream import acestream_manager
await acestream_manager.close()
logger.info("Acestream manager closed")
# Close telegram session
await telegram_manager.close()
logger.info("Telegram manager closed")
if settings.enable_telegram:
from mediaflow_proxy.utils.telegram import telegram_manager
await telegram_manager.close()
logger.info("Telegram manager closed")
# Close Redis connections
await redis_utils.close_redis()
logger.info("Redis connections closed")
@@ -318,8 +304,15 @@ async def check_base64_url(url: str):
app.include_router(proxy_router, prefix="/proxy", tags=["proxy"], dependencies=[Depends(verify_api_key)])
app.include_router(acestream_router, prefix="/proxy", tags=["acestream"], dependencies=[Depends(verify_api_key)])
app.include_router(telegram_router, prefix="/proxy", tags=["telegram"], dependencies=[Depends(verify_api_key)])
app.include_router(epg_router, prefix="/proxy", tags=["epg"], dependencies=[Depends(verify_api_key)])
if settings.enable_acestream:
from mediaflow_proxy.routes.acestream import acestream_router
app.include_router(acestream_router, prefix="/proxy", tags=["acestream"], dependencies=[Depends(verify_api_key)])
if settings.enable_telegram:
from mediaflow_proxy.routes.telegram import telegram_router
app.include_router(telegram_router, prefix="/proxy", tags=["telegram"], dependencies=[Depends(verify_api_key)])
app.include_router(extractor_router, prefix="/extractor", tags=["extractors"], dependencies=[Depends(verify_api_key)])
app.include_router(speedtest_router, prefix="/speedtest", tags=["speedtest"], dependencies=[Depends(verify_api_key)])
app.include_router(playlist_builder_router, prefix="/playlist", tags=["playlist"])
@@ -331,9 +324,11 @@ app.mount("/", StaticFiles(directory=str(static_path), html=True), name="static"
def run():
import os
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8888, log_level="info", workers=3)
port = int(os.environ.get("PORT", "8888"))
uvicorn.run(app, host="0.0.0.0", port=port, log_level="info", workers=3)
if __name__ == "__main__":
+236 -38
View File
@@ -1,18 +1,23 @@
import asyncio
import logging
import math
import statistics
import time
from aiohttp import ClientTimeout
from fastapi import Request, Response, HTTPException
from mediaflow_proxy.drm.decrypter import decrypt_segment, process_drm_init_segment
from mediaflow_proxy.utils.crypto_utils import encryption_handler
from mediaflow_proxy.utils.http_client import create_aiohttp_session
from mediaflow_proxy.utils.http_utils import (
encode_mediaflow_proxy_url,
fetch_with_retry,
get_original_scheme,
ProxyRequestHeaders,
apply_header_manipulation,
)
from mediaflow_proxy.utils.mpd_utils import parse_sidx_fragments
from mediaflow_proxy.utils.dash_prebuffer import dash_prebuffer
from mediaflow_proxy.utils.cache_utils import get_cached_processed_init, set_cached_processed_init
from mediaflow_proxy.utils.m3u8_processor import SkipSegmentFilter
@@ -30,6 +35,83 @@ def _resolve_ts_mode(request: Request) -> bool:
return settings.remux_to_ts
def _resolve_nominal_duration_mpd_timescale(profile: dict, segments: list[dict]) -> int | None:
"""Resolve a stable nominal segment duration (MPD timescale units) for live sequence math."""
profile_duration = profile.get("nominal_duration_mpd_timescale")
if isinstance(profile_duration, (int, float)) and profile_duration > 0:
return int(profile_duration)
durations = []
for seg in segments:
seg_duration = seg.get("duration_mpd_timescale")
if isinstance(seg_duration, (int, float)) and seg_duration > 0:
durations.append(int(seg_duration))
if durations:
# Use median to avoid jumps when first/last live segments are shorter.
return int(statistics.median_low(durations))
return None
def _compute_live_media_sequence(first_segment: dict, profile: dict, segments: list[dict]) -> int:
"""
Compute a stable HLS media sequence for live playlists.
Strategy:
1) If MPD explicitly sets @startNumber, trust segment numbering.
2) Otherwise derive sequence from timeline time / nominal duration.
3) Fall back to segment number or template start number.
"""
segment_number = first_segment.get("number")
if profile.get("segment_template_start_number_explicit") and segment_number is not None:
return max(int(segment_number), 1)
timeline_time = first_segment.get("time")
nominal_duration = _resolve_nominal_duration_mpd_timescale(profile, segments)
if timeline_time is not None and nominal_duration and nominal_duration > 0:
return max(math.floor(int(timeline_time) / nominal_duration), 1)
if segment_number is not None:
return max(int(segment_number), 1)
template_start = profile.get("segment_template_start_number")
if isinstance(template_start, int) and template_start > 0:
return template_start
return 1
def _compute_live_playlist_depth(
is_ts_mode: bool,
effective_start_offset: float | None,
extinf_values: list[float],
) -> int:
"""
Compute a resilient live playlist depth to reduce segment expiry skips.
We keep a larger floor for fMP4 live (direct mode), and further expand
depth based on requested start_offset so players have enough headroom
during transient stalls.
"""
configured_depth = max(settings.mpd_live_playlist_depth, 1)
depth_floor = 20 if is_ts_mode else 15
depth = max(configured_depth, depth_floor)
if effective_start_offset is not None and effective_start_offset < 0:
if extinf_values:
segment_duration = statistics.median(extinf_values)
if segment_duration <= 0:
segment_duration = 4.0
else:
segment_duration = 4.0
segments_behind_live_edge = math.ceil(abs(effective_start_offset) / segment_duration)
safety_margin = 10 if is_ts_mode else 12
depth = max(depth, segments_behind_live_edge + safety_margin)
return max(depth, 1)
async def process_manifest(
request: Request,
mpd_dict: dict,
@@ -73,6 +155,68 @@ async def process_manifest(
return Response(content=hls_content, media_type="application/vnd.apple.mpegurl", headers=proxy_headers.response)
async def _expand_segment_base_segments(profiles: list, req_headers: dict) -> None:
"""
For SegmentBase profiles with a single segment that has an @indexRange, fetch the
SIDX box and expand the single segment entry into per-fragment segments.
This allows mpv/ffmpeg to seek by requesting only the relevant fragment from the
CDN instead of re-downloading the whole file from byte 938 every time.
Modifies *profiles* in-place; on any failure the original single-segment entry
is kept as a fallback.
"""
timeout = ClientTimeout(connect=10, sock_read=30, total=60)
for profile in profiles:
segments = profile.get("segments", [])
if len(segments) != 1:
continue
seg = segments[0]
index_range: str = seg.get("indexRange") or ""
media_url: str = seg.get("media") or ""
init_range: str = seg.get("initRange") or ""
if not index_range or not media_url:
continue # no SIDX info → keep single-segment fallback
try:
headers = dict(req_headers)
headers["Range"] = f"bytes={index_range}"
async with create_aiohttp_session(media_url, timeout=timeout) as (session, proxy_url):
response = await fetch_with_retry(session, "GET", media_url, headers, proxy=proxy_url)
sidx_bytes = await response.read()
index_range_start = int(index_range.split("-")[0])
fragments = parse_sidx_fragments(sidx_bytes, index_range_start)
if not fragments:
logger.warning(f"SIDX parse returned no fragments for {media_url}, keeping single-segment fallback")
continue
new_segments = [
{
"type": "segment",
"media": media_url,
"number": i + 1,
"extinf": frag["duration_timescale"] / frag["timescale"],
"initRange": init_range,
"mediaRange": f"{frag['start']}-{frag['end']}",
}
for i, frag in enumerate(fragments)
]
profile["segments"] = new_segments
logger.info(
f"SegmentBase SIDX expanded {media_url!r}{len(new_segments)} fragments "
f"({new_segments[0]['extinf']:.3f}s each)"
)
except Exception as exc:
logger.warning(f"SIDX expansion failed for {media_url}: {exc}; keeping single-segment fallback")
async def process_playlist(
request: Request,
mpd_dict: dict,
@@ -102,6 +246,10 @@ async def process_playlist(
if not matching_profiles:
raise HTTPException(status_code=404, detail="Profile not found")
# For SegmentBase profiles (single large file with SIDX), expand into per-fragment
# segments so that mpv/ffmpeg can seek by requesting only the relevant fragment.
await _expand_segment_base_segments(matching_profiles, dict(proxy_headers.request))
hls_content = build_hls_playlist(mpd_dict, matching_profiles, request, skip_segments, start_offset)
# Trigger prebuffering of upcoming segments for live streams
@@ -301,22 +449,79 @@ def build_hls(
max_height = max(p[0].get("height", 0) for p in video_profiles.values())
video_profiles = {k: v for k, v in video_profiles.items() if v[0].get("height", 0) >= max_height}
# Add audio streams
for i, (profile, playlist_url) in enumerate(audio_profiles.values()):
is_default = "YES" if i == 0 else "NO" # Set the first audio track as default
lang = profile.get("lang", "und")
bandwidth = profile.get("bandwidth", "128000")
name = f"Audio {lang} ({bandwidth})" if lang != "und" else f"Audio {i + 1} ({bandwidth})"
hls.append(
f'#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="audio",NAME="{name}",DEFAULT={is_default},AUTOSELECT=YES,LANGUAGE="{lang}",URI="{playlist_url}"'
if not is_ts_mode and video_profiles:
# Sort by bandwidth descending; keep highest bandwidth per unique rep_id
sorted_by_bw = sorted(video_profiles.values(), key=lambda pv: pv[0].get("bandwidth", 0), reverse=True)
seen_rep_ids = set()
deduped = []
for profile, playlist_url in sorted_by_bw:
rep_id = profile.get("rep_id", profile["id"])
if rep_id not in seen_rep_ids:
seen_rep_ids.add(rep_id)
deduped.append((profile, playlist_url))
if resolution:
# Explicit resolution: single matching variant
deduped = deduped[:1]
else:
# Limit ABR ladder: one entry per unique height, capped at MAX_VIDEO_VARIANTS.
# libavformat (mpv/ffmpeg) fetches ALL #EXT-X-STREAM-INF playlists and their
# init segments before playback regardless of BANDWIDTH hints —
# N variants = N × ~2 s of init-segment probing at startup.
MAX_VIDEO_VARIANTS = 5
seen_heights: set = set()
height_deduped = []
for p, url in deduped:
h = p.get("height", 0)
if h not in seen_heights:
seen_heights.add(h)
height_deduped.append((p, url))
deduped = height_deduped[:MAX_VIDEO_VARIANTS]
video_profiles = {p["id"]: (p, url) for p, url in deduped}
# Determine the default audio (English preferred, else highest bandwidth).
default_audio_id = None
if audio_profiles:
all_audio = list(audio_profiles.values())
en_audio = [(p, u) for p, u in all_audio if (p.get("lang") or "").startswith("en")]
default_profile, _ = max(en_audio or all_audio, key=lambda pu: pu[0].get("bandwidth", 0))
default_audio_id = default_profile["id"]
# Audio tracks: one entry per unique language, capped at MAX_AUDIO_TRACKS.
# Sort default track first (always within cap), then by bandwidth descending
# so the highest-quality codec per language wins.
# libavformat probes every #EXT-X-MEDIA entry regardless of DEFAULT/AUTOSELECT;
# capping at 4 keeps language selection while bounding startup probing.
MAX_AUDIO_TRACKS = 4
first_audio_codec = None
if audio_profiles:
sorted_audio = sorted(
audio_profiles.values(),
key=lambda pv: (pv[0]["id"] != default_audio_id, -pv[0].get("bandwidth", 0)),
)
seen_langs = set()
count = 0
for profile, playlist_url in sorted_audio:
if count >= MAX_AUDIO_TRACKS:
break
lang = profile.get("lang", "und")
if lang in seen_langs:
continue
seen_langs.add(lang)
is_default = profile["id"] == default_audio_id
default_attr = "YES" if is_default else "NO"
autoselect_attr = "YES" if is_default else "NO"
bandwidth = profile.get("bandwidth", 128000)
name = f"Audio {lang} ({bandwidth})" if lang != "und" else f"Audio 1 ({bandwidth})"
hls.append(
f'#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="audio",NAME="{name}",DEFAULT={default_attr},AUTOSELECT={autoselect_attr},LANGUAGE="{lang}",URI="{playlist_url}"'
)
if is_default:
first_audio_codec = profile.get("codecs", "")
count += 1
# Build combined codecs string (video + audio) for EXT-X-STREAM-INF
# ExoPlayer requires CODECS to list all codecs when AUDIO group is referenced
first_audio_codec = None
if audio_profiles:
first_audio_profile = next(iter(audio_profiles.values()))[0]
first_audio_codec = first_audio_profile.get("codecs", "")
# Add video streams
for profile, playlist_url in video_profiles.values():
@@ -440,9 +645,13 @@ def build_hls_playlist(
skip_filter = SkipSegmentFilter(skip_segments) if skip_segments else None
# In TS mode, we don't use EXT-X-MAP because TS segments are self-contained
# (PAT/PMT/VPS/SPS/PPS are embedded in each segment)
# Use EXT-X-MAP for live streams, but only for fMP4 (not TS)
use_map = is_live and not is_ts_mode
# (PAT/PMT/VPS/SPS/PPS are embedded in each segment).
# Use EXT-X-MAP for:
# - live fMP4 streams (init changes with discontinuities)
# - SegmentBase fMP4 (init and media are different byte ranges of the same file;
# without EXT-X-MAP every segment would redundantly include the moov box)
has_segment_base = not is_ts_mode and any(p.get("initRange") for p in profiles)
use_map = not is_ts_mode and (is_live or has_segment_base)
# Select appropriate endpoint based on remux mode
if is_ts_mode:
@@ -462,8 +671,8 @@ def build_hls_playlist(
continue
if is_live:
# TS mode uses deeper playlist for ExoPlayer buffering
depth = 20 if is_ts_mode else max(settings.mpd_live_playlist_depth, 1)
extinf_values_for_depth = [s["extinf"] for s in segments if "extinf" in s]
depth = _compute_live_playlist_depth(is_ts_mode, effective_start_offset, extinf_values_for_depth)
trimmed_segments = segments[-depth:]
else:
trimmed_segments = segments
@@ -479,31 +688,13 @@ def build_hls_playlist(
else:
target_duration = math.ceil(max(extinf_values)) if extinf_values else 3
# Align HLS media sequence with MPD-provided numbering when available
if is_ts_mode and is_live:
# For live TS, derive sequence from timeline first for stable continuity
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 = first_segment.get("number") or profile.get("segment_template_start_number") or 1
if is_live:
sequence = _compute_live_media_sequence(first_segment, profile, trimmed_segments)
else:
mpd_start_number = profile.get("segment_template_start_number")
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:
# 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
sequence = mpd_start_number if mpd_start_number is not None else 1
hls.extend(
[
@@ -543,6 +734,10 @@ def build_hls_playlist(
if query_params.get("api_password"):
init_query_params["api_password"] = query_params["api_password"]
for k, v in query_params.items():
if k.startswith("rp_") or k.startswith("h_"):
init_query_params[k] = v
init_map_url = encode_mediaflow_proxy_url(
init_proxy_url,
query_params=init_query_params,
@@ -594,6 +789,9 @@ def build_hls_playlist(
# Segment may also have its own range (for SegmentBase)
if "initRange" in segment:
segment_query_params["init_range"] = segment["initRange"]
# Media byte range: bytes after the init segment (SegmentBase only)
if segment.get("mediaRange"):
segment_query_params["segment_range"] = segment["mediaRange"]
query_params.update(segment_query_params)
hls.append(
+5 -1
View File
@@ -13,7 +13,6 @@ from typing import Protocol, runtime_checkable
from urllib.parse import urlparse, unquote
from mediaflow_proxy.utils.http_client import create_aiohttp_session
from mediaflow_proxy.utils.telegram import telegram_manager
logger = logging.getLogger(__name__)
@@ -133,6 +132,8 @@ class TelegramMediaSource:
raw = f"file_id:{ref.file_id}"
elif ref.chat_id is not None and ref.message_id is not None:
raw = f"chat:{ref.chat_id}:msg:{ref.message_id}"
elif ref.chat_id is not None and ref.document_id is not None:
raw = f"chat:{ref.chat_id}:doc:{ref.document_id}"
else:
return ""
return hashlib.sha256(raw.encode()).hexdigest()[:16]
@@ -142,6 +143,9 @@ class TelegramMediaSource:
return self._filename_hint
async def stream(self, offset: int = 0, limit: int | None = None) -> AsyncIterator[bytes]:
# Lazy import to avoid loading Telegram dependencies for non-Telegram routes.
from mediaflow_proxy.utils.telegram import telegram_manager
effective_limit = limit or self._file_size
if self._use_single_client:
async for chunk in telegram_manager.stream_media_single(
+34 -8
View File
@@ -1,11 +1,3 @@
from .proxy import proxy_router
from .extractor import extractor_router
from .speedtest import speedtest_router
from .playlist_builder import playlist_builder_router
from .xtream import xtream_root_router
from .acestream import acestream_router
from .telegram import telegram_router
__all__ = [
"proxy_router",
"extractor_router",
@@ -15,3 +7,37 @@ __all__ = [
"acestream_router",
"telegram_router",
]
def __getattr__(name: str):
# Lazy import routers so importing a single route module does not
# pull in optional integrations (telegram/acestream/transcode) at startup.
if name == "proxy_router":
from .proxy import proxy_router
return proxy_router
if name == "extractor_router":
from .extractor import extractor_router
return extractor_router
if name == "speedtest_router":
from .speedtest import speedtest_router
return speedtest_router
if name == "playlist_builder_router":
from .playlist_builder import playlist_builder_router
return playlist_builder_router
if name == "xtream_root_router":
from .xtream import xtream_root_router
return xtream_root_router
if name == "acestream_router":
from .acestream import acestream_router
return acestream_router
if name == "telegram_router":
from .telegram import telegram_router
return telegram_router
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
+24 -4
View File
@@ -9,7 +9,8 @@ Provides endpoints for proxying acestream content:
import asyncio
import logging
from typing import Annotated
from functools import lru_cache
from typing import Annotated, TYPE_CHECKING
from urllib.parse import urlencode, urljoin, urlparse
import aiohttp
@@ -17,8 +18,6 @@ from fastapi import APIRouter, Query, Request, HTTPException, Response, Depends
from starlette.background import BackgroundTask
from mediaflow_proxy.configs import settings
from mediaflow_proxy.remuxer.transcode_pipeline import stream_transcode_universal
from mediaflow_proxy.utils.acestream import acestream_manager, AcestreamSession
from mediaflow_proxy.utils.http_client import create_aiohttp_session
from mediaflow_proxy.utils.http_utils import (
get_original_scheme,
@@ -34,6 +33,22 @@ from mediaflow_proxy.utils.hls_prebuffer import hls_prebuffer
logger = logging.getLogger(__name__)
acestream_router = APIRouter()
if TYPE_CHECKING:
from mediaflow_proxy.utils.acestream import AcestreamSession
def _get_acestream_manager():
from mediaflow_proxy.utils.acestream import acestream_manager
return acestream_manager
@lru_cache(maxsize=1)
def _load_transcode_pipeline():
from mediaflow_proxy.remuxer.transcode_pipeline import stream_transcode_universal
return stream_transcode_universal
class AcestreamM3U8Processor(M3U8Processor):
"""
@@ -46,7 +61,7 @@ class AcestreamM3U8Processor(M3U8Processor):
def __init__(
self,
request: Request,
session: AcestreamSession,
session: "AcestreamSession",
key_url: str = None,
force_playlist_proxy: bool = True,
key_only_proxy: bool = False,
@@ -140,6 +155,7 @@ async def acestream_hls_manifest(
"""
if not settings.enable_acestream:
raise HTTPException(status_code=503, detail="Acestream support is disabled")
acestream_manager = _get_acestream_manager()
if not infohash and not id:
raise HTTPException(status_code=400, detail="Either 'infohash' or 'id' parameter is required")
@@ -278,6 +294,7 @@ async def acestream_segment_proxy(
"""
if not settings.enable_acestream:
raise HTTPException(status_code=503, detail="Acestream support is disabled")
acestream_manager = _get_acestream_manager()
# Use id or infohash for session lookup
session_key = id or infohash
@@ -368,6 +385,7 @@ async def acestream_ts_stream(
"""
if not settings.enable_acestream:
raise HTTPException(status_code=503, detail="Acestream support is disabled")
acestream_manager = _get_acestream_manager()
if not infohash and not id:
raise HTTPException(status_code=400, detail="Either 'infohash' or 'id' parameter is required")
@@ -438,6 +456,7 @@ async def acestream_ts_stream(
# Use our custom PyAV pipeline with forced video re-encoding
# (live MPEG-TS sources often have corrupt H.264 bitstreams
# that browsers reject; re-encoding produces a clean stream).
stream_transcode_universal = _load_transcode_pipeline()
content = stream_transcode_universal(
_acestream_ts_source(),
force_video_reencode=True,
@@ -509,6 +528,7 @@ async def acestream_status(
"""
if not settings.enable_acestream:
raise HTTPException(status_code=503, detail="Acestream support is disabled")
acestream_manager = _get_acestream_manager()
if infohash:
session = acestream_manager.get_session(infohash)
+203
View File
@@ -0,0 +1,203 @@
"""
EPG Proxy XMLTV/EPG pass-through with caching.
Supports Channels DVR, Plex, Emby, and any XMLTV-compatible EPG client.
Usage:
GET /proxy/epg?d=<epg_url>&api_password=<key>
With custom headers for protected sources:
GET /proxy/epg?d=<url>&h_Authorization=Bearer+<token>&api_password=<key>
With cache TTL override:
GET /proxy/epg?d=<url>&cache_ttl=7200&api_password=<key>
"""
import hashlib
import logging
import time
from typing import Optional
import aiohttp
from fastapi import APIRouter, HTTPException, Query, Request
from fastapi.responses import Response
from mediaflow_proxy.configs import settings
from mediaflow_proxy.utils.base64_utils import process_potential_base64_url
from mediaflow_proxy.utils.http_client import create_aiohttp_session
logger = logging.getLogger(__name__)
epg_router = APIRouter()
# In-memory EPG cache: {cache_key: (content_bytes, content_type, fetch_timestamp)}
# EPG data rarely changes; default TTL is 1 hour.
_epg_cache: dict[str, tuple[bytes, str, float]] = {}
def _get_cached_epg(cache_key: str, ttl: int) -> Optional[tuple[bytes, str]]:
"""Return (content, content_type) from cache if the entry exists and has not expired."""
entry = _epg_cache.get(cache_key)
if entry is None:
return None
content, content_type, ts = entry
if time.monotonic() - ts < ttl:
return content, content_type
# Expired — evict lazily
del _epg_cache[cache_key]
return None
def _set_cached_epg(cache_key: str, content: bytes, content_type: str) -> None:
_epg_cache[cache_key] = (content, content_type, time.monotonic())
def _build_cache_key(destination: str, request_headers: dict[str, str]) -> str:
"""
Incorporate auth-bearing headers into the cache key so that different
credentials don't serve each other's cached EPG data.
"""
if not request_headers:
return destination
header_hash = hashlib.md5(str(sorted(request_headers.items())).encode()).hexdigest()[:8]
return f"{destination}|{header_hash}"
@epg_router.get("/epg")
@epg_router.head("/epg")
async def epg_proxy(
request: Request,
destination: str = Query(
...,
alias="d",
description="URL of the XMLTV/EPG source. Supports plain URLs and base64-encoded URLs.",
),
cache_ttl: Optional[int] = Query(
None,
description=(
"Cache lifetime in seconds. 0 disables caching. Defaults to the EPG_CACHE_TTL setting (3600 s = 1 h)."
),
),
):
"""
Proxy EPG / XMLTV data from any upstream source with optional caching.
**Channels DVR setup:** enter this URL as your custom EPG source:
http://<proxy-host>:<port>/proxy/epg?d=<epg_url>&api_password=<key>
**Protected EPG sources** pass authentication via `h_` header params:
?d=<url>&h_Authorization=Bearer+<token>&api_password=<key>
Base64-encoded destination URLs are automatically decoded.
Returns the XMLTV XML with `Content-Type: application/xml`.
"""
# Resolve base64-encoded destination URLs
destination = process_potential_base64_url(destination)
if not destination.startswith(("http://", "https://")):
raise HTTPException(
status_code=400,
detail="Destination must be an http:// or https:// URL.",
)
# Collect upstream request headers from h_<name> query params
request_headers: dict[str, str] = {
key[2:]: value for key, value in request.query_params.items() if key.startswith("h_")
}
# Effective TTL — per-request override or global config
effective_ttl: int = cache_ttl if cache_ttl is not None else settings.epg_cache_ttl
cache_key = _build_cache_key(destination, request_headers)
# --- Cache read -------------------------------------------------------
if effective_ttl > 0:
cached = _get_cached_epg(cache_key, effective_ttl)
if cached is not None:
content, content_type = cached
logger.debug("[epg_proxy] Cache HIT: %s", destination)
if request.method == "HEAD":
return Response(
status_code=200,
headers={
"Content-Type": content_type,
"Content-Length": str(len(content)),
"X-EPG-Cache": "HIT",
"Cache-Control": f"public, max-age={effective_ttl}",
},
)
return Response(
content=content,
media_type=content_type,
headers={
"X-EPG-Cache": "HIT",
"Cache-Control": f"public, max-age={effective_ttl}",
},
)
# --- Upstream fetch ---------------------------------------------------
logger.info("[epg_proxy] Fetching EPG from: %s", destination)
async with create_aiohttp_session(destination, timeout=120) as (session, proxy_url):
try:
async with session.get(
destination,
headers=request_headers,
proxy=proxy_url,
allow_redirects=True,
) as response:
response.raise_for_status()
content = await response.read()
content_type = response.headers.get("content-type", "application/xml; charset=utf-8")
# Normalise to XML content type if upstream returns something unexpected
if not any(t in content_type.lower() for t in ("xml", "text")):
content_type = "application/xml; charset=utf-8"
if effective_ttl > 0:
_set_cached_epg(cache_key, content, content_type)
logger.info(
"[epg_proxy] Cached %d bytes from %s (TTL=%ds)",
len(content),
destination,
effective_ttl,
)
if request.method == "HEAD":
return Response(
status_code=200,
headers={
"Content-Type": content_type,
"Content-Length": str(len(content)),
"X-EPG-Cache": "MISS",
"Cache-Control": f"public, max-age={effective_ttl}",
},
)
return Response(
content=content,
media_type=content_type,
headers={
"X-EPG-Cache": "MISS",
"Cache-Control": f"public, max-age={effective_ttl}",
},
)
except aiohttp.ClientResponseError as e:
logger.warning("[epg_proxy] Upstream HTTP %s for %s", e.status, destination)
raise HTTPException(
status_code=e.status,
detail=f"Upstream EPG error: HTTP {e.status}",
)
except aiohttp.ClientConnectorError as e:
logger.error("[epg_proxy] Cannot connect to %s: %s", destination, e)
raise HTTPException(
status_code=502,
detail=f"Cannot connect to EPG source: {e}",
)
except TimeoutError:
logger.error("[epg_proxy] Timeout fetching %s", destination)
raise HTTPException(status_code=504, detail="EPG source timed out")
except aiohttp.ClientError as e:
logger.error("[epg_proxy] Fetch error for %s: %s", destination, e)
raise HTTPException(status_code=502, detail=f"EPG fetch failed: {e}")
+24 -1
View File
@@ -179,9 +179,32 @@ async def _extract_url_impl(
if "no_proxy" in request.query_params:
response["query_params"]["no_proxy"] = request.query_params.get("no_proxy")
# Some extractors return force_playlist_proxy as top-level metadata for internal
# manifest processing. Redirect URL encoding expects it in query params.
if response.pop("force_playlist_proxy", False):
response["query_params"]["force_playlist_proxy"] = "1"
if extractor_params.redirect_stream:
encode_args = {
key: response[key]
for key in (
"mediaflow_proxy_url",
"endpoint",
"destination_url",
"query_params",
"request_headers",
"propagate_response_headers",
"remove_response_headers",
"encryption_handler",
"expiration",
"ip",
"filename",
"stream_transformer",
)
if key in response
}
stream_url = encode_mediaflow_proxy_url(
**response,
**encode_args,
response_headers=proxy_headers.response,
)
return RedirectResponse(url=stream_url, status_code=302)
+34 -206
View File
@@ -1,10 +1,9 @@
import asyncio
import logging
import re
from functools import lru_cache
from typing import Annotated
from urllib.parse import quote, unquote
import aiohttp
from fastapi import Request, Depends, APIRouter, Query, HTTPException, Response
from fastapi.datastructures import QueryParams
@@ -28,32 +27,40 @@ from mediaflow_proxy.schemas import (
)
from mediaflow_proxy.utils.base64_utils import process_potential_base64_url
from mediaflow_proxy.utils.extractor_helpers import (
check_and_extract_dlhd_stream,
check_and_extract_sportsonline_stream,
)
from mediaflow_proxy.utils.hls_prebuffer import hls_prebuffer
from mediaflow_proxy.utils.hls_utils import parse_hls_playlist, find_stream_by_resolution
from mediaflow_proxy.utils.http_utils import (
get_proxy_headers,
ProxyRequestHeaders,
apply_header_manipulation,
)
from mediaflow_proxy.utils.http_client import create_aiohttp_session
from mediaflow_proxy.utils.m3u8_processor import M3U8Processor
from mediaflow_proxy.utils.stream_transformers import apply_transformer_to_bytes
from mediaflow_proxy.remuxer.media_source import HTTPMediaSource
from mediaflow_proxy.remuxer.transcode_handler import (
handle_transcode,
handle_transcode_hls_init,
handle_transcode_hls_playlist,
handle_transcode_hls_segment,
)
logger = logging.getLogger(__name__)
proxy_router = APIRouter()
@lru_cache(maxsize=1)
def _load_transcode_components():
from mediaflow_proxy.remuxer.media_source import HTTPMediaSource
from mediaflow_proxy.remuxer.transcode_handler import (
handle_transcode,
handle_transcode_hls_init,
handle_transcode_hls_playlist,
handle_transcode_hls_segment,
)
return (
HTTPMediaSource,
handle_transcode,
handle_transcode_hls_init,
handle_transcode_hls_playlist,
handle_transcode_hls_segment,
)
def sanitize_url(url: str) -> str:
"""
Sanitize URL to fix common encoding issues and handle base64 encoded URLs.
@@ -168,52 +175,6 @@ async def hls_manifest_proxy(
# Sanitize destination URL to fix common encoding issues
hls_params.destination = sanitize_url(hls_params.destination)
# 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
# Check if extractor wants to force playlist proxy (needed for .css disguised m3u8)
if dlhd_result.get("force_playlist_proxy"):
hls_params.force_playlist_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
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
# Add DLHD key params if present (for dynamic key header computation)
if dlhd_result.get("dlhd_channel_salt"):
query_dict["dlhd_salt"] = dlhd_result["dlhd_channel_salt"]
if dlhd_result.get("dlhd_auth_token"):
query_dict["dlhd_token"] = dlhd_result["dlhd_auth_token"]
if dlhd_result.get("dlhd_iframe_url"):
query_dict["dlhd_iframe"] = dlhd_result["dlhd_iframe_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:
@@ -231,124 +192,9 @@ async def hls_manifest_proxy(
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.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.
"""
# Check if resolution selection is needed (either max_res or specific resolution)
if hls_params.max_res or hls_params.resolution:
async with create_aiohttp_session(hls_params.destination) as (session, proxy_url):
try:
response = await session.get(
hls_params.destination,
headers=proxy_headers.request,
proxy=proxy_url,
)
response.raise_for_status()
playlist_content = await response.text()
except aiohttp.ClientResponseError as e:
raise HTTPException(
status_code=502,
detail=f"Failed to fetch HLS manifest from origin: {e.status}",
) from e
except asyncio.TimeoutError as e:
raise HTTPException(
status_code=504,
detail=f"Timeout while fetching HLS manifest: {e}",
) from e
except aiohttp.ClientError 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.")
# Select stream based on resolution parameter or max_res
if hls_params.resolution:
selected_stream = find_stream_by_resolution(streams, hls_params.resolution)
if not selected_stream:
raise HTTPException(
status_code=404, detail=f"No suitable stream found for resolution {hls_params.resolution}."
)
else:
# max_res: select highest resolution
selected_stream = max(
streams,
key=lambda s: s.get("resolution", (0, 0))[0] * s.get("resolution", (0, 0))[1],
)
if selected_stream.get("resolution", (0, 0)) == (0, 0):
logger.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()
selected_variant_index = streams.index(selected_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 == selected_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)
# Parse skip segments (already returns list of dicts with 'start' and 'end' keys)
skip_segments_list = hls_params.get_skip_segments()
# 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,
skip_segments_list,
hls_params.start_offset,
)
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, hls_params.transformer)
@@ -463,10 +309,14 @@ async def hls_segment_proxy(
response_headers = apply_header_manipulation(base_headers, proxy_headers)
return Response(content=segment_data, media_type=mime_type, headers=response_headers)
# get_or_download returned None (timeout or error) - fall through to streaming
logger.warning(f"[hls_segment_proxy] Prebuffer timeout, using direct streaming: {segment_url}")
# get_or_download returned None (timeout or error) - fall through to direct fetch
logger.warning(f"[hls_segment_proxy] Prebuffer timeout, using direct fetch: {segment_url}")
# Fallback to direct streaming
# Fallback to direct streaming.
# Override the response Content-Type so that CDN-served MPEG-TS segments
# are not interpreted as a non-video format.
if mime_type != "application/octet-stream":
proxy_headers.response["content-type"] = mime_type
return await handle_stream_request("GET", segment_url, proxy_headers, transformer)
@@ -499,6 +349,7 @@ async def transcode_hls_playlist(
"""
if not settings.enable_transcode:
raise HTTPException(status_code=503, detail="Transcoding support is disabled")
HTTPMediaSource, _, _, handle_transcode_hls_playlist, _ = _load_transcode_components()
destination = sanitize_url(destination)
source = HTTPMediaSource(url=destination, headers=dict(proxy_headers.request))
await source.resolve_file_size()
@@ -540,6 +391,7 @@ async def transcode_hls_init(
"""
if not settings.enable_transcode:
raise HTTPException(status_code=503, detail="Transcoding support is disabled")
HTTPMediaSource, _, handle_transcode_hls_init, _, _ = _load_transcode_components()
destination = sanitize_url(destination)
source = HTTPMediaSource(url=destination, headers=dict(proxy_headers.request))
await source.resolve_file_size()
@@ -572,6 +424,7 @@ async def transcode_hls_segment(
"""
if not settings.enable_transcode:
raise HTTPException(status_code=503, detail="Transcoding support is disabled")
HTTPMediaSource, _, _, _, handle_transcode_hls_segment = _load_transcode_components()
destination = sanitize_url(destination)
source = HTTPMediaSource(url=destination, headers=dict(proxy_headers.request))
await source.resolve_file_size()
@@ -660,39 +513,11 @@ async def proxy_stream_endpoint(
# Sanitize destination URL to fix common encoding issues
destination = sanitize_url(destination)
# Check if this is a DLHD key URL request with key params in query
dlhd_salt = request.query_params.get("dlhd_salt")
dlhd_token = request.query_params.get("dlhd_token")
if dlhd_salt and "/key/" in destination:
# This is a DLHD key URL - compute dynamic headers via executor to avoid blocking
from mediaflow_proxy.extractors.dlhd import compute_key_headers
key_headers = await asyncio.to_thread(compute_key_headers, destination, dlhd_salt)
if key_headers:
ts, nonce, key_path, fingerprint = key_headers
proxy_headers.request.update(
{
"X-Key-Timestamp": str(ts),
"X-Key-Nonce": str(nonce),
"X-Fingerprint": fingerprint,
"X-Key-Path": key_path,
}
)
if dlhd_token:
proxy_headers.request["Authorization"] = f"Bearer {dlhd_token}"
logger.info(f"[proxy_stream] Computed DLHD key headers for: {destination}")
# 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", {}))
# Handle transcode mode — transcode uses time-based seeking, not byte ranges
if transcode:
if not settings.enable_transcode:
raise HTTPException(status_code=503, detail="Transcoding support is disabled")
HTTPMediaSource, handle_transcode, _, _, _ = _load_transcode_components()
transcode_headers = dict(proxy_headers.request)
transcode_headers.pop("range", None)
transcode_headers.pop("if-range", None)
@@ -708,6 +533,9 @@ async def proxy_stream_endpoint(
if "range" not in proxy_headers.request:
proxy_headers.request["range"] = "bytes=0-"
# Mark that this range was auto-added (not from client)
# This is used in handlers.py to decide whether to convert 206->200
proxy_headers.auto_added_range = True
if filename:
# If a filename is provided (not a segment), set it in the headers using RFC 6266 format
+229 -91
View File
@@ -12,37 +12,75 @@ import asyncio
import logging
import re
import secrets
from typing import Annotated, Optional
from urllib.parse import quote
from functools import lru_cache
from typing import Annotated, Optional, TYPE_CHECKING
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
from pydantic import BaseModel
from telethon import TelegramClient
from telethon.sessions import StringSession
from mediaflow_proxy.configs import settings
from mediaflow_proxy.remuxer.media_source import TelegramMediaSource
from mediaflow_proxy.remuxer.transcode_handler import (
handle_transcode,
handle_transcode_hls_init,
handle_transcode_hls_playlist,
handle_transcode_hls_segment,
)
from mediaflow_proxy.utils.http_utils import (
EnhancedStreamingResponse,
ProxyRequestHeaders,
apply_header_manipulation,
get_proxy_headers,
)
from mediaflow_proxy.utils.telegram import (
TelegramMediaRef,
parse_telegram_url,
telegram_manager,
)
logger = logging.getLogger(__name__)
telegram_router = APIRouter()
if TYPE_CHECKING:
from mediaflow_proxy.utils.telegram import TelegramMediaRef
def _telegram_utils():
from mediaflow_proxy.utils.telegram import TelegramMediaRef, parse_telegram_url, telegram_manager
return TelegramMediaRef, parse_telegram_url, telegram_manager
@lru_cache(maxsize=1)
def _load_transcode_handlers():
from mediaflow_proxy.remuxer.transcode_handler import (
handle_transcode,
handle_transcode_hls_init,
handle_transcode_hls_playlist,
handle_transcode_hls_segment,
)
return (
handle_transcode,
handle_transcode_hls_init,
handle_transcode_hls_playlist,
handle_transcode_hls_segment,
)
def _content_disposition_inline(filename: str) -> str:
"""
Build a Content-Disposition header value that is always latin-1 safe.
Starlette/FastAPI requires header values to be latin-1 encodable. Telegram filenames
may contain unicode (e.g. Cyrillic), so we use RFC 6266 `filename*` when needed.
"""
# Sanitize newlines and carriage returns
sanitized = (filename or "").strip().replace("\n", " ").replace("\r", " ")
if not sanitized:
return "inline"
try:
# Try if the filename is latin-1 encodable
sanitized.encode("latin-1")
# For the filename= parameter, we must escape backslashes and double quotes
escaped = sanitized.replace("\\", "\\\\").replace('"', '\\"')
return f'inline; filename="{escaped}"'
except UnicodeEncodeError:
# For filename*, use percent-encoding with the original (unescaped) sanitized name
encoded = quote(sanitized, encoding="utf-8", safe="")
return f"inline; filename*=UTF-8''{encoded}"
def get_content_type(mime_type: str, file_name: Optional[str] = None) -> str:
"""Determine content type from mime type or filename."""
@@ -115,6 +153,87 @@ def parse_range_header(range_header: Optional[str], file_size: int) -> tuple[int
return start, end
def _parse_chat_id_value(chat_id: str) -> int | str:
"""Parse chat_id as integer when possible; otherwise keep username form."""
try:
return int(chat_id)
except ValueError:
return chat_id
def _build_telegram_ref_from_params(
TelegramMediaRef,
parse_telegram_url,
*,
telegram_url: str | None,
chat_id: str | None,
message_id: int | None,
file_id: str | None,
document_id: int | None,
file_size: int | None,
require_file_size_for_file_id: bool,
):
"""
Build a TelegramMediaRef with route-level priority:
URL -> chat_id+message_id -> chat_id+document_id -> chat_id+file_id -> file_id.
"""
if telegram_url:
return parse_telegram_url(telegram_url)
if chat_id and message_id is not None:
return TelegramMediaRef(
chat_id=_parse_chat_id_value(chat_id),
message_id=message_id,
file_id=file_id,
document_id=document_id,
)
if chat_id and document_id is not None:
return TelegramMediaRef(chat_id=_parse_chat_id_value(chat_id), document_id=document_id)
if chat_id and file_id:
return TelegramMediaRef(chat_id=_parse_chat_id_value(chat_id), file_id=file_id)
if file_id:
if require_file_size_for_file_id and not file_size:
raise HTTPException(
status_code=400,
detail="file_size parameter is required when using file_id. "
"The file_id doesn't contain size information needed for range requests.",
)
return TelegramMediaRef(file_id=file_id)
raise HTTPException(
status_code=400,
detail="Provide either 'd' (t.me URL), 'chat_id' + 'message_id', "
"'chat_id' + 'document_id', or 'file_id' (+ 'file_size' for stream/transcode)",
)
async def _resolve_media_info_with_file_id_fallback(telegram_manager, TelegramMediaRef, ref, file_size: int | None):
"""
Resolve media info and keep compatibility with direct file_id mode.
If chat-scoped resolution (chat_id + message_id/document_id/file_id) fails to locate
a message, and file_id is available, fall back to direct file_id resolution.
"""
try:
return ref, await telegram_manager.get_media_info(ref, file_size=file_size)
except Exception as e:
error_name = type(e).__name__
can_fallback_to_file_id = (
ref.file_id is not None
and ref.chat_id is not None
and error_name in {"TelegramDocumentNotFoundError", "TelegramMessageNotFoundError"}
)
if not can_fallback_to_file_id:
raise
fallback_ref = TelegramMediaRef(file_id=ref.file_id)
media_info = await telegram_manager.get_media_info(fallback_ref, file_size=file_size)
return fallback_ref, media_info
@telegram_router.head("/telegram/stream")
@telegram_router.get("/telegram/stream")
@telegram_router.head("/telegram/stream/{filename:path}")
@@ -126,6 +245,7 @@ async def telegram_stream(
url: Optional[str] = Query(None, description="Alias for 'd' parameter"),
chat_id: Optional[str] = Query(None, description="Chat/Channel ID (use with message_id)"),
message_id: Optional[int] = Query(None, description="Message ID (use with chat_id)"),
document_id: Optional[int] = Query(None, description="Document ID (use with chat_id)"),
file_id: Optional[str] = Query(None, description="Bot API file_id (requires file_size parameter)"),
file_size: Optional[int] = Query(None, description="File size in bytes (required for file_id streaming)"),
transcode: bool = Query(False, description="Transcode to browser-compatible fMP4 (EAC3/AC3->AAC)"),
@@ -138,6 +258,7 @@ async def telegram_stream(
Supports:
- t.me links: https://t.me/channel/123, https://t.me/c/123456789/456
- chat_id + message_id: Direct reference by IDs (e.g., chat_id=-100123456&message_id=789)
- chat_id + document_id: Resolve by scanning recent messages in the chat
- file_id + file_size: Direct streaming by Bot API file_id (requires file_size)
When transcode=true, the media is remuxed to fragmented MP4 with
@@ -155,6 +276,7 @@ async def telegram_stream(
url: Alias for 'd' parameter
chat_id: Chat/Channel ID (numeric or username)
message_id: Message ID within the chat
document_id: Telegram document ID within the chat
file_id: Bot API file_id (requires file_size parameter)
file_size: File size in bytes (required for file_id streaming)
transcode: Transcode to browser-compatible format (EAC3/AC3->AAC)
@@ -165,41 +287,28 @@ async def telegram_stream(
"""
if not settings.enable_telegram:
raise HTTPException(status_code=503, detail="Telegram proxy support is disabled")
TelegramMediaRef, parse_telegram_url, telegram_manager = _telegram_utils()
# Get the URL from either parameter
telegram_url = d or url
# Determine which input method was used
if not telegram_url and not file_id and not (chat_id and message_id):
raise HTTPException(
status_code=400,
detail="Provide either 'd' (t.me URL), 'chat_id' + 'message_id', or 'file_id' + 'file_size' parameters",
try:
ref = _build_telegram_ref_from_params(
TelegramMediaRef,
parse_telegram_url,
telegram_url=telegram_url,
chat_id=chat_id,
message_id=message_id,
file_id=file_id,
document_id=document_id,
file_size=file_size,
require_file_size_for_file_id=True,
)
try:
# Parse the reference based on input type
if telegram_url:
ref = parse_telegram_url(telegram_url)
elif chat_id and message_id:
# Direct chat_id + message_id
# Try to parse chat_id as int, otherwise treat as username
try:
parsed_chat_id: int | str = int(chat_id)
except ValueError:
parsed_chat_id = chat_id # Username
ref = TelegramMediaRef(chat_id=parsed_chat_id, message_id=message_id)
else:
# file_id mode
if not file_size:
raise HTTPException(
status_code=400,
detail="file_size parameter is required when using file_id. "
"The file_id doesn't contain size information needed for range requests.",
)
ref = TelegramMediaRef(file_id=file_id)
# Get media info (pass file_size for file_id mode)
media_info = await telegram_manager.get_media_info(ref, file_size=file_size)
ref, media_info = await _resolve_media_info_with_file_id_fallback(
telegram_manager, TelegramMediaRef, ref, file_size
)
actual_file_size = media_info.file_size
mime_type = media_info.mime_type
media_filename = filename or media_info.file_name
@@ -235,7 +344,7 @@ async def telegram_stream(
"access-control-allow-origin": "*",
}
if media_filename:
headers["content-disposition"] = f'inline; filename="{media_filename}"'
headers["content-disposition"] = _content_disposition_inline(media_filename)
return Response(headers=headers)
# Build response headers
@@ -253,7 +362,7 @@ async def telegram_stream(
base_headers["content-range"] = f"bytes {start}-{end}/{actual_file_size}"
if media_filename:
base_headers["content-disposition"] = f'inline; filename="{media_filename}"'
base_headers["content-disposition"] = _content_disposition_inline(media_filename)
response_headers = apply_header_manipulation(base_headers, proxy_headers)
@@ -326,6 +435,10 @@ async def telegram_stream(
)
elif error_name == "MessageIdInvalidError":
raise HTTPException(status_code=404, detail="Message not found in the specified chat.")
elif error_name == "TelegramMessageNotFoundError":
raise HTTPException(status_code=404, detail=str(e))
elif error_name == "TelegramDocumentNotFoundError":
raise HTTPException(status_code=404, detail=str(e))
elif error_name == "AuthKeyError":
raise HTTPException(
status_code=401, detail="Telegram session is invalid. Please regenerate the session string."
@@ -359,7 +472,7 @@ async def telegram_stream(
async def _handle_transcode(
request: Request,
ref: TelegramMediaRef,
ref: "TelegramMediaRef",
file_size: int,
start_time: float | None = None,
file_name: str = "",
@@ -371,6 +484,7 @@ async def _handle_transcode(
passes it to the source-agnostic transcode handler which handles
cue probing, seeking, and pipeline selection.
"""
handle_transcode, _, _, _ = _load_transcode_handlers()
source = TelegramMediaSource(ref, file_size, file_name=file_name)
return await handle_transcode(request, source, start_time=start_time)
@@ -385,6 +499,7 @@ async def _resolve_telegram_source(
url: str | None = None,
chat_id: str | None = None,
message_id: int | None = None,
document_id: int | None = None,
file_id: str | None = None,
file_size: int | None = None,
filename: str | None = None,
@@ -403,39 +518,31 @@ async def _resolve_telegram_source(
DC connections per request is wasteful.
"""
if not settings.enable_telegram:
from fastapi import HTTPException
raise HTTPException(status_code=503, detail="Telegram proxy support is disabled")
TelegramMediaRef, parse_telegram_url, telegram_manager = _telegram_utils()
telegram_url = d or url
if not telegram_url and not file_id and not (chat_id and message_id):
from fastapi import HTTPException
ref = _build_telegram_ref_from_params(
TelegramMediaRef,
parse_telegram_url,
telegram_url=telegram_url,
chat_id=chat_id,
message_id=message_id,
file_id=file_id,
document_id=document_id,
file_size=file_size,
require_file_size_for_file_id=True,
)
raise HTTPException(
status_code=400,
detail="Provide either 'd' (t.me URL), 'chat_id' + 'message_id', or 'file_id' + 'file_size'",
try:
ref, media_info = await _resolve_media_info_with_file_id_fallback(
telegram_manager, TelegramMediaRef, ref, file_size
)
if telegram_url:
ref = parse_telegram_url(telegram_url)
elif chat_id and message_id:
try:
parsed_chat_id: int | str = int(chat_id)
except ValueError:
parsed_chat_id = chat_id
ref = TelegramMediaRef(chat_id=parsed_chat_id, message_id=message_id)
else:
if not file_size:
from fastapi import HTTPException
raise HTTPException(
status_code=400,
detail="file_size is required when using file_id",
)
ref = TelegramMediaRef(file_id=file_id)
media_info = await telegram_manager.get_media_info(ref, file_size=file_size)
except Exception as e:
if type(e).__name__ in {"TelegramDocumentNotFoundError", "TelegramMessageNotFoundError"}:
raise HTTPException(status_code=404, detail=str(e))
raise
actual_file_size = media_info.file_size
media_filename = filename or media_info.file_name
@@ -455,6 +562,7 @@ async def telegram_transcode_hls_playlist(
url: Optional[str] = Query(None, description="Alias for 'd'"),
chat_id: Optional[str] = Query(None, description="Chat/Channel ID"),
message_id: Optional[int] = Query(None, description="Message ID"),
document_id: Optional[int] = Query(None, description="Document ID"),
file_id: Optional[str] = Query(None, description="Bot API file_id"),
file_size: Optional[int] = Query(None, description="File size in bytes"),
filename: Optional[str] = Query(None, description="Optional filename"),
@@ -462,11 +570,13 @@ async def telegram_transcode_hls_playlist(
"""Generate an HLS VOD M3U8 playlist for a Telegram media file."""
if not settings.enable_transcode:
raise HTTPException(status_code=503, detail="Transcoding support is disabled")
_, _, handle_transcode_hls_playlist, _ = _load_transcode_handlers()
source = await _resolve_telegram_source(
d,
url,
chat_id,
message_id,
document_id,
file_id,
file_size,
filename,
@@ -497,6 +607,7 @@ async def telegram_transcode_hls_init(
url: Optional[str] = Query(None, description="Alias for 'd'"),
chat_id: Optional[str] = Query(None, description="Chat/Channel ID"),
message_id: Optional[int] = Query(None, description="Message ID"),
document_id: Optional[int] = Query(None, description="Document ID"),
file_id: Optional[str] = Query(None, description="Bot API file_id"),
file_size: Optional[int] = Query(None, description="File size in bytes"),
filename: Optional[str] = Query(None, description="Optional filename"),
@@ -504,11 +615,13 @@ async def telegram_transcode_hls_init(
"""Serve the fMP4 init segment for a Telegram media file."""
if not settings.enable_transcode:
raise HTTPException(status_code=503, detail="Transcoding support is disabled")
_, handle_transcode_hls_init, _, _ = _load_transcode_handlers()
source = await _resolve_telegram_source(
d,
url,
chat_id,
message_id,
document_id,
file_id,
file_size,
filename,
@@ -527,6 +640,7 @@ async def telegram_transcode_hls_segment(
url: Optional[str] = Query(None, description="Alias for 'd'"),
chat_id: Optional[str] = Query(None, description="Chat/Channel ID"),
message_id: Optional[int] = Query(None, description="Message ID"),
document_id: Optional[int] = Query(None, description="Document ID"),
file_id: Optional[str] = Query(None, description="Bot API file_id"),
file_size: Optional[int] = Query(None, description="File size in bytes"),
filename: Optional[str] = Query(None, description="Optional filename"),
@@ -534,11 +648,13 @@ async def telegram_transcode_hls_segment(
"""Serve a single HLS fMP4 media segment for a Telegram media file."""
if not settings.enable_transcode:
raise HTTPException(status_code=503, detail="Transcoding support is disabled")
_, _, _, handle_transcode_hls_segment = _load_transcode_handlers()
source = await _resolve_telegram_source(
d,
url,
chat_id,
message_id,
document_id,
file_id,
file_size,
filename,
@@ -585,7 +701,18 @@ def _build_telegram_hls_resolved_params(
# Carry over non-identifying params from the original request
# (api_password, filename, etc.)
_skip_keys = {"d", "url", "chat_id", "message_id", "file_id", "file_size", "seg", "start_ms", "end_ms"}
_skip_keys = {
"d",
"url",
"chat_id",
"message_id",
"document_id",
"file_id",
"file_size",
"seg",
"start_ms",
"end_ms",
}
for key in request.query_params:
if key not in _skip_keys:
params[key] = request.query_params[key]
@@ -595,6 +722,9 @@ def _build_telegram_hls_resolved_params(
if ref.chat_id is not None and ref.message_id is not None:
params["chat_id"] = str(ref.chat_id)
params["message_id"] = str(ref.message_id)
elif ref.chat_id is not None and ref.document_id is not None:
params["chat_id"] = str(ref.chat_id)
params["document_id"] = str(ref.document_id)
elif ref.file_id:
params["file_id"] = ref.file_id
# Always include file_size -- it prevents unnecessary lookups
@@ -609,6 +739,7 @@ async def telegram_info(
url: Optional[str] = Query(None, description="Alias for 'd' parameter"),
chat_id: Optional[str] = Query(None, description="Chat/Channel ID (use with message_id)"),
message_id: Optional[int] = Query(None, description="Message ID (use with chat_id)"),
document_id: Optional[int] = Query(None, description="Document ID (use with chat_id)"),
file_id: Optional[str] = Query(None, description="Bot API file_id"),
file_size: Optional[int] = Query(None, description="File size in bytes (optional for file_id)"),
):
@@ -620,6 +751,7 @@ async def telegram_info(
url: Alias for 'd' parameter
chat_id: Chat/Channel ID (numeric or username)
message_id: Message ID within the chat
document_id: Telegram document ID within the chat
file_id: Bot API file_id
file_size: File size in bytes (optional, will be 0 if not provided for file_id)
@@ -628,28 +760,26 @@ async def telegram_info(
"""
if not settings.enable_telegram:
raise HTTPException(status_code=503, detail="Telegram proxy support is disabled")
TelegramMediaRef, parse_telegram_url, telegram_manager = _telegram_utils()
telegram_url = d or url
if not telegram_url and not file_id and not (chat_id and message_id):
raise HTTPException(
status_code=400,
detail="Provide either 'd' (t.me URL), 'chat_id' + 'message_id', or 'file_id' parameter",
try:
ref = _build_telegram_ref_from_params(
TelegramMediaRef,
parse_telegram_url,
telegram_url=telegram_url,
chat_id=chat_id,
message_id=message_id,
file_id=file_id,
document_id=document_id,
file_size=file_size,
require_file_size_for_file_id=False,
)
try:
if telegram_url:
ref = parse_telegram_url(telegram_url)
elif chat_id and message_id:
try:
parsed_chat_id: int | str = int(chat_id)
except ValueError:
parsed_chat_id = chat_id
ref = TelegramMediaRef(chat_id=parsed_chat_id, message_id=message_id)
else:
ref = TelegramMediaRef(file_id=file_id)
media_info = await telegram_manager.get_media_info(ref, file_size=file_size)
ref, media_info = await _resolve_media_info_with_file_id_fallback(
telegram_manager, TelegramMediaRef, ref, file_size
)
return {
"file_id": media_info.file_id,
@@ -673,6 +803,10 @@ async def telegram_info(
)
elif error_name == "MessageIdInvalidError":
raise HTTPException(status_code=404, detail="Message not found in the specified chat.")
elif error_name == "TelegramMessageNotFoundError":
raise HTTPException(status_code=404, detail=str(e))
elif error_name == "TelegramDocumentNotFoundError":
raise HTTPException(status_code=404, detail=str(e))
elif error_name == "FileReferenceExpiredError":
raise HTTPException(
status_code=410,
@@ -718,6 +852,7 @@ async def telegram_status():
}
# Check if client is connected
_, _, telegram_manager = _telegram_utils()
if telegram_manager.is_initialized:
return {
"enabled": True,
@@ -783,6 +918,9 @@ async def session_start(request: SessionStartRequest):
session_id = secrets.token_urlsafe(16)
try:
from telethon import TelegramClient
from telethon.sessions import StringSession
client = TelegramClient(StringSession(), request.api_id, request.api_hash)
await client.connect()
+24 -7
View File
@@ -22,6 +22,7 @@ Configuration:
import base64
import logging
import re
from functools import lru_cache
from typing import Annotated
from urllib.parse import urljoin, urlencode, urlparse
@@ -31,13 +32,6 @@ from fastapi import APIRouter, Request, Depends, Query, Response, HTTPException
from mediaflow_proxy.configs import settings
from mediaflow_proxy.handlers import proxy_stream
from mediaflow_proxy.remuxer.media_source import HTTPMediaSource
from mediaflow_proxy.remuxer.transcode_handler import (
handle_transcode,
handle_transcode_hls_init,
handle_transcode_hls_playlist,
handle_transcode_hls_segment,
)
from mediaflow_proxy.utils.base64_utils import decode_base64_url
from mediaflow_proxy.utils.http_utils import ProxyRequestHeaders, get_proxy_headers
from mediaflow_proxy.utils.http_client import create_aiohttp_session
@@ -46,10 +40,30 @@ logger = logging.getLogger(__name__)
xtream_root_router = APIRouter()
@lru_cache(maxsize=1)
def _load_transcode_components():
from mediaflow_proxy.remuxer.media_source import HTTPMediaSource
from mediaflow_proxy.remuxer.transcode_handler import (
handle_transcode,
handle_transcode_hls_init,
handle_transcode_hls_playlist,
handle_transcode_hls_segment,
)
return (
HTTPMediaSource,
handle_transcode,
handle_transcode_hls_init,
handle_transcode_hls_playlist,
handle_transcode_hls_segment,
)
async def _handle_xtream_transcode(request, upstream_url: str, proxy_headers, start_time: float | None):
"""Shared transcode handler for Xtream stream endpoints."""
if not settings.enable_transcode:
raise HTTPException(status_code=503, detail="Transcoding support is disabled")
HTTPMediaSource, handle_transcode, _, _, _ = _load_transcode_components()
source = HTTPMediaSource(url=upstream_url, headers=dict(proxy_headers.request))
await source.resolve_file_size()
return await handle_transcode(request, source, start_time=start_time)
@@ -59,6 +73,7 @@ async def _handle_xtream_hls_playlist(request, upstream_url: str, proxy_headers)
"""Generate HLS VOD playlist for an Xtream stream."""
if not settings.enable_transcode:
raise HTTPException(status_code=503, detail="Transcoding support is disabled")
HTTPMediaSource, _, _, handle_transcode_hls_playlist, _ = _load_transcode_components()
from urllib.parse import quote
source = HTTPMediaSource(url=upstream_url, headers=dict(proxy_headers.request))
@@ -88,6 +103,7 @@ async def _handle_xtream_hls_init(request, upstream_url: str, proxy_headers):
"""Serve fMP4 init segment for an Xtream stream."""
if not settings.enable_transcode:
raise HTTPException(status_code=503, detail="Transcoding support is disabled")
HTTPMediaSource, _, handle_transcode_hls_init, _, _ = _load_transcode_components()
source = HTTPMediaSource(url=upstream_url, headers=dict(proxy_headers.request))
await source.resolve_file_size()
return await handle_transcode_hls_init(request, source)
@@ -104,6 +120,7 @@ async def _handle_xtream_hls_segment(
"""Serve a single HLS fMP4 segment for an Xtream stream."""
if not settings.enable_transcode:
raise HTTPException(status_code=503, detail="Transcoding support is disabled")
HTTPMediaSource, _, _, _, handle_transcode_hls_segment = _load_transcode_components()
source = HTTPMediaSource(url=upstream_url, headers=dict(proxy_headers.request))
await source.resolve_file_size()
return await handle_transcode_hls_segment(
+7 -1
View File
@@ -263,6 +263,10 @@ class MPDSegmentParams(GenericParams):
False,
description="Whether EXT-X-MAP is used (init sent separately). If true, don't concatenate init with segment.",
)
segment_range: Optional[str] = Field(
None,
description="Byte range for the media segment (e.g. '658-'). Used for SegmentBase MPDs where init and segment share the same file.",
)
class MPDInitParams(GenericParams):
@@ -280,6 +284,7 @@ class MPDInitParams(GenericParams):
class ExtractorURLParams(GenericParams):
host: Literal[
"City",
"Doodstream",
"FileLions",
"FileMoon",
@@ -295,13 +300,14 @@ class ExtractorURLParams(GenericParams):
"Maxstream",
"LiveTV",
"LuluStream",
"DLHD",
"Fastream",
"TurboVidPlay",
"Vidmoly",
"Vidoza",
"Voe",
"Sportsonline",
"Vavoo",
"VidFast",
] = Field(..., description="The host to extract the URL from.")
destination: Annotated[str, Field(description="The URL of the stream.", alias="d")]
redirect_stream: bool = Field(False, description="Whether to redirect to the stream endpoint automatically.")
+330 -13
View File
@@ -174,6 +174,9 @@
<button onclick="switchTab('telegram')" id="tab-telegram" class="tab-btn px-6 py-3 rounded-xl font-semibold text-sm bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-all duration-200 shadow-md">
<i class="fa-brands fa-telegram"></i> Telegram
</button>
<button onclick="switchTab('epg')" id="tab-epg" class="tab-btn px-6 py-3 rounded-xl font-semibold text-sm bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-all duration-200 shadow-md">
📅 EPG Proxy
</button>
</div>
<!-- Proxy URL Generator Tab -->
@@ -554,27 +557,30 @@
<label class="block text-sm font-semibold text-gray-700 dark:text-gray-200 mb-2">Video Host <span class="text-red-500">*</span></label>
<select id="extractor-host" onchange="onExtractorHostChange()" class="input-field w-full px-4 py-3 rounded-xl border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-700/50 text-gray-900 dark:text-white focus:outline-none focus:border-indigo-500">
<option value="">Select a host...</option>
<option value="City">City</option>
<option value="Doodstream">Doodstream</option>
<option value="F16Px">F16Px</option>
<option value="Fastream">Fastream</option>
<option value="FileLions">FileLions</option>
<option value="FileMoon">FileMoon</option>
<option value="F16Px">F16Px</option>
<option value="Gupload">Gupload</option>
<option value="LiveTV">LiveTV</option>
<option value="LuluStream">LuluStream</option>
<option value="Maxstream">Maxstream</option>
<option value="Mixdrop">Mixdrop</option>
<option value="Uqload">Uqload</option>
<option value="Okru">Okru</option>
<option value="Sportsonline">Sportsonline</option>
<option value="Streamtape">Streamtape</option>
<option value="StreamWish">StreamWish</option>
<option value="Supervideo">Supervideo</option>
<option value="VixCloud">VixCloud</option>
<option value="Okru">Okru</option>
<option value="Maxstream">Maxstream</option>
<option value="LiveTV">LiveTV</option>
<option value="LuluStream">LuluStream</option>
<option value="DLHD">DLHD</option>
<option value="Fastream">Fastream</option>
<option value="TurboVidPlay">TurboVidPlay</option>
<option value="Uqload">Uqload</option>
<option value="Vavoo">Vavoo</option>
<option value="VidFast">VidFast</option>
<option value="Vidmoly">Vidmoly</option>
<option value="Vidoza">Vidoza</option>
<option value="VixCloud">VixCloud</option>
<option value="Voe">Voe</option>
<option value="Sportsonline">Sportsonline</option>
</select>
</div>
@@ -1574,6 +1580,242 @@
</div>
</div>
</div>
<!-- EPG Proxy Tab -->
<div id="panel-epg" class="tab-panel hidden animate-fade-in">
<div class="bg-white dark:bg-gray-800 rounded-2xl shadow-xl p-6 border border-gray-200 dark:border-gray-700">
<h2 class="text-xl font-bold text-gray-800 dark:text-white mb-2 flex items-center gap-2">
<span class="w-8 h-8 rounded-lg bg-gradient-to-br from-emerald-500 to-teal-600 flex items-center justify-center text-white text-sm">📅</span>
EPG Proxy URL Generator
</h2>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-6">
Proxy any XMLTV/EPG source through MediaFlow with caching. Compatible with
<strong>Channels DVR</strong>, Plex, Emby, Jellyfin, TiviMate, and all XMLTV-based clients.
</p>
<!-- What is EPG info box -->
<div class="bg-teal-50 dark:bg-teal-900/20 rounded-xl p-4 border border-teal-200 dark:border-teal-800 mb-6">
<div class="flex items-start gap-3">
<svg class="w-5 h-5 text-teal-500 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<div class="text-sm text-teal-800 dark:text-teal-200">
<p class="font-semibold mb-1">EPG vs DVR — what's the difference?</p>
<p><strong>EPG</strong> (Electronic Program Guide) is the XMLTV schedule data file that tells your player/DVR what's on TV and when.</p>
<p class="mt-1"><strong>Channels DVR</strong> is a popular DVR application that <em>reads</em> EPG data to populate its TV guide and schedule recordings. This proxy sits between Channels DVR (or any other client) and your upstream EPG source.</p>
</div>
</div>
</div>
<div class="space-y-5">
<!-- Proxy Base URL -->
<div>
<label class="block text-sm font-semibold text-gray-700 dark:text-gray-200 mb-2">MediaFlow Proxy URL <span class="text-red-500">*</span></label>
<input type="url" id="epg-proxy-url" placeholder="http://localhost:8888"
class="input-field w-full px-4 py-3 rounded-xl border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-700/50 text-gray-900 dark:text-white focus:outline-none focus:border-emerald-500">
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Base URL of your MediaFlow proxy instance</p>
</div>
<!-- EPG Source URL -->
<div>
<label class="block text-sm font-semibold text-gray-700 dark:text-gray-200 mb-2">EPG Source URL <span class="text-red-500">*</span></label>
<input type="url" id="epg-source-url" placeholder="http://provider.com/epg.xml or http://provider.com/xmltv.php?username=x&password=y"
class="input-field w-full px-4 py-3 rounded-xl border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-700/50 text-gray-900 dark:text-white focus:outline-none focus:border-emerald-500">
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Your upstream XMLTV/EPG source URL</p>
</div>
<!-- Cache TTL -->
<div>
<label class="block text-sm font-semibold text-gray-700 dark:text-gray-200 mb-2">Cache Duration <span class="text-gray-400 font-normal">(optional)</span></label>
<div class="flex gap-3 items-center">
<select id="epg-cache-ttl" class="input-field px-4 py-2.5 rounded-xl border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:outline-none focus:border-emerald-500 text-sm">
<option value="">Default (1 hour)</option>
<option value="1800">30 minutes</option>
<option value="3600">1 hour</option>
<option value="7200">2 hours</option>
<option value="14400">4 hours</option>
<option value="21600">6 hours</option>
<option value="43200">12 hours</option>
<option value="86400">24 hours</option>
<option value="0">Disabled (no cache)</option>
</select>
<p class="text-xs text-gray-500 dark:text-gray-400">EPG data rarely changes — longer cache = fewer upstream requests</p>
</div>
</div>
<!-- Base64 Encode Toggle -->
<div class="bg-gray-50 dark:bg-gray-700/30 rounded-xl p-4 border border-gray-200 dark:border-gray-600">
<div class="flex items-center gap-3 mb-2">
<label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" id="epg-base64" class="sr-only peer" checked>
<div class="w-11 h-6 bg-gray-300 dark:bg-gray-600 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-emerald-500"></div>
</label>
<span class="text-sm font-semibold text-gray-700 dark:text-gray-200">Base64-encode source URL</span>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400">Recommended — hides credentials in your EPG source URL and prevents URL-parsing issues</p>
</div>
<!-- Custom Headers for Protected Sources -->
<div class="bg-gray-50 dark:bg-gray-700/30 rounded-xl p-4 border border-gray-200 dark:border-gray-600">
<div class="flex items-center justify-between mb-3">
<h4 class="text-sm font-semibold text-gray-700 dark:text-gray-200">Custom Request Headers <span class="text-gray-400 font-normal">(optional)</span></h4>
<button onclick="addEpgHeader()" class="text-xs px-3 py-1.5 rounded-lg bg-emerald-100 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300 hover:bg-emerald-200 dark:hover:bg-emerald-900/50 transition-colors font-medium">
+ Add Header
</button>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400 mb-3">Use for EPG sources that require authentication (e.g. Authorization: Bearer token)</p>
<div id="epg-headers-list" class="space-y-2">
<!-- Headers injected here -->
</div>
</div>
</div>
<!-- Generate Button -->
<div class="mt-6">
<button onclick="generateEpgUrl()" class="generate-btn w-full py-3.5 rounded-xl text-white font-semibold text-sm" style="background: linear-gradient(135deg, #10b981 0%, #0d9488 100%);">
Generate EPG Proxy URL
</button>
</div>
<!-- Output -->
<div id="epg-output" class="mt-6 hidden">
<!-- URL Card -->
<div class="bg-gradient-to-br from-emerald-50 to-teal-50 dark:from-emerald-900/20 dark:to-teal-900/20 rounded-xl p-6 border border-emerald-200 dark:border-emerald-800 mb-6">
<h3 class="text-base font-bold text-gray-800 dark:text-white mb-4 flex items-center gap-2">
<svg class="w-5 h-5 text-emerald-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"/>
</svg>
Your EPG Proxy URL
</h3>
<div class="flex gap-2">
<div class="url-output flex-1 rounded-xl px-4 py-3 text-green-400 text-xs break-all leading-relaxed">
<code id="epg-result-url"></code>
</div>
<button onclick="copyEpgUrl(event)" class="copy-btn px-4 py-2 rounded-xl text-white font-semibold text-sm flex-shrink-0 self-start">
📋 Copy
</button>
</div>
</div>
<!-- Setup Instructions -->
<div class="bg-gray-50 dark:bg-gray-700/30 rounded-xl p-4 border border-gray-200 dark:border-gray-600">
<h4 class="text-sm font-semibold text-gray-700 dark:text-gray-200 mb-3 flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
</svg>
Setup Instructions
</h4>
<div class="space-y-3 text-sm">
<!-- Channels DVR -->
<div class="collapsible">
<div class="collapsible-header flex items-center justify-between p-3 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors" onclick="toggleCollapsible(this)">
<span class="font-medium text-gray-700 dark:text-gray-300">📡 Channels DVR</span>
<svg class="w-4 h-4 text-gray-500 transform transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
</div>
<div class="collapsible-content">
<div class="p-3 text-gray-600 dark:text-gray-400">
<ol class="list-decimal list-inside space-y-1">
<li>Open Channels DVR web interface → <strong>Sources</strong></li>
<li>Click <strong>Add Source</strong> → choose <strong>Custom Channels</strong></li>
<li>Under the channel list, find the <strong>EPG</strong> or <strong>Program Guide</strong> section</li>
<li>Paste the generated URL as your <strong>EPG / XMLTV URL</strong></li>
<li>Save and trigger a guide refresh</li>
</ol>
</div>
</div>
</div>
<!-- Plex -->
<div class="collapsible">
<div class="collapsible-header flex items-center justify-between p-3 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors" onclick="toggleCollapsible(this)">
<span class="font-medium text-gray-700 dark:text-gray-300">🟡 Plex</span>
<svg class="w-4 h-4 text-gray-500 transform transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
</div>
<div class="collapsible-content">
<div class="p-3 text-gray-600 dark:text-gray-400">
<ol class="list-decimal list-inside space-y-1">
<li>Open Plex → <strong>Settings → Live TV &amp; DVR</strong></li>
<li>Set up or edit your tuner/M3U source</li>
<li>In the EPG/Guide Data step, choose <strong>XMLTV Guide</strong></li>
<li>Paste the generated URL as the <strong>XMLTV URL</strong></li>
<li>Complete setup and refresh guide data</li>
</ol>
</div>
</div>
</div>
<!-- Emby / Jellyfin -->
<div class="collapsible">
<div class="collapsible-header flex items-center justify-between p-3 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors" onclick="toggleCollapsible(this)">
<span class="font-medium text-gray-700 dark:text-gray-300">🟢 Emby / Jellyfin</span>
<svg class="w-4 h-4 text-gray-500 transform transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
</div>
<div class="collapsible-content">
<div class="p-3 text-gray-600 dark:text-gray-400">
<ol class="list-decimal list-inside space-y-1">
<li>Open Emby/Jellyfin → <strong>Dashboard → Live TV</strong></li>
<li>Click <strong>+</strong> next to <em>TV Guide Data Providers</em></li>
<li>Select <strong>XMLTV</strong> as the guide provider type</li>
<li>Paste the generated URL as the <strong>XMLTV URL</strong></li>
<li>Save — guide data will refresh automatically</li>
</ol>
</div>
</div>
</div>
<!-- TiviMate -->
<div class="collapsible">
<div class="collapsible-header flex items-center justify-between p-3 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors" onclick="toggleCollapsible(this)">
<span class="font-medium text-gray-700 dark:text-gray-300">📱 TiviMate</span>
<svg class="w-4 h-4 text-gray-500 transform transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
</div>
<div class="collapsible-content">
<div class="p-3 text-gray-600 dark:text-gray-400">
<ol class="list-decimal list-inside space-y-1">
<li>Open TiviMate → <strong>Settings → Playlists</strong></li>
<li>Select your playlist → <strong>EPG</strong></li>
<li>Tap <strong>Add EPG Source</strong></li>
<li>Paste the generated URL and save</li>
</ol>
</div>
</div>
</div>
<!-- Generic XMLTV -->
<div class="collapsible">
<div class="collapsible-header flex items-center justify-between p-3 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors" onclick="toggleCollapsible(this)">
<span class="font-medium text-gray-700 dark:text-gray-300">🎯 Any XMLTV-compatible app</span>
<svg class="w-4 h-4 text-gray-500 transform transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
</div>
<div class="collapsible-content">
<div class="p-3 text-gray-600 dark:text-gray-400">
<p class="mb-2">Use the generated URL anywhere an <strong>XMLTV URL</strong> or <strong>EPG URL</strong> is accepted:</p>
<ul class="list-disc list-inside space-y-1">
<li>The proxy returns standard XMLTV XML</li>
<li>Responses are cached (reduces load on your provider)</li>
<li>Set <code>cache_ttl=0</code> to always fetch fresh data</li>
</ul>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</main>
<script>
@@ -2071,9 +2313,9 @@
// Hosts that return HLS streams (auto-suggest .m3u8 extension)
const HLS_EXTRACTOR_HOSTS = [
'TurboVidPlay', 'FileMoon', 'StreamWish', 'VixCloud',
'LiveTV', 'LuluStream', 'DLHD', 'Fastream', 'Sportsonline',
'FileLions', 'Vidmoly', 'Voe'
'City', 'TurboVidPlay', 'FileMoon', 'StreamWish', 'VixCloud',
'LiveTV', 'LuluStream', 'Fastream', 'Sportsonline',
'FileLions', 'Gupload', 'VidFast', 'Vidmoly', 'Voe'
];
// Handle extractor host selection change
@@ -3033,9 +3275,82 @@ TELEGRAM_SESSION_STRING=${sessionString}`;
}
}
// ─── EPG Proxy ────────────────────────────────────────────────────────
let epgHeaderCount = 0;
function addEpgHeader(name = '', value = '') {
const list = document.getElementById('epg-headers-list');
const id = epgHeaderCount++;
const row = document.createElement('div');
row.className = 'flex gap-2 items-center epg-header-row';
row.dataset.id = id;
row.innerHTML = `
<input type="text" placeholder="Header name (e.g. Authorization)" value="${name}"
class="epg-header-name input-field flex-1 px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:outline-none focus:border-emerald-500 text-sm">
<input type="text" placeholder="Header value (e.g. Bearer token123)" value="${value}"
class="epg-header-value input-field flex-1 px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:outline-none focus:border-emerald-500 text-sm">
<button onclick="this.closest('.epg-header-row').remove()"
class="flex-shrink-0 w-8 h-8 rounded-lg bg-red-100 dark:bg-red-900/30 text-red-500 hover:bg-red-200 dark:hover:bg-red-900/50 transition-colors flex items-center justify-center text-sm">
</button>`;
list.appendChild(row);
}
function generateEpgUrl() {
const proxyUrl = (document.getElementById('epg-proxy-url').value || window.location.origin).replace(/\/$/, '');
const sourceUrl = document.getElementById('epg-source-url').value.trim();
const cacheTtl = document.getElementById('epg-cache-ttl').value;
const useBase64 = document.getElementById('epg-base64').checked;
const apiPassword = document.getElementById('globalApiPassword').value.trim();
if (!sourceUrl) {
alert('Please enter an EPG Source URL.');
return;
}
// Build destination param
let dest = sourceUrl;
if (useBase64) {
dest = btoa(sourceUrl).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
const params = new URLSearchParams();
params.set('d', dest);
if (apiPassword) params.set('api_password', apiPassword);
if (cacheTtl !== '') params.set('cache_ttl', cacheTtl);
// Collect custom headers
document.querySelectorAll('.epg-header-row').forEach(row => {
const name = row.querySelector('.epg-header-name').value.trim();
const value = row.querySelector('.epg-header-value').value.trim();
if (name && value) {
params.set(`h_${name}`, value);
}
});
const finalUrl = `${proxyUrl}/proxy/epg?${params.toString()}`;
document.getElementById('epg-result-url').textContent = finalUrl;
document.getElementById('epg-output').classList.remove('hidden');
document.getElementById('epg-output').scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
function copyEpgUrl(event) {
const url = document.getElementById('epg-result-url').textContent;
navigator.clipboard.writeText(url).then(() => {
const btn = event.currentTarget;
const original = btn.textContent;
btn.textContent = '✅ Copied!';
setTimeout(() => { btn.textContent = original; }, 2000);
});
}
// ─── End EPG Proxy ────────────────────────────────────────────────────
// Check for hash on page load to switch to correct tab
document.addEventListener('DOMContentLoaded', () => {
document.getElementById('encoded-proxy-url').value = window.location.origin;
document.getElementById('epg-proxy-url').value = window.location.origin;
// Check if URL has hash
if (window.location.hash === '#xc') {
@@ -3044,6 +3359,8 @@ TELEGRAM_SESSION_STRING=${sessionString}`;
switchTab('acestream');
} else if (window.location.hash === '#telegram') {
switchTab('telegram');
} else if (window.location.hash === '#epg') {
switchTab('epg');
}
});
</script>

Some files were not shown because too many files have changed in this diff Show More