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']*data-url="([^"]+)"[^>]*>Player\s*\d+', 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'