mirror of
https://github.com/UrloMythus/UnHided.git
synced 2026-06-10 09:10:23 +00:00
new version
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -64,14 +64,21 @@ class Settings(BaseSettings):
|
|||||||
dash_prebuffer_emergency_threshold: int = 90 # Emergency threshold percentage to trigger aggressive cache cleanup.
|
dash_prebuffer_emergency_threshold: int = 90 # Emergency threshold percentage to trigger aggressive cache cleanup.
|
||||||
dash_prebuffer_inactivity_timeout: int = 60 # Seconds of inactivity before cleaning up stream state.
|
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_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_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.
|
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.
|
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.
|
processed_segment_cache_ttl: int = 60 # TTL (seconds) for caching processed (decrypted/remuxed) segments.
|
||||||
|
|
||||||
# FlareSolverr settings (for Cloudflare bypass)
|
# Byparr settings — Firefox/Camoufox-based solver for Cloudflare bypass and chevy IP whitelist.
|
||||||
flaresolverr_url: str | None = None # FlareSolverr service URL. Example: http://localhost:8191
|
# https://github.com/ThePhaseless/Byparr (drop-in FlareSolverr-compatible API)
|
||||||
flaresolverr_timeout: int = 60 # Timeout (seconds) for FlareSolverr requests.
|
byparr_url: str | None = None # Byparr service URL. Example: http://localhost:8192
|
||||||
|
byparr_timeout: int = 60 # Timeout (seconds) for Byparr requests.
|
||||||
|
|
||||||
# Acestream settings
|
# Acestream settings
|
||||||
enable_acestream: bool = False # Whether to enable Acestream proxy support.
|
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_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_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_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
|
# Transcode settings
|
||||||
enable_transcode: bool = True # Whether to enable on-the-fly transcoding endpoints (MKV→fMP4, HLS VOD).
|
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.
|
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.
|
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 settings
|
||||||
redis_url: str | None = None # Redis URL for distributed locking and caching. None = disabled.
|
redis_url: str | None = None # Redis URL for distributed locking and caching. None = disabled.
|
||||||
cache_namespace: str | None = (
|
cache_namespace: str | None = (
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -645,17 +645,23 @@ class MP4Decrypter:
|
|||||||
|
|
||||||
return sample_info
|
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.
|
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
|
Uses the KID extracted from the tenc box if available, otherwise falls back to
|
||||||
using the first key if only one key is provided.
|
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:
|
Args:
|
||||||
track_id (int): The track ID.
|
track_id (int): The track ID.
|
||||||
|
|
||||||
Returns:
|
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 we have an extracted KID for this track, use it to look up the key
|
||||||
if track_id in self.extracted_kids:
|
if track_id in self.extracted_kids:
|
||||||
@@ -668,13 +674,31 @@ class MP4Decrypter:
|
|||||||
if len(self.key_map) == 1:
|
if len(self.key_map) == 1:
|
||||||
return next(iter(self.key_map.values()))
|
return next(iter(self.key_map.values()))
|
||||||
else:
|
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)
|
key = self.key_map.get(extracted_kid)
|
||||||
if key:
|
if key:
|
||||||
return key
|
return key
|
||||||
# If KID doesn't match, try fallback
|
|
||||||
# Note: This is expected when KID in file doesn't match provided key_id
|
# PlayReady GUID fallback: some content packagers store the KID in
|
||||||
# The provided key_id should still work if it's the correct decryption key
|
# 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)
|
# Fallback: if only one key provided, use it (backward compatibility)
|
||||||
if len(self.key_map) == 1:
|
if len(self.key_map) == 1:
|
||||||
@@ -683,9 +707,12 @@ class MP4Decrypter:
|
|||||||
# Try using track_id as KID (for multi-key scenarios)
|
# Try using track_id as KID (for multi-key scenarios)
|
||||||
track_id_bytes = track_id.to_bytes(4, "big")
|
track_id_bytes = track_id.to_bytes(4, "big")
|
||||||
key = self.key_map.get(track_id_bytes)
|
key = self.key_map.get(track_id_bytes)
|
||||||
if not key:
|
if key:
|
||||||
raise ValueError(f"No key found for track ID {track_id}")
|
return 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
|
@staticmethod
|
||||||
def _process_sample(
|
def _process_sample(
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -9,7 +9,7 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
|
|
||||||
from mediaflow_proxy.configs import settings
|
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
|
from mediaflow_proxy.utils.http_utils import DownloadError
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -65,6 +65,16 @@ class BaseExtractor(ABC):
|
|||||||
# merge incoming headers (e.g. Accept-Language / Referer) with default base headers
|
# merge incoming headers (e.g. Accept-Language / Referer) with default base headers
|
||||||
self.base_headers.update(request_headers or {})
|
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(
|
async def _make_request(
|
||||||
self,
|
self,
|
||||||
url: str,
|
url: str,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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}")
|
|
||||||
@@ -1,49 +1,209 @@
|
|||||||
|
import logging
|
||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
from urllib.parse import urlparse, urljoin
|
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.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):
|
class DoodStreamExtractor(BaseExtractor):
|
||||||
"""
|
"""
|
||||||
Dood / MyVidPlay extractor
|
DoodStream / PlayMogo extractor.
|
||||||
Resolves to direct CDN MP4
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, request_headers: dict):
|
All DoodStream mirror domains (dsvplay.com, myvidplay.com, dood.to, …) now
|
||||||
super().__init__(request_headers)
|
redirect to playmogo.com which sits behind Cloudflare and may require a
|
||||||
self.base_url = "https://myvidplay.com"
|
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):
|
async def extract(self, url: str, **kwargs):
|
||||||
parsed = urlparse(url)
|
parsed = urlparse(url)
|
||||||
video_id = parsed.path.rstrip("/").split("/")[-1]
|
video_id = parsed.path.rstrip("/").split("/")[-1]
|
||||||
if not video_id:
|
if not video_id:
|
||||||
raise ExtractorError("Invalid Dood URL")
|
raise ExtractorError("Invalid DoodStream URL: no video ID found")
|
||||||
|
|
||||||
headers = {
|
if settings.byparr_url:
|
||||||
"User-Agent": self.base_headers.get("User-Agent") or "Mozilla/5.0",
|
try:
|
||||||
"Referer": f"{self.base_url}/",
|
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}"
|
async with aiohttp.ClientSession() as session:
|
||||||
html = (await self._make_request(embed_url, headers=headers)).text
|
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 data.get("status") != "ok":
|
||||||
if not match:
|
raise ExtractorError(f"Byparr: {data.get('message', 'unknown error')}")
|
||||||
raise ExtractorError("Dood: pass_md5 not found")
|
|
||||||
|
|
||||||
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:
|
if not token_match:
|
||||||
raise ExtractorError("Dood: token missing")
|
raise ExtractorError("DoodStream: token not found in embed HTML")
|
||||||
|
|
||||||
token = token_match.group(1)
|
token = token_match.group(1)
|
||||||
|
expiry = int(time.time())
|
||||||
final_url = f"{base_stream}123456789?token={token}&expiry={int(time.time())}"
|
final_url = f"{base_stream}123456789?token={token}&expiry={expiry}"
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"destination_url": final_url,
|
"destination_url": final_url,
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
from typing import Dict, Type
|
from typing import Dict, Type
|
||||||
|
|
||||||
from mediaflow_proxy.extractors.base import BaseExtractor, ExtractorError
|
from mediaflow_proxy.extractors.base import BaseExtractor, ExtractorError
|
||||||
from mediaflow_proxy.extractors.dlhd import DLHDExtractor
|
|
||||||
from mediaflow_proxy.extractors.doodstream import DoodStreamExtractor
|
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.sportsonline import SportsonlineExtractor
|
||||||
from mediaflow_proxy.extractors.filelions import FileLionsExtractor
|
from mediaflow_proxy.extractors.filelions import FileLionsExtractor
|
||||||
from mediaflow_proxy.extractors.filemoon import FileMoonExtractor
|
from mediaflow_proxy.extractors.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.vixcloud import VixCloudExtractor
|
||||||
from mediaflow_proxy.extractors.fastream import FastreamExtractor
|
from mediaflow_proxy.extractors.fastream import FastreamExtractor
|
||||||
from mediaflow_proxy.extractors.voe import VoeExtractor
|
from mediaflow_proxy.extractors.voe import VoeExtractor
|
||||||
|
from mediaflow_proxy.extractors.vidfast import VidFastExtractor
|
||||||
|
|
||||||
|
|
||||||
class ExtractorFactory:
|
class ExtractorFactory:
|
||||||
"""Factory for creating URL extractors."""
|
"""Factory for creating URL extractors."""
|
||||||
|
|
||||||
_extractors: Dict[str, Type[BaseExtractor]] = {
|
_extractors: Dict[str, Type[BaseExtractor]] = {
|
||||||
|
"City": CityExtractor,
|
||||||
"Doodstream": DoodStreamExtractor,
|
"Doodstream": DoodStreamExtractor,
|
||||||
"FileLions": FileLionsExtractor,
|
"FileLions": FileLionsExtractor,
|
||||||
"FileMoon": FileMoonExtractor,
|
"FileMoon": FileMoonExtractor,
|
||||||
@@ -46,13 +48,13 @@ class ExtractorFactory:
|
|||||||
"Maxstream": MaxstreamExtractor,
|
"Maxstream": MaxstreamExtractor,
|
||||||
"LiveTV": LiveTVExtractor,
|
"LiveTV": LiveTVExtractor,
|
||||||
"LuluStream": LuluStreamExtractor,
|
"LuluStream": LuluStreamExtractor,
|
||||||
"DLHD": DLHDExtractor,
|
|
||||||
"Vavoo": VavooExtractor,
|
"Vavoo": VavooExtractor,
|
||||||
"Vidmoly": VidmolyExtractor,
|
"Vidmoly": VidmolyExtractor,
|
||||||
"Vidoza": VidozaExtractor,
|
"Vidoza": VidozaExtractor,
|
||||||
"Fastream": FastreamExtractor,
|
"Fastream": FastreamExtractor,
|
||||||
"Voe": VoeExtractor,
|
"Voe": VoeExtractor,
|
||||||
"Sportsonline": SportsonlineExtractor,
|
"Sportsonline": SportsonlineExtractor,
|
||||||
|
"VidFast": VidFastExtractor,
|
||||||
}
|
}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|||||||
@@ -1,23 +1,42 @@
|
|||||||
import re
|
import re
|
||||||
from typing import Dict, Any
|
from typing import Dict, Any
|
||||||
|
|
||||||
|
from curl_cffi.requests import AsyncSession
|
||||||
|
|
||||||
from mediaflow_proxy.extractors.base import BaseExtractor, ExtractorError
|
from mediaflow_proxy.extractors.base import BaseExtractor, ExtractorError
|
||||||
|
|
||||||
|
|
||||||
class LuluStreamExtractor(BaseExtractor):
|
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):
|
def __init__(self, *args, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
self.mediaflow_endpoint = "hls_manifest_proxy"
|
self.mediaflow_endpoint = "hls_manifest_proxy"
|
||||||
|
|
||||||
async def extract(self, url: str, **kwargs) -> Dict[str, Any]:
|
async def extract(self, url: str, **kwargs) -> Dict[str, Any]:
|
||||||
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
|
# See https://github.com/Gujal00/ResolveURL/blob/master/script.module.resolveurl/lib/resolveurl/plugins/lulustream.py
|
||||||
pattern = r"""sources:\s*\[{file:\s*["'](?P<url>[^"']+)"""
|
pattern = r"""sources:\s*\[{file:\s*["'](?P<url>[^"']+)"""
|
||||||
match = re.search(pattern, response.text, re.DOTALL)
|
match = re.search(pattern, response.text, re.DOTALL)
|
||||||
if not match:
|
if not match:
|
||||||
raise ExtractorError("Failed to extract source URL")
|
raise ExtractorError("LuluStream: Failed to extract source URL")
|
||||||
final_url = match.group(1)
|
final_url = match.group("url")
|
||||||
|
|
||||||
self.base_headers["referer"] = url
|
self.base_headers["referer"] = url
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import re
|
import re
|
||||||
import logging
|
import logging
|
||||||
from typing import Any, Dict
|
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.extractors.base import BaseExtractor, ExtractorError
|
||||||
from mediaflow_proxy.utils.packed import unpack
|
from mediaflow_proxy.utils.packed import unpack
|
||||||
@@ -14,7 +14,7 @@ class SportsonlineExtractor(BaseExtractor):
|
|||||||
|
|
||||||
Strategy:
|
Strategy:
|
||||||
1. Fetch page -> find first <iframe src="...">
|
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.
|
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".
|
4. Unpack P.A.C.K.E.R. and search var src="...m3u8".
|
||||||
5. Return final m3u8 with referer header.
|
5. Return final m3u8 with referer header.
|
||||||
@@ -33,56 +33,125 @@ class SportsonlineExtractor(BaseExtractor):
|
|||||||
"""
|
"""
|
||||||
Detect and extract packed eval blocks from HTML.
|
Detect and extract packed eval blocks from HTML.
|
||||||
"""
|
"""
|
||||||
# Find all eval(function...) blocks - more greedy to capture full packed code
|
raw_matches: list[str] = []
|
||||||
pattern = re.compile(r"eval\(function\(p,a,c,k,e,.*?\)\)(?:\s*;|\s*<)", re.DOTALL)
|
strict_eval_pattern = re.compile(r"eval\(function\(p,a,c,k,e,.*?\}\(.*?\)\)", re.DOTALL)
|
||||||
raw_matches = pattern.findall(html)
|
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 no matches with the strict pattern, try a more relaxed one
|
||||||
if not raw_matches:
|
if not raw_matches:
|
||||||
# Try to find eval(function and capture until we find the closing ))
|
raw_matches = relaxed_eval_pattern.findall(html)
|
||||||
pattern = re.compile(r"eval\(function\(p,a,c,k,e,[dr]\).*?\}\(.*?\)\)", re.DOTALL)
|
|
||||||
raw_matches = pattern.findall(html)
|
|
||||||
|
|
||||||
return raw_matches
|
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]:
|
async def extract(self, url: str, **kwargs) -> Dict[str, Any]:
|
||||||
"""Main extraction flow: fetch page, extract iframe, unpack and find m3u8."""
|
"""Main extraction flow: fetch page, extract iframe, unpack and find m3u8."""
|
||||||
try:
|
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
|
# Step 1: Fetch main page
|
||||||
logger.info(f"Fetching main page: {url}")
|
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
|
main_html = main_response.text
|
||||||
|
parsed_main = urlparse(main_response.url)
|
||||||
|
main_origin = f"{parsed_main.scheme}://{parsed_main.netloc}"
|
||||||
|
|
||||||
# Extract first iframe
|
# Extract first iframe (src can appear in any attribute order)
|
||||||
iframe_match = re.search(r'<iframe\s+src=["\']([^"\']+)["\']', main_html, re.IGNORECASE)
|
iframe_match = re.search(r'<iframe[^>]+(?<!data-)src=["\']([^"\']+)["\']', main_html, re.IGNORECASE)
|
||||||
if not iframe_match:
|
iframe_url = main_response.url
|
||||||
raise ExtractorError("No iframe found on the page")
|
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
|
# Step 2: Fetch iframe with source page as referer
|
||||||
if iframe_url.startswith("//"):
|
iframe_headers = {
|
||||||
iframe_url = "https:" + iframe_url
|
"Referer": main_response.url,
|
||||||
elif iframe_url.startswith("/"):
|
"Origin": main_origin,
|
||||||
parsed_main = urlparse(url)
|
"User-Agent": user_agent,
|
||||||
iframe_url = f"{parsed_main.scheme}://{parsed_main.netloc}{iframe_url}"
|
"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
|
parsed_iframe = urlparse(iframe_url)
|
||||||
iframe_headers = {
|
playback_headers = {
|
||||||
"Referer": "https://sportzonline.st/",
|
"Referer": iframe_url,
|
||||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36",
|
"Origin": f"{parsed_iframe.scheme}://{parsed_iframe.netloc}",
|
||||||
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
"User-Agent": user_agent,
|
||||||
"Accept-Language": "en-US,en;q=0.9,it;q=0.8",
|
|
||||||
"Cache-Control": "no-cache",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
iframe_response = await self._make_request(iframe_url, headers=iframe_headers, timeout=15)
|
|
||||||
iframe_html = iframe_response.text
|
|
||||||
|
|
||||||
logger.debug(f"Iframe HTML length: {len(iframe_html)}")
|
|
||||||
|
|
||||||
# Step 3: Detect packed blocks
|
# Step 3: Detect packed blocks
|
||||||
packed_blocks = self._detect_packed_blocks(iframe_html)
|
packed_blocks = self._detect_packed_blocks(iframe_html)
|
||||||
|
|
||||||
@@ -91,21 +160,19 @@ class SportsonlineExtractor(BaseExtractor):
|
|||||||
if not packed_blocks:
|
if not packed_blocks:
|
||||||
logger.warning("No packed blocks found, trying direct m3u8 search")
|
logger.warning("No packed blocks found, trying direct m3u8 search")
|
||||||
# Fallback: try 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:
|
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}")
|
logger.info(f"Found direct m3u8 URL: {m3u8_url}")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"destination_url": m3u8_url,
|
"destination_url": m3u8_url,
|
||||||
"request_headers": {"Referer": iframe_url, "User-Agent": iframe_headers["User-Agent"]},
|
"request_headers": playback_headers,
|
||||||
"mediaflow_endpoint": self.mediaflow_endpoint,
|
"mediaflow_endpoint": self.mediaflow_endpoint,
|
||||||
}
|
}
|
||||||
else:
|
else:
|
||||||
raise ExtractorError("No packed blocks or direct m3u8 URL found")
|
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)
|
# Choose block: if >=2 use second (index 1), else first (index 0)
|
||||||
chosen_idx = 1 if len(packed_blocks) > 1 else 0
|
chosen_idx = 1 if len(packed_blocks) > 1 else 0
|
||||||
m3u8_url = None
|
m3u8_url = None
|
||||||
@@ -123,22 +190,7 @@ class SportsonlineExtractor(BaseExtractor):
|
|||||||
|
|
||||||
# Search for var src="...m3u8" with multiple patterns
|
# Search for var src="...m3u8" with multiple patterns
|
||||||
if unpacked_code:
|
if unpacked_code:
|
||||||
# Try multiple patterns as in the TypeScript version
|
m3u8_url = self._extract_m3u8_candidate(unpacked_code)
|
||||||
patterns = [
|
|
||||||
r'var\s+src\s*=\s*["\']([^"\']+)["\']', # var src="..."
|
|
||||||
r'src\s*=\s*["\']([^"\']+\.m3u8[^"\']*)["\']', # src="...m3u8"
|
|
||||||
r'file\s*:\s*["\']([^"\']+\.m3u8[^"\']*)["\']', # file: "...m3u8"
|
|
||||||
r'["\']([^"\']*https?://[^"\']+\.m3u8[^"\']*)["\']', # any m3u8 URL
|
|
||||||
]
|
|
||||||
|
|
||||||
for pattern in patterns:
|
|
||||||
src_match = re.search(pattern, unpacked_code)
|
|
||||||
if src_match:
|
|
||||||
m3u8_url = src_match.group(1)
|
|
||||||
# Verify it looks like a valid m3u8 URL
|
|
||||||
if ".m3u8" in m3u8_url or "http" in m3u8_url:
|
|
||||||
break
|
|
||||||
m3u8_url = None
|
|
||||||
|
|
||||||
# If not found, try all other blocks
|
# If not found, try all other blocks
|
||||||
if not m3u8_url:
|
if not m3u8_url:
|
||||||
@@ -148,36 +200,30 @@ class SportsonlineExtractor(BaseExtractor):
|
|||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
unpacked_code = unpack(block)
|
unpacked_code = unpack(block)
|
||||||
# Use the same patterns as above
|
m3u8_url = self._extract_m3u8_candidate(unpacked_code)
|
||||||
for pattern in [
|
|
||||||
r'var\s+src\s*=\s*["\']([^"\']+)["\']',
|
|
||||||
r'src\s*=\s*["\']([^"\']+\.m3u8[^"\']*)["\']',
|
|
||||||
r'file\s*:\s*["\']([^"\']+\.m3u8[^"\']*)["\']',
|
|
||||||
r'["\']([^"\']*https?://[^"\']+\.m3u8[^"\']*)["\']',
|
|
||||||
]:
|
|
||||||
src_match = re.search(pattern, unpacked_code)
|
|
||||||
if src_match:
|
|
||||||
test_url = src_match.group(1)
|
|
||||||
if ".m3u8" in test_url or "http" in test_url:
|
|
||||||
m3u8_url = test_url
|
|
||||||
logger.info(f"Found m3u8 in block {i}")
|
|
||||||
break
|
|
||||||
|
|
||||||
if m3u8_url:
|
if m3u8_url:
|
||||||
|
logger.info(f"Found m3u8 in block {i}")
|
||||||
break
|
break
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(f"Failed to process block {i}: {e}")
|
logger.debug(f"Failed to process block {i}: {e}")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
if not m3u8_url:
|
||||||
|
fallback_candidate = self._extract_m3u8_candidate(iframe_html)
|
||||||
|
if fallback_candidate:
|
||||||
|
m3u8_url = fallback_candidate
|
||||||
|
|
||||||
if not m3u8_url:
|
if not m3u8_url:
|
||||||
raise ExtractorError("Could not extract m3u8 URL from packed code")
|
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}")
|
logger.info(f"Successfully extracted m3u8 URL: {m3u8_url}")
|
||||||
|
|
||||||
# Return stream configuration
|
# Return stream configuration
|
||||||
return {
|
return {
|
||||||
"destination_url": m3u8_url,
|
"destination_url": m3u8_url,
|
||||||
"request_headers": {"Referer": iframe_url, "User-Agent": iframe_headers["User-Agent"]},
|
"request_headers": playback_headers,
|
||||||
"mediaflow_endpoint": self.mediaflow_endpoint,
|
"mediaflow_endpoint": self.mediaflow_endpoint,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,12 +24,17 @@ class SupervideoExtractor(BaseExtractor):
|
|||||||
|
|
||||||
Uses curl_cffi with Chrome impersonation to bypass Cloudflare.
|
Uses curl_cffi with Chrome impersonation to bypass Cloudflare.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
patterns = [r'file:"(.*?)"']
|
patterns = [r'file:"(.*?)"']
|
||||||
|
proxy = self._get_proxy(url)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with AsyncSession() as session:
|
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:
|
if response.status_code != 200:
|
||||||
raise ExtractorError(f"HTTP {response.status_code} while fetching {url}")
|
raise ExtractorError(f"HTTP {response.status_code} while fetching {url}")
|
||||||
|
|||||||
@@ -1,22 +1,39 @@
|
|||||||
import re
|
import re
|
||||||
from typing import Dict
|
from typing import Dict, Any
|
||||||
from urllib.parse import urljoin
|
from urllib.parse import urljoin
|
||||||
|
|
||||||
|
from curl_cffi.requests import AsyncSession
|
||||||
|
|
||||||
from mediaflow_proxy.extractors.base import BaseExtractor, ExtractorError
|
from mediaflow_proxy.extractors.base import BaseExtractor, ExtractorError
|
||||||
|
|
||||||
|
|
||||||
class UqloadExtractor(BaseExtractor):
|
class UqloadExtractor(BaseExtractor):
|
||||||
"""Uqload URL extractor."""
|
"""Uqload URL extractor.
|
||||||
|
|
||||||
async def extract(self, url: str, **kwargs) -> Dict[str, str]:
|
Uses curl_cffi + Chrome impersonation to handle Cloudflare protection.
|
||||||
"""Extract Uqload URL."""
|
Follows redirects automatically (uqload.bz/co/io all redirect to uqload.is).
|
||||||
response = await self._make_request(url)
|
"""
|
||||||
|
|
||||||
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:
|
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 {
|
return {
|
||||||
"destination_url": video_url_match.group(1),
|
"destination_url": video_url_match.group(1),
|
||||||
"request_headers": self.base_headers,
|
"request_headers": self.base_headers,
|
||||||
|
|||||||
+244
-174
@@ -1,5 +1,9 @@
|
|||||||
import logging
|
import logging
|
||||||
|
import time
|
||||||
|
import re
|
||||||
|
import uuid
|
||||||
from typing import Any, Dict, Optional
|
from typing import Any, Dict, Optional
|
||||||
|
from urllib.parse import quote, urlparse
|
||||||
|
|
||||||
from mediaflow_proxy.extractors.base import BaseExtractor, ExtractorError
|
from mediaflow_proxy.extractors.base import BaseExtractor, ExtractorError
|
||||||
|
|
||||||
@@ -7,71 +11,29 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
class VavooExtractor(BaseExtractor):
|
class VavooExtractor(BaseExtractor):
|
||||||
"""Vavoo URL extractor for resolving vavoo.to links.
|
"""Vavoo URL extractor per risolvere link vavoo.to"""
|
||||||
|
|
||||||
Supports two URL formats:
|
API_UA = "okhttp/4.11.0"
|
||||||
1. Web-VOD API links: https://vavoo.to/web-vod/api/get?link=...
|
RESOLVE_UA = "MediaHubMX/2"
|
||||||
These redirect (302) to external video hosts (Doodstream, etc.)
|
TS_UA = "VAVOO/2.6"
|
||||||
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
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, request_headers: dict):
|
def __init__(self, request_headers: dict):
|
||||||
super().__init__(request_headers)
|
super().__init__(request_headers)
|
||||||
|
# Endpoint is resolved dynamically per-extraction based on the stream URL type.
|
||||||
self.mediaflow_endpoint = "proxy_stream_endpoint"
|
self.mediaflow_endpoint = "proxy_stream_endpoint"
|
||||||
|
|
||||||
async def _resolve_web_vod_link(self, url: str) -> str:
|
async def _get_auth_signature(self) -> Optional[str]:
|
||||||
"""Resolve a web-vod API link by getting the redirect Location header."""
|
"""Get authentication signature via lokke.app/api/app/ping (aligned with working plugin)."""
|
||||||
import aiohttp
|
unique_id = uuid.uuid4().hex[:16]
|
||||||
|
now_ms = int(time.time() * 1000)
|
||||||
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)."""
|
|
||||||
headers = {
|
headers = {
|
||||||
"user-agent": "okhttp/4.11.0",
|
"user-agent": self.API_UA,
|
||||||
"accept": "application/json",
|
"accept": "application/json",
|
||||||
"content-type": "application/json; charset=utf-8",
|
"content-type": "application/json; charset=utf-8",
|
||||||
"accept-encoding": "gzip",
|
"accept-encoding": "gzip",
|
||||||
}
|
}
|
||||||
import time
|
body = {
|
||||||
|
"token": "ldCvE092e7gER0rVIajfsXIvRhwlrAzP6_1oEJ4q6HH89QHt24v6NNL_jQJO219hiLOXF2hqEfsUuEWitEIGN4EaHHEHb7Cd7gojc5SQYRFzU3XWo_kMeryAUbcwWnQrnf0-",
|
||||||
current_time = int(time.time() * 1000)
|
|
||||||
|
|
||||||
data = {
|
|
||||||
"token": "",
|
|
||||||
"reason": "app-blur",
|
"reason": "app-blur",
|
||||||
"locale": "de",
|
"locale": "de",
|
||||||
"theme": "dark",
|
"theme": "dark",
|
||||||
@@ -79,174 +41,282 @@ class VavooExtractor(BaseExtractor):
|
|||||||
"device": {
|
"device": {
|
||||||
"type": "Handset",
|
"type": "Handset",
|
||||||
"brand": "google",
|
"brand": "google",
|
||||||
"model": "Pixel",
|
"model": "Nexus",
|
||||||
"name": "sdk_gphone64_arm64",
|
"name": "21081111RG",
|
||||||
"uniqueId": "d10e5d99ab665233",
|
"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,
|
"appFocusTime": 0,
|
||||||
"playerActive": False,
|
"playerActive": False,
|
||||||
"playDuration": 0,
|
"playDuration": 0,
|
||||||
"devMode": False,
|
"devMode": True,
|
||||||
"hasAddon": True,
|
"hasAddon": True,
|
||||||
"castConnected": False,
|
"castConnected": False,
|
||||||
"package": "tv.vavoo.app",
|
"package": "app.lokke.main",
|
||||||
"version": "3.1.21",
|
"version": "1.1.0",
|
||||||
"process": "app",
|
"process": "app",
|
||||||
"firstAppStart": current_time,
|
"firstAppStart": now_ms - 86400000,
|
||||||
"lastAppStart": current_time,
|
"lastAppStart": now_ms,
|
||||||
"ipLocation": "",
|
"ipLocation": None,
|
||||||
"adblockEnabled": True,
|
"adblockEnabled": False,
|
||||||
"proxy": {
|
"proxy": {
|
||||||
"supported": ["ss", "openvpn"],
|
"supported": ["ss", "openvpn"],
|
||||||
"engine": "ss",
|
"engine": "openvpn",
|
||||||
"ssVersion": 1,
|
"ssVersion": 1,
|
||||||
"enabled": True,
|
"enabled": False,
|
||||||
"autoServer": True,
|
"autoServer": True,
|
||||||
"id": "de-fra",
|
"id": "fi-hel",
|
||||||
},
|
},
|
||||||
"iap": {"supported": False},
|
"iap": {"supported": True},
|
||||||
}
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
resp = await self._make_request(
|
resp = await self._make_request(
|
||||||
"https://www.vavoo.tv/api/app/ping",
|
"https://www.lokke.app/api/app/ping",
|
||||||
method="POST",
|
method="POST",
|
||||||
json=data,
|
json=body,
|
||||||
headers=headers,
|
headers=headers,
|
||||||
timeout=10,
|
timeout=15,
|
||||||
retries=2,
|
retries=2,
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
result = resp.json()
|
result = resp.json()
|
||||||
except Exception:
|
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
|
return None
|
||||||
|
|
||||||
addon_sig = result.get("addonSig") if isinstance(result, dict) else None
|
addon_sig = result.get("addonSig") if isinstance(result, dict) else None
|
||||||
if addon_sig:
|
if addon_sig:
|
||||||
logger.info("Successfully obtained Vavoo authentication signature")
|
logger.info("Successfully obtained auth signature from lokke.app")
|
||||||
return addon_sig
|
return addon_sig
|
||||||
else:
|
logger.warning("No addonSig in lokke API response: %s", result)
|
||||||
logger.warning("No addonSig in Vavoo API response: %s", result)
|
return None
|
||||||
return None
|
except Exception as e:
|
||||||
except ExtractorError as e:
|
logger.debug("_get_auth_signature error: %s", e)
|
||||||
logger.warning("Failed to get Vavoo auth signature: %s", e)
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def extract(self, url: str, **kwargs) -> Dict[str, Any]:
|
async def _get_ts_signature(self) -> Optional[str]:
|
||||||
"""Extract Vavoo stream URL (async).
|
"""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:
|
async def _resolve_with_auth(self, url: str, signature: str) -> Optional[str]:
|
||||||
- Direct play URLs: https://vavoo.to/play/{id}/index.m3u8 (Live TV)
|
"""Resolve a Vavoo link using the MediaHubMX API with auth signature."""
|
||||||
- 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)."""
|
|
||||||
headers = {
|
headers = {
|
||||||
"user-agent": "okhttp/4.11.0",
|
"user-agent": self.RESOLVE_UA,
|
||||||
"accept": "application/json",
|
"accept": "application/json",
|
||||||
"content-type": "application/json; charset=utf-8",
|
"content-type": "application/json; charset=utf-8",
|
||||||
"accept-encoding": "gzip",
|
"accept-encoding": "gzip",
|
||||||
"mediahubmx-signature": signature,
|
"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:
|
try:
|
||||||
logger.info(f"Attempting to resolve Vavoo URL: {link}")
|
|
||||||
resp = await self._make_request(
|
resp = await self._make_request(
|
||||||
"https://vavoo.to/mediahubmx-resolve.json",
|
"https://vavoo.to/mediahubmx-resolve.json",
|
||||||
method="POST",
|
method="POST",
|
||||||
json=data,
|
json=payload,
|
||||||
headers=headers,
|
headers=headers,
|
||||||
timeout=12,
|
timeout=15,
|
||||||
retries=3,
|
retries=3,
|
||||||
backoff_factor=0.6,
|
backoff_factor=0.6,
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
result = resp.json()
|
result = resp.json()
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning(
|
logger.warning("Vavoo resolve returned non-json (status=%s)", resp.status)
|
||||||
"Vavoo resolve returned non-json response (status=%s). Body preview: %s",
|
|
||||||
resp.status,
|
|
||||||
getattr(resp, "text", "")[:500],
|
|
||||||
)
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
logger.debug("Vavoo API response: %s", result)
|
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"):
|
if isinstance(result, list) and result and isinstance(result[0], dict) and result[0].get("url"):
|
||||||
resolved_url = result[0]["url"]
|
return str(result[0]["url"])
|
||||||
logger.info("Successfully resolved Vavoo URL to: %s", resolved_url)
|
if isinstance(result, dict):
|
||||||
return resolved_url
|
if result.get("url"):
|
||||||
elif isinstance(result, dict) and result.get("url"):
|
return str(result["url"])
|
||||||
resolved_url = result["url"]
|
if isinstance(result.get("data"), dict) and result["data"].get("url"):
|
||||||
logger.info("Successfully resolved Vavoo URL to: %s", resolved_url)
|
return str(result["data"]["url"])
|
||||||
return resolved_url
|
logger.warning("No URL found in Vavoo API response: %s", result)
|
||||||
else:
|
return None
|
||||||
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
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Unexpected error while resolving Vavoo URL {link}: {e}")
|
logger.debug("_resolve_with_auth error: %s", e)
|
||||||
raise ExtractorError(f"Vavoo resolution failed: {str(e)}") from 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,
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -46,7 +46,17 @@ class VixCloudExtractor(BaseExtractor):
|
|||||||
iframe = soup.find("iframe").get("src")
|
iframe = soup.find("iframe").get("src")
|
||||||
response = await self._make_request(iframe, headers={"x-inertia": "true", "x-inertia-version": version})
|
response = await self._make_request(iframe, headers={"x-inertia": "true", "x-inertia-version": version})
|
||||||
elif "movie" in url or "tv" in url:
|
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:
|
if response.status != 200:
|
||||||
raise ExtractorError("Failed to extract URL components, Invalid Request")
|
raise ExtractorError("Failed to extract URL components, Invalid Request")
|
||||||
|
|||||||
+234
-14
@@ -2,12 +2,14 @@ import asyncio
|
|||||||
import base64
|
import base64
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
from typing import Optional
|
from typing import Optional, AsyncGenerator
|
||||||
from urllib.parse import urlparse, parse_qs
|
from urllib.parse import urlparse, parse_qs
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
|
from aiohttp import ClientTimeout
|
||||||
import tenacity
|
import tenacity
|
||||||
from fastapi import Request, Response, HTTPException
|
from fastapi import Request, Response, HTTPException
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
from starlette.background import BackgroundTask
|
from starlette.background import BackgroundTask
|
||||||
|
|
||||||
from .const import SUPPORTED_RESPONSE_HEADERS
|
from .const import SUPPORTED_RESPONSE_HEADERS
|
||||||
@@ -394,10 +396,9 @@ async def handle_stream_request(
|
|||||||
range_header = proxy_headers.request.get("range", "not set")
|
range_header = proxy_headers.request.get("range", "not set")
|
||||||
logger.info(f"[handle_stream] Starting upstream {method} request - range: {range_header}")
|
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)
|
# Check if the range header was auto-added by proxy (not from client)
|
||||||
# We detect this by checking if range equals exactly "bytes=0-" which indicates
|
# This is set in proxy.py when we add bytes=0- because client didn't send a range
|
||||||
# a proxy-added default range, not a client seeking request
|
auto_added_range = getattr(proxy_headers, "auto_added_range", False)
|
||||||
auto_added_range = proxy_headers.request.get("range") == "bytes=0-"
|
|
||||||
|
|
||||||
# Use the same HTTP method for upstream request (HEAD for HEAD, GET for GET)
|
# Use the same HTTP method for upstream request (HEAD for HEAD, GET for GET)
|
||||||
# This prevents unnecessary data download when client just wants headers
|
# 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:
|
# When client didn't send a Range header but upstream returns 206 Partial Content:
|
||||||
# - Convert status to 200 (full content, not partial)
|
# - Convert status to 200 (full content, not partial)
|
||||||
# - Remove content-range header to avoid confusing the client
|
# - 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
|
# This handles cases where we added bytes=0- range but upstream still treats it as a range request
|
||||||
status_code = streamer.response.status
|
status_code = streamer.response.status
|
||||||
if status_code == 206:
|
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]:
|
if "content-range" in [h.lower() for h in proxy_headers.remove]:
|
||||||
# Explicitly requested to remove content-range
|
# Explicitly requested to remove content-range
|
||||||
status_code = 200
|
status_code = 200
|
||||||
@@ -510,10 +518,21 @@ def prepare_response_headers(
|
|||||||
remove_set = set(h.lower() for h in (remove_headers or []))
|
remove_set = set(h.lower() for h in (remove_headers or []))
|
||||||
response_headers = {}
|
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
|
# Handle aiohttp CIMultiDictProxy
|
||||||
for k, v in original_headers.items():
|
for k, v in original_headers.items():
|
||||||
k_lower = k.lower()
|
k_lower = k.lower()
|
||||||
if k_lower in SUPPORTED_RESPONSE_HEADERS and k_lower not in remove_set:
|
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
|
response_headers[k_lower] = v
|
||||||
|
|
||||||
# Apply propagate headers first (for segments), then response headers (response takes precedence)
|
# 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)
|
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:
|
def _normalize_drm_key_value(value: str) -> str:
|
||||||
"""
|
"""
|
||||||
Normalize a DRM key_id or key value to lowercase hex.
|
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
|
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.
|
# 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
|
# 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(
|
async def get_segment(
|
||||||
segment_params: MPDSegmentParams,
|
segment_params: MPDSegmentParams,
|
||||||
proxy_headers: ProxyRequestHeaders,
|
proxy_headers: ProxyRequestHeaders,
|
||||||
@@ -861,32 +1035,78 @@ async def get_segment(
|
|||||||
segment_url = segment_params.segment_url
|
segment_url = segment_params.segment_url
|
||||||
should_remux = force_remux_ts if force_remux_ts is not None else settings.remux_to_ts
|
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)
|
# Check processed segment cache first (avoids re-decrypting/re-remuxing)
|
||||||
is_processed = bool(segment_params.key_id or should_remux)
|
is_processed = bool(segment_params.key_id or should_remux)
|
||||||
if is_processed:
|
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:
|
if processed_content:
|
||||||
logger.info(f"Serving processed segment from cache: {segment_url}")
|
logger.info(f"Serving processed segment from cache: {segment_url}")
|
||||||
mimetype = "video/mp2t" if should_remux else segment_params.mime_type
|
mimetype = "video/mp2t" if should_remux else segment_params.mime_type
|
||||||
response_headers = apply_header_manipulation({}, proxy_headers)
|
response_headers = apply_header_manipulation({}, proxy_headers)
|
||||||
return Response(content=processed_content, media_type=mimetype, headers=response_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
|
# Use event-based coordination for segment download
|
||||||
# get_or_download() handles:
|
# get_or_download() handles:
|
||||||
# - Cache check
|
# - Cache check
|
||||||
# - Waiting for existing downloads (via asyncio.Event)
|
# - Waiting for existing downloads (via asyncio.Event)
|
||||||
# - Starting new download if needed
|
# - Starting new download if needed
|
||||||
# - Caching the result
|
# - Caching the result
|
||||||
# Use a short timeout (1s) for player requests to avoid blocking if prebuffer is busy
|
# Player requests should get priority over background prebuffer activity.
|
||||||
# This ensures players get fast responses even when background prefetching is active
|
# Use a configurable lock timeout to balance responsiveness and cache reuse.
|
||||||
if settings.enable_dash_prebuffer:
|
elif settings.enable_dash_prebuffer:
|
||||||
segment_content = await dash_prebuffer.get_or_download(segment_url, proxy_headers.request, timeout=1.0)
|
segment_content = await dash_prebuffer.get_or_download(
|
||||||
|
segment_url, segment_headers, timeout=settings.dash_player_lock_timeout
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
# Prebuffer disabled - check cache then download directly
|
# Prebuffer disabled - check cache then download directly
|
||||||
segment_content = await get_cached_segment(segment_url)
|
segment_content = await get_cached_segment(segment_url)
|
||||||
if not segment_content:
|
if not segment_content:
|
||||||
try:
|
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)
|
# Cache for future requests (synchronous to ensure it's cached before returning)
|
||||||
if segment_content and segment_params.is_live:
|
if segment_content and segment_params.is_live:
|
||||||
# Use create_task for non-blocking cache write, but segment is already downloaded
|
# 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.
|
# Then fall back to a direct download if still not cached.
|
||||||
# This is critical for live streams where the prebuffer may be busy
|
# This is critical for live streams where the prebuffer may be busy
|
||||||
# downloading other segments/profiles.
|
# 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
|
# Final cache check - download may have completed during lock wait
|
||||||
segment_content = await get_cached_segment(segment_url)
|
segment_content = await get_cached_segment(segment_url)
|
||||||
if segment_content:
|
if segment_content:
|
||||||
@@ -910,7 +1130,7 @@ async def get_segment(
|
|||||||
else:
|
else:
|
||||||
logger.info(f"Prebuffer returned no content, falling back to direct download: {segment_url}")
|
logger.info(f"Prebuffer returned no content, falling back to direct download: {segment_url}")
|
||||||
try:
|
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
|
# Cache on success for future requests
|
||||||
if segment_content and segment_params.is_live:
|
if segment_content and segment_params.is_live:
|
||||||
asyncio.create_task(
|
asyncio.create_task(
|
||||||
|
|||||||
+28
-33
@@ -12,23 +12,17 @@ from starlette.staticfiles import StaticFiles
|
|||||||
|
|
||||||
from mediaflow_proxy.configs import settings
|
from mediaflow_proxy.configs import settings
|
||||||
from mediaflow_proxy.middleware import UIAccessControlMiddleware
|
from mediaflow_proxy.middleware import UIAccessControlMiddleware
|
||||||
from mediaflow_proxy.routes import (
|
from mediaflow_proxy.routes.proxy import proxy_router
|
||||||
proxy_router,
|
from mediaflow_proxy.routes.epg import epg_router
|
||||||
extractor_router,
|
from mediaflow_proxy.routes.extractor import extractor_router
|
||||||
speedtest_router,
|
from mediaflow_proxy.routes.speedtest import speedtest_router
|
||||||
playlist_builder_router,
|
from mediaflow_proxy.routes.playlist_builder import playlist_builder_router
|
||||||
xtream_root_router,
|
from mediaflow_proxy.routes.xtream import xtream_root_router
|
||||||
acestream_router,
|
|
||||||
telegram_router,
|
|
||||||
)
|
|
||||||
from mediaflow_proxy.schemas import GenerateUrlRequest, GenerateMultiUrlRequest, MultiUrlRequestItem
|
from mediaflow_proxy.schemas import GenerateUrlRequest, GenerateMultiUrlRequest, MultiUrlRequestItem
|
||||||
from mediaflow_proxy.utils.crypto_utils import EncryptionHandler, EncryptionMiddleware
|
from mediaflow_proxy.utils.crypto_utils import EncryptionHandler, EncryptionMiddleware
|
||||||
from mediaflow_proxy.utils import redis_utils
|
from mediaflow_proxy.utils import redis_utils
|
||||||
from mediaflow_proxy.utils.http_utils import encode_mediaflow_proxy_url
|
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.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")
|
logging.basicConfig(level=settings.log_level, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s")
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -61,30 +55,22 @@ async def lifespan(app: FastAPI):
|
|||||||
# use redis-cli KEYS "mfp:*" | xargs redis-cli DEL
|
# use redis-cli KEYS "mfp:*" | xargs redis-cli DEL
|
||||||
logger.info("Cache clearing note: Redis entries will expire via TTL")
|
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
|
yield
|
||||||
|
|
||||||
# Shutdown
|
# Shutdown
|
||||||
logger.info("Shutting down...")
|
logger.info("Shutting down...")
|
||||||
# Close acestream sessions
|
# Close acestream sessions
|
||||||
await acestream_manager.close()
|
if settings.enable_acestream:
|
||||||
logger.info("Acestream manager closed")
|
from mediaflow_proxy.utils.acestream import acestream_manager
|
||||||
|
|
||||||
|
await acestream_manager.close()
|
||||||
|
logger.info("Acestream manager closed")
|
||||||
# Close telegram session
|
# Close telegram session
|
||||||
await telegram_manager.close()
|
if settings.enable_telegram:
|
||||||
logger.info("Telegram manager closed")
|
from mediaflow_proxy.utils.telegram import telegram_manager
|
||||||
|
|
||||||
|
await telegram_manager.close()
|
||||||
|
logger.info("Telegram manager closed")
|
||||||
# Close Redis connections
|
# Close Redis connections
|
||||||
await redis_utils.close_redis()
|
await redis_utils.close_redis()
|
||||||
logger.info("Redis connections closed")
|
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(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(epg_router, prefix="/proxy", tags=["epg"], dependencies=[Depends(verify_api_key)])
|
||||||
app.include_router(telegram_router, prefix="/proxy", tags=["telegram"], 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(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(speedtest_router, prefix="/speedtest", tags=["speedtest"], dependencies=[Depends(verify_api_key)])
|
||||||
app.include_router(playlist_builder_router, prefix="/playlist", tags=["playlist"])
|
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():
|
def run():
|
||||||
|
import os
|
||||||
import uvicorn
|
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__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -1,18 +1,23 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import math
|
import math
|
||||||
|
import statistics
|
||||||
import time
|
import time
|
||||||
|
|
||||||
|
from aiohttp import ClientTimeout
|
||||||
from fastapi import Request, Response, HTTPException
|
from fastapi import Request, Response, HTTPException
|
||||||
|
|
||||||
from mediaflow_proxy.drm.decrypter import decrypt_segment, process_drm_init_segment
|
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.crypto_utils import encryption_handler
|
||||||
|
from mediaflow_proxy.utils.http_client import create_aiohttp_session
|
||||||
from mediaflow_proxy.utils.http_utils import (
|
from mediaflow_proxy.utils.http_utils import (
|
||||||
encode_mediaflow_proxy_url,
|
encode_mediaflow_proxy_url,
|
||||||
|
fetch_with_retry,
|
||||||
get_original_scheme,
|
get_original_scheme,
|
||||||
ProxyRequestHeaders,
|
ProxyRequestHeaders,
|
||||||
apply_header_manipulation,
|
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.dash_prebuffer import dash_prebuffer
|
||||||
from mediaflow_proxy.utils.cache_utils import get_cached_processed_init, set_cached_processed_init
|
from mediaflow_proxy.utils.cache_utils import get_cached_processed_init, set_cached_processed_init
|
||||||
from mediaflow_proxy.utils.m3u8_processor import SkipSegmentFilter
|
from mediaflow_proxy.utils.m3u8_processor import SkipSegmentFilter
|
||||||
@@ -30,6 +35,83 @@ def _resolve_ts_mode(request: Request) -> bool:
|
|||||||
return settings.remux_to_ts
|
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(
|
async def process_manifest(
|
||||||
request: Request,
|
request: Request,
|
||||||
mpd_dict: dict,
|
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)
|
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(
|
async def process_playlist(
|
||||||
request: Request,
|
request: Request,
|
||||||
mpd_dict: dict,
|
mpd_dict: dict,
|
||||||
@@ -102,6 +246,10 @@ async def process_playlist(
|
|||||||
if not matching_profiles:
|
if not matching_profiles:
|
||||||
raise HTTPException(status_code=404, detail="Profile not found")
|
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)
|
hls_content = build_hls_playlist(mpd_dict, matching_profiles, request, skip_segments, start_offset)
|
||||||
|
|
||||||
# Trigger prebuffering of upcoming segments for live streams
|
# 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())
|
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}
|
video_profiles = {k: v for k, v in video_profiles.items() if v[0].get("height", 0) >= max_height}
|
||||||
|
|
||||||
# Add audio streams
|
if not is_ts_mode and video_profiles:
|
||||||
for i, (profile, playlist_url) in enumerate(audio_profiles.values()):
|
# Sort by bandwidth descending; keep highest bandwidth per unique rep_id
|
||||||
is_default = "YES" if i == 0 else "NO" # Set the first audio track as default
|
sorted_by_bw = sorted(video_profiles.values(), key=lambda pv: pv[0].get("bandwidth", 0), reverse=True)
|
||||||
lang = profile.get("lang", "und")
|
seen_rep_ids = set()
|
||||||
bandwidth = profile.get("bandwidth", "128000")
|
deduped = []
|
||||||
name = f"Audio {lang} ({bandwidth})" if lang != "und" else f"Audio {i + 1} ({bandwidth})"
|
for profile, playlist_url in sorted_by_bw:
|
||||||
hls.append(
|
rep_id = profile.get("rep_id", profile["id"])
|
||||||
f'#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="audio",NAME="{name}",DEFAULT={is_default},AUTOSELECT=YES,LANGUAGE="{lang}",URI="{playlist_url}"'
|
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
|
# Build combined codecs string (video + audio) for EXT-X-STREAM-INF
|
||||||
# ExoPlayer requires CODECS to list all codecs when AUDIO group is referenced
|
# 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
|
# Add video streams
|
||||||
for profile, playlist_url in video_profiles.values():
|
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
|
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
|
# 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)
|
# (PAT/PMT/VPS/SPS/PPS are embedded in each segment).
|
||||||
# Use EXT-X-MAP for live streams, but only for fMP4 (not TS)
|
# Use EXT-X-MAP for:
|
||||||
use_map = is_live and not is_ts_mode
|
# - 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
|
# Select appropriate endpoint based on remux mode
|
||||||
if is_ts_mode:
|
if is_ts_mode:
|
||||||
@@ -462,8 +671,8 @@ def build_hls_playlist(
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
if is_live:
|
if is_live:
|
||||||
# TS mode uses deeper playlist for ExoPlayer buffering
|
extinf_values_for_depth = [s["extinf"] for s in segments if "extinf" in s]
|
||||||
depth = 20 if is_ts_mode else max(settings.mpd_live_playlist_depth, 1)
|
depth = _compute_live_playlist_depth(is_ts_mode, effective_start_offset, extinf_values_for_depth)
|
||||||
trimmed_segments = segments[-depth:]
|
trimmed_segments = segments[-depth:]
|
||||||
else:
|
else:
|
||||||
trimmed_segments = segments
|
trimmed_segments = segments
|
||||||
@@ -479,31 +688,13 @@ def build_hls_playlist(
|
|||||||
else:
|
else:
|
||||||
target_duration = math.ceil(max(extinf_values)) if extinf_values else 3
|
target_duration = math.ceil(max(extinf_values)) if extinf_values else 3
|
||||||
|
|
||||||
# Align HLS media sequence with MPD-provided numbering when available
|
if is_live:
|
||||||
if is_ts_mode and is_live:
|
sequence = _compute_live_media_sequence(first_segment, profile, trimmed_segments)
|
||||||
# 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
|
|
||||||
else:
|
else:
|
||||||
mpd_start_number = profile.get("segment_template_start_number")
|
mpd_start_number = profile.get("segment_template_start_number")
|
||||||
sequence = first_segment.get("number")
|
sequence = first_segment.get("number")
|
||||||
|
|
||||||
if sequence is None:
|
if sequence is None:
|
||||||
# Fallback to MPD template start number
|
sequence = mpd_start_number if mpd_start_number is not None else 1
|
||||||
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
|
|
||||||
|
|
||||||
hls.extend(
|
hls.extend(
|
||||||
[
|
[
|
||||||
@@ -543,6 +734,10 @@ def build_hls_playlist(
|
|||||||
if query_params.get("api_password"):
|
if query_params.get("api_password"):
|
||||||
init_query_params["api_password"] = query_params["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_map_url = encode_mediaflow_proxy_url(
|
||||||
init_proxy_url,
|
init_proxy_url,
|
||||||
query_params=init_query_params,
|
query_params=init_query_params,
|
||||||
@@ -594,6 +789,9 @@ def build_hls_playlist(
|
|||||||
# Segment may also have its own range (for SegmentBase)
|
# Segment may also have its own range (for SegmentBase)
|
||||||
if "initRange" in segment:
|
if "initRange" in segment:
|
||||||
segment_query_params["init_range"] = segment["initRange"]
|
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)
|
query_params.update(segment_query_params)
|
||||||
hls.append(
|
hls.append(
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -13,7 +13,6 @@ from typing import Protocol, runtime_checkable
|
|||||||
from urllib.parse import urlparse, unquote
|
from urllib.parse import urlparse, unquote
|
||||||
|
|
||||||
from mediaflow_proxy.utils.http_client import create_aiohttp_session
|
from mediaflow_proxy.utils.http_client import create_aiohttp_session
|
||||||
from mediaflow_proxy.utils.telegram import telegram_manager
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -133,6 +132,8 @@ class TelegramMediaSource:
|
|||||||
raw = f"file_id:{ref.file_id}"
|
raw = f"file_id:{ref.file_id}"
|
||||||
elif ref.chat_id is not None and ref.message_id is not None:
|
elif ref.chat_id is not None and ref.message_id is not None:
|
||||||
raw = f"chat:{ref.chat_id}:msg:{ref.message_id}"
|
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:
|
else:
|
||||||
return ""
|
return ""
|
||||||
return hashlib.sha256(raw.encode()).hexdigest()[:16]
|
return hashlib.sha256(raw.encode()).hexdigest()[:16]
|
||||||
@@ -142,6 +143,9 @@ class TelegramMediaSource:
|
|||||||
return self._filename_hint
|
return self._filename_hint
|
||||||
|
|
||||||
async def stream(self, offset: int = 0, limit: int | None = None) -> AsyncIterator[bytes]:
|
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
|
effective_limit = limit or self._file_size
|
||||||
if self._use_single_client:
|
if self._use_single_client:
|
||||||
async for chunk in telegram_manager.stream_media_single(
|
async for chunk in telegram_manager.stream_media_single(
|
||||||
|
|||||||
@@ -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__ = [
|
__all__ = [
|
||||||
"proxy_router",
|
"proxy_router",
|
||||||
"extractor_router",
|
"extractor_router",
|
||||||
@@ -15,3 +7,37 @@ __all__ = [
|
|||||||
"acestream_router",
|
"acestream_router",
|
||||||
"telegram_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}")
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -9,7 +9,8 @@ Provides endpoints for proxying acestream content:
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
from typing import Annotated
|
from functools import lru_cache
|
||||||
|
from typing import Annotated, TYPE_CHECKING
|
||||||
from urllib.parse import urlencode, urljoin, urlparse
|
from urllib.parse import urlencode, urljoin, urlparse
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
@@ -17,8 +18,6 @@ from fastapi import APIRouter, Query, Request, HTTPException, Response, Depends
|
|||||||
from starlette.background import BackgroundTask
|
from starlette.background import BackgroundTask
|
||||||
|
|
||||||
from mediaflow_proxy.configs import settings
|
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_client import create_aiohttp_session
|
||||||
from mediaflow_proxy.utils.http_utils import (
|
from mediaflow_proxy.utils.http_utils import (
|
||||||
get_original_scheme,
|
get_original_scheme,
|
||||||
@@ -34,6 +33,22 @@ from mediaflow_proxy.utils.hls_prebuffer import hls_prebuffer
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
acestream_router = APIRouter()
|
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):
|
class AcestreamM3U8Processor(M3U8Processor):
|
||||||
"""
|
"""
|
||||||
@@ -46,7 +61,7 @@ class AcestreamM3U8Processor(M3U8Processor):
|
|||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
request: Request,
|
request: Request,
|
||||||
session: AcestreamSession,
|
session: "AcestreamSession",
|
||||||
key_url: str = None,
|
key_url: str = None,
|
||||||
force_playlist_proxy: bool = True,
|
force_playlist_proxy: bool = True,
|
||||||
key_only_proxy: bool = False,
|
key_only_proxy: bool = False,
|
||||||
@@ -140,6 +155,7 @@ async def acestream_hls_manifest(
|
|||||||
"""
|
"""
|
||||||
if not settings.enable_acestream:
|
if not settings.enable_acestream:
|
||||||
raise HTTPException(status_code=503, detail="Acestream support is disabled")
|
raise HTTPException(status_code=503, detail="Acestream support is disabled")
|
||||||
|
acestream_manager = _get_acestream_manager()
|
||||||
|
|
||||||
if not infohash and not id:
|
if not infohash and not id:
|
||||||
raise HTTPException(status_code=400, detail="Either 'infohash' or 'id' parameter is required")
|
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:
|
if not settings.enable_acestream:
|
||||||
raise HTTPException(status_code=503, detail="Acestream support is disabled")
|
raise HTTPException(status_code=503, detail="Acestream support is disabled")
|
||||||
|
acestream_manager = _get_acestream_manager()
|
||||||
|
|
||||||
# Use id or infohash for session lookup
|
# Use id or infohash for session lookup
|
||||||
session_key = id or infohash
|
session_key = id or infohash
|
||||||
@@ -368,6 +385,7 @@ async def acestream_ts_stream(
|
|||||||
"""
|
"""
|
||||||
if not settings.enable_acestream:
|
if not settings.enable_acestream:
|
||||||
raise HTTPException(status_code=503, detail="Acestream support is disabled")
|
raise HTTPException(status_code=503, detail="Acestream support is disabled")
|
||||||
|
acestream_manager = _get_acestream_manager()
|
||||||
|
|
||||||
if not infohash and not id:
|
if not infohash and not id:
|
||||||
raise HTTPException(status_code=400, detail="Either 'infohash' or 'id' parameter is required")
|
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
|
# Use our custom PyAV pipeline with forced video re-encoding
|
||||||
# (live MPEG-TS sources often have corrupt H.264 bitstreams
|
# (live MPEG-TS sources often have corrupt H.264 bitstreams
|
||||||
# that browsers reject; re-encoding produces a clean stream).
|
# that browsers reject; re-encoding produces a clean stream).
|
||||||
|
stream_transcode_universal = _load_transcode_pipeline()
|
||||||
content = stream_transcode_universal(
|
content = stream_transcode_universal(
|
||||||
_acestream_ts_source(),
|
_acestream_ts_source(),
|
||||||
force_video_reencode=True,
|
force_video_reencode=True,
|
||||||
@@ -509,6 +528,7 @@ async def acestream_status(
|
|||||||
"""
|
"""
|
||||||
if not settings.enable_acestream:
|
if not settings.enable_acestream:
|
||||||
raise HTTPException(status_code=503, detail="Acestream support is disabled")
|
raise HTTPException(status_code=503, detail="Acestream support is disabled")
|
||||||
|
acestream_manager = _get_acestream_manager()
|
||||||
|
|
||||||
if infohash:
|
if infohash:
|
||||||
session = acestream_manager.get_session(infohash)
|
session = acestream_manager.get_session(infohash)
|
||||||
|
|||||||
@@ -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}")
|
||||||
@@ -179,9 +179,32 @@ async def _extract_url_impl(
|
|||||||
if "no_proxy" in request.query_params:
|
if "no_proxy" in request.query_params:
|
||||||
response["query_params"]["no_proxy"] = request.query_params.get("no_proxy")
|
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:
|
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(
|
stream_url = encode_mediaflow_proxy_url(
|
||||||
**response,
|
**encode_args,
|
||||||
response_headers=proxy_headers.response,
|
response_headers=proxy_headers.response,
|
||||||
)
|
)
|
||||||
return RedirectResponse(url=stream_url, status_code=302)
|
return RedirectResponse(url=stream_url, status_code=302)
|
||||||
|
|||||||
+34
-206
@@ -1,10 +1,9 @@
|
|||||||
import asyncio
|
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
|
from functools import lru_cache
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
from urllib.parse import quote, unquote
|
from urllib.parse import quote, unquote
|
||||||
|
|
||||||
import aiohttp
|
|
||||||
from fastapi import Request, Depends, APIRouter, Query, HTTPException, Response
|
from fastapi import Request, Depends, APIRouter, Query, HTTPException, Response
|
||||||
from fastapi.datastructures import QueryParams
|
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.base64_utils import process_potential_base64_url
|
||||||
from mediaflow_proxy.utils.extractor_helpers import (
|
from mediaflow_proxy.utils.extractor_helpers import (
|
||||||
check_and_extract_dlhd_stream,
|
|
||||||
check_and_extract_sportsonline_stream,
|
check_and_extract_sportsonline_stream,
|
||||||
)
|
)
|
||||||
from mediaflow_proxy.utils.hls_prebuffer import hls_prebuffer
|
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 (
|
from mediaflow_proxy.utils.http_utils import (
|
||||||
get_proxy_headers,
|
get_proxy_headers,
|
||||||
ProxyRequestHeaders,
|
ProxyRequestHeaders,
|
||||||
apply_header_manipulation,
|
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.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__)
|
logger = logging.getLogger(__name__)
|
||||||
proxy_router = APIRouter()
|
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:
|
def sanitize_url(url: str) -> str:
|
||||||
"""
|
"""
|
||||||
Sanitize URL to fix common encoding issues and handle base64 encoded URLs.
|
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
|
# Sanitize destination URL to fix common encoding issues
|
||||||
hls_params.destination = sanitize_url(hls_params.destination)
|
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
|
# Check if destination contains Sportsonline pattern and extract stream directly
|
||||||
sportsonline_result = await check_and_extract_sportsonline_stream(request, hls_params.destination, proxy_headers)
|
sportsonline_result = await check_and_extract_sportsonline_stream(request, hls_params.destination, proxy_headers)
|
||||||
if sportsonline_result:
|
if sportsonline_result:
|
||||||
@@ -231,124 +192,9 @@ async def hls_manifest_proxy(
|
|||||||
for header_name, header_value in extracted_headers.items():
|
for header_name, header_value in extracted_headers.items():
|
||||||
# Add header with h_ prefix to query params
|
# Add header with h_ prefix to query params
|
||||||
query_dict[f"h_{header_name}"] = header_value
|
query_dict[f"h_{header_name}"] = header_value
|
||||||
# Remove retry flag from subsequent requests
|
|
||||||
query_dict.pop("dlhd_retry", None)
|
|
||||||
# Update request query params
|
# Update request query params
|
||||||
request._query_params = QueryParams(query_dict)
|
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)
|
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)
|
response_headers = apply_header_manipulation(base_headers, proxy_headers)
|
||||||
return Response(content=segment_data, media_type=mime_type, headers=response_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
|
# get_or_download returned None (timeout or error) - fall through to direct fetch
|
||||||
logger.warning(f"[hls_segment_proxy] Prebuffer timeout, using direct streaming: {segment_url}")
|
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)
|
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:
|
if not settings.enable_transcode:
|
||||||
raise HTTPException(status_code=503, detail="Transcoding support is disabled")
|
raise HTTPException(status_code=503, detail="Transcoding support is disabled")
|
||||||
|
HTTPMediaSource, _, _, handle_transcode_hls_playlist, _ = _load_transcode_components()
|
||||||
destination = sanitize_url(destination)
|
destination = sanitize_url(destination)
|
||||||
source = HTTPMediaSource(url=destination, headers=dict(proxy_headers.request))
|
source = HTTPMediaSource(url=destination, headers=dict(proxy_headers.request))
|
||||||
await source.resolve_file_size()
|
await source.resolve_file_size()
|
||||||
@@ -540,6 +391,7 @@ async def transcode_hls_init(
|
|||||||
"""
|
"""
|
||||||
if not settings.enable_transcode:
|
if not settings.enable_transcode:
|
||||||
raise HTTPException(status_code=503, detail="Transcoding support is disabled")
|
raise HTTPException(status_code=503, detail="Transcoding support is disabled")
|
||||||
|
HTTPMediaSource, _, handle_transcode_hls_init, _, _ = _load_transcode_components()
|
||||||
destination = sanitize_url(destination)
|
destination = sanitize_url(destination)
|
||||||
source = HTTPMediaSource(url=destination, headers=dict(proxy_headers.request))
|
source = HTTPMediaSource(url=destination, headers=dict(proxy_headers.request))
|
||||||
await source.resolve_file_size()
|
await source.resolve_file_size()
|
||||||
@@ -572,6 +424,7 @@ async def transcode_hls_segment(
|
|||||||
"""
|
"""
|
||||||
if not settings.enable_transcode:
|
if not settings.enable_transcode:
|
||||||
raise HTTPException(status_code=503, detail="Transcoding support is disabled")
|
raise HTTPException(status_code=503, detail="Transcoding support is disabled")
|
||||||
|
HTTPMediaSource, _, _, _, handle_transcode_hls_segment = _load_transcode_components()
|
||||||
destination = sanitize_url(destination)
|
destination = sanitize_url(destination)
|
||||||
source = HTTPMediaSource(url=destination, headers=dict(proxy_headers.request))
|
source = HTTPMediaSource(url=destination, headers=dict(proxy_headers.request))
|
||||||
await source.resolve_file_size()
|
await source.resolve_file_size()
|
||||||
@@ -660,39 +513,11 @@ async def proxy_stream_endpoint(
|
|||||||
# Sanitize destination URL to fix common encoding issues
|
# Sanitize destination URL to fix common encoding issues
|
||||||
destination = sanitize_url(destination)
|
destination = sanitize_url(destination)
|
||||||
|
|
||||||
# Check if 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
|
# Handle transcode mode — transcode uses time-based seeking, not byte ranges
|
||||||
if transcode:
|
if transcode:
|
||||||
if not settings.enable_transcode:
|
if not settings.enable_transcode:
|
||||||
raise HTTPException(status_code=503, detail="Transcoding support is disabled")
|
raise HTTPException(status_code=503, detail="Transcoding support is disabled")
|
||||||
|
HTTPMediaSource, handle_transcode, _, _, _ = _load_transcode_components()
|
||||||
transcode_headers = dict(proxy_headers.request)
|
transcode_headers = dict(proxy_headers.request)
|
||||||
transcode_headers.pop("range", None)
|
transcode_headers.pop("range", None)
|
||||||
transcode_headers.pop("if-range", None)
|
transcode_headers.pop("if-range", None)
|
||||||
@@ -708,6 +533,9 @@ async def proxy_stream_endpoint(
|
|||||||
|
|
||||||
if "range" not in proxy_headers.request:
|
if "range" not in proxy_headers.request:
|
||||||
proxy_headers.request["range"] = "bytes=0-"
|
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 filename:
|
||||||
# If a filename is provided (not a segment), set it in the headers using RFC 6266 format
|
# If a filename is provided (not a segment), set it in the headers using RFC 6266 format
|
||||||
|
|||||||
@@ -12,37 +12,75 @@ import asyncio
|
|||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
import secrets
|
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 fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from telethon import TelegramClient
|
|
||||||
from telethon.sessions import StringSession
|
|
||||||
|
|
||||||
from mediaflow_proxy.configs import settings
|
from mediaflow_proxy.configs import settings
|
||||||
from mediaflow_proxy.remuxer.media_source import TelegramMediaSource
|
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 (
|
from mediaflow_proxy.utils.http_utils import (
|
||||||
EnhancedStreamingResponse,
|
EnhancedStreamingResponse,
|
||||||
ProxyRequestHeaders,
|
ProxyRequestHeaders,
|
||||||
apply_header_manipulation,
|
apply_header_manipulation,
|
||||||
get_proxy_headers,
|
get_proxy_headers,
|
||||||
)
|
)
|
||||||
from mediaflow_proxy.utils.telegram import (
|
|
||||||
TelegramMediaRef,
|
|
||||||
parse_telegram_url,
|
|
||||||
telegram_manager,
|
|
||||||
)
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
telegram_router = APIRouter()
|
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:
|
def get_content_type(mime_type: str, file_name: Optional[str] = None) -> str:
|
||||||
"""Determine content type from mime type or filename."""
|
"""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
|
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.head("/telegram/stream")
|
||||||
@telegram_router.get("/telegram/stream")
|
@telegram_router.get("/telegram/stream")
|
||||||
@telegram_router.head("/telegram/stream/{filename:path}")
|
@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"),
|
url: Optional[str] = Query(None, description="Alias for 'd' parameter"),
|
||||||
chat_id: Optional[str] = Query(None, description="Chat/Channel ID (use with message_id)"),
|
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)"),
|
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_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)"),
|
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)"),
|
transcode: bool = Query(False, description="Transcode to browser-compatible fMP4 (EAC3/AC3->AAC)"),
|
||||||
@@ -138,6 +258,7 @@ async def telegram_stream(
|
|||||||
Supports:
|
Supports:
|
||||||
- t.me links: https://t.me/channel/123, https://t.me/c/123456789/456
|
- 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 + 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)
|
- 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
|
When transcode=true, the media is remuxed to fragmented MP4 with
|
||||||
@@ -155,6 +276,7 @@ async def telegram_stream(
|
|||||||
url: Alias for 'd' parameter
|
url: Alias for 'd' parameter
|
||||||
chat_id: Chat/Channel ID (numeric or username)
|
chat_id: Chat/Channel ID (numeric or username)
|
||||||
message_id: Message ID within the chat
|
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_id: Bot API file_id (requires file_size parameter)
|
||||||
file_size: File size in bytes (required for file_id streaming)
|
file_size: File size in bytes (required for file_id streaming)
|
||||||
transcode: Transcode to browser-compatible format (EAC3/AC3->AAC)
|
transcode: Transcode to browser-compatible format (EAC3/AC3->AAC)
|
||||||
@@ -165,41 +287,28 @@ async def telegram_stream(
|
|||||||
"""
|
"""
|
||||||
if not settings.enable_telegram:
|
if not settings.enable_telegram:
|
||||||
raise HTTPException(status_code=503, detail="Telegram proxy support is disabled")
|
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
|
# Get the URL from either parameter
|
||||||
telegram_url = d or url
|
telegram_url = d or url
|
||||||
|
|
||||||
# Determine which input method was used
|
try:
|
||||||
if not telegram_url and not file_id and not (chat_id and message_id):
|
ref = _build_telegram_ref_from_params(
|
||||||
raise HTTPException(
|
TelegramMediaRef,
|
||||||
status_code=400,
|
parse_telegram_url,
|
||||||
detail="Provide either 'd' (t.me URL), 'chat_id' + 'message_id', or 'file_id' + 'file_size' parameters",
|
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)
|
# 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
|
actual_file_size = media_info.file_size
|
||||||
mime_type = media_info.mime_type
|
mime_type = media_info.mime_type
|
||||||
media_filename = filename or media_info.file_name
|
media_filename = filename or media_info.file_name
|
||||||
@@ -235,7 +344,7 @@ async def telegram_stream(
|
|||||||
"access-control-allow-origin": "*",
|
"access-control-allow-origin": "*",
|
||||||
}
|
}
|
||||||
if media_filename:
|
if media_filename:
|
||||||
headers["content-disposition"] = f'inline; filename="{media_filename}"'
|
headers["content-disposition"] = _content_disposition_inline(media_filename)
|
||||||
return Response(headers=headers)
|
return Response(headers=headers)
|
||||||
|
|
||||||
# Build response headers
|
# Build response headers
|
||||||
@@ -253,7 +362,7 @@ async def telegram_stream(
|
|||||||
base_headers["content-range"] = f"bytes {start}-{end}/{actual_file_size}"
|
base_headers["content-range"] = f"bytes {start}-{end}/{actual_file_size}"
|
||||||
|
|
||||||
if media_filename:
|
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)
|
response_headers = apply_header_manipulation(base_headers, proxy_headers)
|
||||||
|
|
||||||
@@ -326,6 +435,10 @@ async def telegram_stream(
|
|||||||
)
|
)
|
||||||
elif error_name == "MessageIdInvalidError":
|
elif error_name == "MessageIdInvalidError":
|
||||||
raise HTTPException(status_code=404, detail="Message not found in the specified chat.")
|
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":
|
elif error_name == "AuthKeyError":
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=401, detail="Telegram session is invalid. Please regenerate the session string."
|
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(
|
async def _handle_transcode(
|
||||||
request: Request,
|
request: Request,
|
||||||
ref: TelegramMediaRef,
|
ref: "TelegramMediaRef",
|
||||||
file_size: int,
|
file_size: int,
|
||||||
start_time: float | None = None,
|
start_time: float | None = None,
|
||||||
file_name: str = "",
|
file_name: str = "",
|
||||||
@@ -371,6 +484,7 @@ async def _handle_transcode(
|
|||||||
passes it to the source-agnostic transcode handler which handles
|
passes it to the source-agnostic transcode handler which handles
|
||||||
cue probing, seeking, and pipeline selection.
|
cue probing, seeking, and pipeline selection.
|
||||||
"""
|
"""
|
||||||
|
handle_transcode, _, _, _ = _load_transcode_handlers()
|
||||||
source = TelegramMediaSource(ref, file_size, file_name=file_name)
|
source = TelegramMediaSource(ref, file_size, file_name=file_name)
|
||||||
return await handle_transcode(request, source, start_time=start_time)
|
return await handle_transcode(request, source, start_time=start_time)
|
||||||
|
|
||||||
@@ -385,6 +499,7 @@ async def _resolve_telegram_source(
|
|||||||
url: str | None = None,
|
url: str | None = None,
|
||||||
chat_id: str | None = None,
|
chat_id: str | None = None,
|
||||||
message_id: int | None = None,
|
message_id: int | None = None,
|
||||||
|
document_id: int | None = None,
|
||||||
file_id: str | None = None,
|
file_id: str | None = None,
|
||||||
file_size: int | None = None,
|
file_size: int | None = None,
|
||||||
filename: str | None = None,
|
filename: str | None = None,
|
||||||
@@ -403,39 +518,31 @@ async def _resolve_telegram_source(
|
|||||||
DC connections per request is wasteful.
|
DC connections per request is wasteful.
|
||||||
"""
|
"""
|
||||||
if not settings.enable_telegram:
|
if not settings.enable_telegram:
|
||||||
from fastapi import HTTPException
|
|
||||||
|
|
||||||
raise HTTPException(status_code=503, detail="Telegram proxy support is disabled")
|
raise HTTPException(status_code=503, detail="Telegram proxy support is disabled")
|
||||||
|
TelegramMediaRef, parse_telegram_url, telegram_manager = _telegram_utils()
|
||||||
|
|
||||||
telegram_url = d or url
|
telegram_url = d or url
|
||||||
|
|
||||||
if not telegram_url and not file_id and not (chat_id and message_id):
|
ref = _build_telegram_ref_from_params(
|
||||||
from fastapi import HTTPException
|
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(
|
try:
|
||||||
status_code=400,
|
ref, media_info = await _resolve_media_info_with_file_id_fallback(
|
||||||
detail="Provide either 'd' (t.me URL), 'chat_id' + 'message_id', or 'file_id' + 'file_size'",
|
telegram_manager, TelegramMediaRef, ref, file_size
|
||||||
)
|
)
|
||||||
|
except Exception as e:
|
||||||
if telegram_url:
|
if type(e).__name__ in {"TelegramDocumentNotFoundError", "TelegramMessageNotFoundError"}:
|
||||||
ref = parse_telegram_url(telegram_url)
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
elif chat_id and message_id:
|
raise
|
||||||
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)
|
|
||||||
actual_file_size = media_info.file_size
|
actual_file_size = media_info.file_size
|
||||||
media_filename = filename or media_info.file_name
|
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'"),
|
url: Optional[str] = Query(None, description="Alias for 'd'"),
|
||||||
chat_id: Optional[str] = Query(None, description="Chat/Channel ID"),
|
chat_id: Optional[str] = Query(None, description="Chat/Channel ID"),
|
||||||
message_id: Optional[int] = Query(None, description="Message 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_id: Optional[str] = Query(None, description="Bot API file_id"),
|
||||||
file_size: Optional[int] = Query(None, description="File size in bytes"),
|
file_size: Optional[int] = Query(None, description="File size in bytes"),
|
||||||
filename: Optional[str] = Query(None, description="Optional filename"),
|
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."""
|
"""Generate an HLS VOD M3U8 playlist for a Telegram media file."""
|
||||||
if not settings.enable_transcode:
|
if not settings.enable_transcode:
|
||||||
raise HTTPException(status_code=503, detail="Transcoding support is disabled")
|
raise HTTPException(status_code=503, detail="Transcoding support is disabled")
|
||||||
|
_, _, handle_transcode_hls_playlist, _ = _load_transcode_handlers()
|
||||||
source = await _resolve_telegram_source(
|
source = await _resolve_telegram_source(
|
||||||
d,
|
d,
|
||||||
url,
|
url,
|
||||||
chat_id,
|
chat_id,
|
||||||
message_id,
|
message_id,
|
||||||
|
document_id,
|
||||||
file_id,
|
file_id,
|
||||||
file_size,
|
file_size,
|
||||||
filename,
|
filename,
|
||||||
@@ -497,6 +607,7 @@ async def telegram_transcode_hls_init(
|
|||||||
url: Optional[str] = Query(None, description="Alias for 'd'"),
|
url: Optional[str] = Query(None, description="Alias for 'd'"),
|
||||||
chat_id: Optional[str] = Query(None, description="Chat/Channel ID"),
|
chat_id: Optional[str] = Query(None, description="Chat/Channel ID"),
|
||||||
message_id: Optional[int] = Query(None, description="Message 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_id: Optional[str] = Query(None, description="Bot API file_id"),
|
||||||
file_size: Optional[int] = Query(None, description="File size in bytes"),
|
file_size: Optional[int] = Query(None, description="File size in bytes"),
|
||||||
filename: Optional[str] = Query(None, description="Optional filename"),
|
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."""
|
"""Serve the fMP4 init segment for a Telegram media file."""
|
||||||
if not settings.enable_transcode:
|
if not settings.enable_transcode:
|
||||||
raise HTTPException(status_code=503, detail="Transcoding support is disabled")
|
raise HTTPException(status_code=503, detail="Transcoding support is disabled")
|
||||||
|
_, handle_transcode_hls_init, _, _ = _load_transcode_handlers()
|
||||||
source = await _resolve_telegram_source(
|
source = await _resolve_telegram_source(
|
||||||
d,
|
d,
|
||||||
url,
|
url,
|
||||||
chat_id,
|
chat_id,
|
||||||
message_id,
|
message_id,
|
||||||
|
document_id,
|
||||||
file_id,
|
file_id,
|
||||||
file_size,
|
file_size,
|
||||||
filename,
|
filename,
|
||||||
@@ -527,6 +640,7 @@ async def telegram_transcode_hls_segment(
|
|||||||
url: Optional[str] = Query(None, description="Alias for 'd'"),
|
url: Optional[str] = Query(None, description="Alias for 'd'"),
|
||||||
chat_id: Optional[str] = Query(None, description="Chat/Channel ID"),
|
chat_id: Optional[str] = Query(None, description="Chat/Channel ID"),
|
||||||
message_id: Optional[int] = Query(None, description="Message 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_id: Optional[str] = Query(None, description="Bot API file_id"),
|
||||||
file_size: Optional[int] = Query(None, description="File size in bytes"),
|
file_size: Optional[int] = Query(None, description="File size in bytes"),
|
||||||
filename: Optional[str] = Query(None, description="Optional filename"),
|
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."""
|
"""Serve a single HLS fMP4 media segment for a Telegram media file."""
|
||||||
if not settings.enable_transcode:
|
if not settings.enable_transcode:
|
||||||
raise HTTPException(status_code=503, detail="Transcoding support is disabled")
|
raise HTTPException(status_code=503, detail="Transcoding support is disabled")
|
||||||
|
_, _, _, handle_transcode_hls_segment = _load_transcode_handlers()
|
||||||
source = await _resolve_telegram_source(
|
source = await _resolve_telegram_source(
|
||||||
d,
|
d,
|
||||||
url,
|
url,
|
||||||
chat_id,
|
chat_id,
|
||||||
message_id,
|
message_id,
|
||||||
|
document_id,
|
||||||
file_id,
|
file_id,
|
||||||
file_size,
|
file_size,
|
||||||
filename,
|
filename,
|
||||||
@@ -585,7 +701,18 @@ def _build_telegram_hls_resolved_params(
|
|||||||
|
|
||||||
# Carry over non-identifying params from the original request
|
# Carry over non-identifying params from the original request
|
||||||
# (api_password, filename, etc.)
|
# (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:
|
for key in request.query_params:
|
||||||
if key not in _skip_keys:
|
if key not in _skip_keys:
|
||||||
params[key] = request.query_params[key]
|
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:
|
if ref.chat_id is not None and ref.message_id is not None:
|
||||||
params["chat_id"] = str(ref.chat_id)
|
params["chat_id"] = str(ref.chat_id)
|
||||||
params["message_id"] = str(ref.message_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:
|
elif ref.file_id:
|
||||||
params["file_id"] = ref.file_id
|
params["file_id"] = ref.file_id
|
||||||
# Always include file_size -- it prevents unnecessary lookups
|
# 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"),
|
url: Optional[str] = Query(None, description="Alias for 'd' parameter"),
|
||||||
chat_id: Optional[str] = Query(None, description="Chat/Channel ID (use with message_id)"),
|
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)"),
|
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_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)"),
|
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
|
url: Alias for 'd' parameter
|
||||||
chat_id: Chat/Channel ID (numeric or username)
|
chat_id: Chat/Channel ID (numeric or username)
|
||||||
message_id: Message ID within the chat
|
message_id: Message ID within the chat
|
||||||
|
document_id: Telegram document ID within the chat
|
||||||
file_id: Bot API file_id
|
file_id: Bot API file_id
|
||||||
file_size: File size in bytes (optional, will be 0 if not provided for 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:
|
if not settings.enable_telegram:
|
||||||
raise HTTPException(status_code=503, detail="Telegram proxy support is disabled")
|
raise HTTPException(status_code=503, detail="Telegram proxy support is disabled")
|
||||||
|
TelegramMediaRef, parse_telegram_url, telegram_manager = _telegram_utils()
|
||||||
|
|
||||||
telegram_url = d or url
|
telegram_url = d or url
|
||||||
|
|
||||||
if not telegram_url and not file_id and not (chat_id and message_id):
|
try:
|
||||||
raise HTTPException(
|
ref = _build_telegram_ref_from_params(
|
||||||
status_code=400,
|
TelegramMediaRef,
|
||||||
detail="Provide either 'd' (t.me URL), 'chat_id' + 'message_id', or 'file_id' parameter",
|
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:
|
ref, media_info = await _resolve_media_info_with_file_id_fallback(
|
||||||
if telegram_url:
|
telegram_manager, TelegramMediaRef, ref, file_size
|
||||||
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)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"file_id": media_info.file_id,
|
"file_id": media_info.file_id,
|
||||||
@@ -673,6 +803,10 @@ async def telegram_info(
|
|||||||
)
|
)
|
||||||
elif error_name == "MessageIdInvalidError":
|
elif error_name == "MessageIdInvalidError":
|
||||||
raise HTTPException(status_code=404, detail="Message not found in the specified chat.")
|
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":
|
elif error_name == "FileReferenceExpiredError":
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=410,
|
status_code=410,
|
||||||
@@ -718,6 +852,7 @@ async def telegram_status():
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Check if client is connected
|
# Check if client is connected
|
||||||
|
_, _, telegram_manager = _telegram_utils()
|
||||||
if telegram_manager.is_initialized:
|
if telegram_manager.is_initialized:
|
||||||
return {
|
return {
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
@@ -783,6 +918,9 @@ async def session_start(request: SessionStartRequest):
|
|||||||
session_id = secrets.token_urlsafe(16)
|
session_id = secrets.token_urlsafe(16)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
from telethon import TelegramClient
|
||||||
|
from telethon.sessions import StringSession
|
||||||
|
|
||||||
client = TelegramClient(StringSession(), request.api_id, request.api_hash)
|
client = TelegramClient(StringSession(), request.api_id, request.api_hash)
|
||||||
await client.connect()
|
await client.connect()
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ Configuration:
|
|||||||
import base64
|
import base64
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
|
from functools import lru_cache
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
from urllib.parse import urljoin, urlencode, urlparse
|
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.configs import settings
|
||||||
from mediaflow_proxy.handlers import proxy_stream
|
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.base64_utils import decode_base64_url
|
||||||
from mediaflow_proxy.utils.http_utils import ProxyRequestHeaders, get_proxy_headers
|
from mediaflow_proxy.utils.http_utils import ProxyRequestHeaders, get_proxy_headers
|
||||||
from mediaflow_proxy.utils.http_client import create_aiohttp_session
|
from mediaflow_proxy.utils.http_client import create_aiohttp_session
|
||||||
@@ -46,10 +40,30 @@ logger = logging.getLogger(__name__)
|
|||||||
xtream_root_router = APIRouter()
|
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):
|
async def _handle_xtream_transcode(request, upstream_url: str, proxy_headers, start_time: float | None):
|
||||||
"""Shared transcode handler for Xtream stream endpoints."""
|
"""Shared transcode handler for Xtream stream endpoints."""
|
||||||
if not settings.enable_transcode:
|
if not settings.enable_transcode:
|
||||||
raise HTTPException(status_code=503, detail="Transcoding support is disabled")
|
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))
|
source = HTTPMediaSource(url=upstream_url, headers=dict(proxy_headers.request))
|
||||||
await source.resolve_file_size()
|
await source.resolve_file_size()
|
||||||
return await handle_transcode(request, source, start_time=start_time)
|
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."""
|
"""Generate HLS VOD playlist for an Xtream stream."""
|
||||||
if not settings.enable_transcode:
|
if not settings.enable_transcode:
|
||||||
raise HTTPException(status_code=503, detail="Transcoding support is disabled")
|
raise HTTPException(status_code=503, detail="Transcoding support is disabled")
|
||||||
|
HTTPMediaSource, _, _, handle_transcode_hls_playlist, _ = _load_transcode_components()
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
|
|
||||||
source = HTTPMediaSource(url=upstream_url, headers=dict(proxy_headers.request))
|
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."""
|
"""Serve fMP4 init segment for an Xtream stream."""
|
||||||
if not settings.enable_transcode:
|
if not settings.enable_transcode:
|
||||||
raise HTTPException(status_code=503, detail="Transcoding support is disabled")
|
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))
|
source = HTTPMediaSource(url=upstream_url, headers=dict(proxy_headers.request))
|
||||||
await source.resolve_file_size()
|
await source.resolve_file_size()
|
||||||
return await handle_transcode_hls_init(request, source)
|
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."""
|
"""Serve a single HLS fMP4 segment for an Xtream stream."""
|
||||||
if not settings.enable_transcode:
|
if not settings.enable_transcode:
|
||||||
raise HTTPException(status_code=503, detail="Transcoding support is disabled")
|
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))
|
source = HTTPMediaSource(url=upstream_url, headers=dict(proxy_headers.request))
|
||||||
await source.resolve_file_size()
|
await source.resolve_file_size()
|
||||||
return await handle_transcode_hls_segment(
|
return await handle_transcode_hls_segment(
|
||||||
|
|||||||
@@ -263,6 +263,10 @@ class MPDSegmentParams(GenericParams):
|
|||||||
False,
|
False,
|
||||||
description="Whether EXT-X-MAP is used (init sent separately). If true, don't concatenate init with segment.",
|
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):
|
class MPDInitParams(GenericParams):
|
||||||
@@ -280,6 +284,7 @@ class MPDInitParams(GenericParams):
|
|||||||
|
|
||||||
class ExtractorURLParams(GenericParams):
|
class ExtractorURLParams(GenericParams):
|
||||||
host: Literal[
|
host: Literal[
|
||||||
|
"City",
|
||||||
"Doodstream",
|
"Doodstream",
|
||||||
"FileLions",
|
"FileLions",
|
||||||
"FileMoon",
|
"FileMoon",
|
||||||
@@ -295,13 +300,14 @@ class ExtractorURLParams(GenericParams):
|
|||||||
"Maxstream",
|
"Maxstream",
|
||||||
"LiveTV",
|
"LiveTV",
|
||||||
"LuluStream",
|
"LuluStream",
|
||||||
"DLHD",
|
|
||||||
"Fastream",
|
"Fastream",
|
||||||
"TurboVidPlay",
|
"TurboVidPlay",
|
||||||
"Vidmoly",
|
"Vidmoly",
|
||||||
"Vidoza",
|
"Vidoza",
|
||||||
"Voe",
|
"Voe",
|
||||||
"Sportsonline",
|
"Sportsonline",
|
||||||
|
"Vavoo",
|
||||||
|
"VidFast",
|
||||||
] = Field(..., description="The host to extract the URL from.")
|
] = Field(..., description="The host to extract the URL from.")
|
||||||
destination: Annotated[str, Field(description="The URL of the stream.", alias="d")]
|
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.")
|
redirect_stream: bool = Field(False, description="Whether to redirect to the stream endpoint automatically.")
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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">
|
<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
|
<i class="fa-brands fa-telegram"></i> Telegram
|
||||||
</button>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Proxy URL Generator Tab -->
|
<!-- 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>
|
<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">
|
<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="">Select a host...</option>
|
||||||
|
<option value="City">City</option>
|
||||||
<option value="Doodstream">Doodstream</option>
|
<option value="Doodstream">Doodstream</option>
|
||||||
|
<option value="F16Px">F16Px</option>
|
||||||
|
<option value="Fastream">Fastream</option>
|
||||||
<option value="FileLions">FileLions</option>
|
<option value="FileLions">FileLions</option>
|
||||||
<option value="FileMoon">FileMoon</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="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="Streamtape">Streamtape</option>
|
||||||
<option value="StreamWish">StreamWish</option>
|
<option value="StreamWish">StreamWish</option>
|
||||||
<option value="Supervideo">Supervideo</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="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="Vidmoly">Vidmoly</option>
|
||||||
<option value="Vidoza">Vidoza</option>
|
<option value="Vidoza">Vidoza</option>
|
||||||
|
<option value="VixCloud">VixCloud</option>
|
||||||
<option value="Voe">Voe</option>
|
<option value="Voe">Voe</option>
|
||||||
<option value="Sportsonline">Sportsonline</option>
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1574,6 +1580,242 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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 & 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>
|
</main>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
@@ -2071,9 +2313,9 @@
|
|||||||
|
|
||||||
// Hosts that return HLS streams (auto-suggest .m3u8 extension)
|
// Hosts that return HLS streams (auto-suggest .m3u8 extension)
|
||||||
const HLS_EXTRACTOR_HOSTS = [
|
const HLS_EXTRACTOR_HOSTS = [
|
||||||
'TurboVidPlay', 'FileMoon', 'StreamWish', 'VixCloud',
|
'City', 'TurboVidPlay', 'FileMoon', 'StreamWish', 'VixCloud',
|
||||||
'LiveTV', 'LuluStream', 'DLHD', 'Fastream', 'Sportsonline',
|
'LiveTV', 'LuluStream', 'Fastream', 'Sportsonline',
|
||||||
'FileLions', 'Vidmoly', 'Voe'
|
'FileLions', 'Gupload', 'VidFast', 'Vidmoly', 'Voe'
|
||||||
];
|
];
|
||||||
|
|
||||||
// Handle extractor host selection change
|
// 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
|
// Check for hash on page load to switch to correct tab
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
document.getElementById('encoded-proxy-url').value = window.location.origin;
|
document.getElementById('encoded-proxy-url').value = window.location.origin;
|
||||||
|
document.getElementById('epg-proxy-url').value = window.location.origin;
|
||||||
|
|
||||||
// Check if URL has hash
|
// Check if URL has hash
|
||||||
if (window.location.hash === '#xc') {
|
if (window.location.hash === '#xc') {
|
||||||
@@ -3044,6 +3359,8 @@ TELEGRAM_SESSION_STRING=${sessionString}`;
|
|||||||
switchTab('acestream');
|
switchTab('acestream');
|
||||||
} else if (window.location.hash === '#telegram') {
|
} else if (window.location.hash === '#telegram') {
|
||||||
switchTab('telegram');
|
switchTab('telegram');
|
||||||
|
} else if (window.location.hash === '#epg') {
|
||||||
|
switchTab('epg');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user