updated to newest version, dlhd support

This commit is contained in:
UrloMythus
2025-05-18 22:09:07 +02:00
parent 1bdfe198b5
commit 4b5891457e
9 changed files with 486 additions and 24 deletions

View File

@@ -56,9 +56,12 @@ class Settings(BaseSettings):
log_level: str = "INFO" # The logging level to use.
transport_config: TransportConfig = Field(default_factory=TransportConfig) # Configuration for httpx transport.
enable_streaming_progress: bool = False # Whether to enable streaming progress tracking.
disable_home_page: bool = False # Whether to disable the home page UI.
disable_docs: bool = False # Whether to disable the API documentation (Swagger UI).
disable_speedtest: bool = False # Whether to disable the speedtest UI.
user_agent: str = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.3" # The user agent to use for HTTP requests.
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36" # The user agent to use for HTTP requests.
)
class Config:

View File

@@ -0,0 +1,335 @@
import re
from typing import Dict, Any, Optional
from urllib.parse import urlparse, quote
from mediaflow_proxy.extractors.base import BaseExtractor, ExtractorError
class DLHDExtractor(BaseExtractor):
"""DLHD (DaddyLive) URL extractor for M3U8 streams."""
def __init__(self, request_headers: dict):
super().__init__(request_headers)
# Default to HLS proxy endpoint
self.mediaflow_endpoint = "hls_manifest_proxy"
async def extract(self, url: str, **kwargs) -> Dict[str, Any]:
"""Extract DLHD stream URL and required headers.
Args:
url: The DaddyLive channel URL (required)
Keyword Args:
player_url: Direct player URL (optional)
stream_url: The stream URL (optional)
auth_url_base: Base URL for auth requests (optional)
Returns:
Dict containing stream URL and required headers
"""
try:
# Channel URL is required and serves as the referer
channel_url = url
player_origin = self._get_origin(channel_url)
# Check for direct parameters
player_url = kwargs.get("player_url")
stream_url = kwargs.get("stream_url")
auth_url_base = kwargs.get("auth_url_base")
# If player URL not provided, extract it from channel page
if not player_url:
# Get the channel page to extract the player iframe URL
channel_headers = {
"referer": player_origin + "/",
"origin": player_origin,
"user-agent": self.base_headers["user-agent"]
}
channel_response = await self._make_request(channel_url, headers=channel_headers)
player_url = self._extract_player_url(channel_response.text)
if not player_url:
raise ExtractorError("Could not extract player URL from channel page")
# Check if this is a vecloud URL
if "vecloud" in player_url:
return await self._handle_vecloud(player_url, player_origin + "/")
# Get player page to extract authentication information
player_headers = {
"referer": player_origin + "/",
"origin": player_origin,
"user-agent": self.base_headers["user-agent"]
}
player_response = await self._make_request(player_url, headers=player_headers)
player_content = player_response.text
# Extract authentication details from script tag
auth_data = self._extract_auth_data(player_content)
if not auth_data:
raise ExtractorError("Failed to extract authentication data from player")
# Extract auth URL base if not provided
if not auth_url_base:
auth_url_base = self._extract_auth_url_base(player_content)
# If still no auth URL base, try to derive from stream URL or player URL
if not auth_url_base:
if stream_url:
auth_url_base = self._get_origin(stream_url)
else:
# Try to extract from player URL structure
player_domain = self._get_origin(player_url)
# Attempt to construct a standard auth domain
auth_url_base = self._derive_auth_url_base(player_domain)
if not auth_url_base:
raise ExtractorError("Could not determine auth URL base")
# Construct auth URL
auth_url = (f"{auth_url_base}/auth.php?channel_id={auth_data['channel_key']}"
f"&ts={auth_data['auth_ts']}&rnd={auth_data['auth_rnd']}"
f"&sig={quote(auth_data['auth_sig'])}")
# Make auth request
player_origin = self._get_origin(player_url)
auth_headers = {
"referer": player_origin + "/",
"origin": player_origin,
"user-agent": self.base_headers["user-agent"]
}
auth_response = await self._make_request(auth_url, headers=auth_headers)
# Check if authentication succeeded
if auth_response.json().get("status") != "ok":
raise ExtractorError("Authentication failed")
# If no stream URL provided, look up the server and generate the stream URL
if not stream_url:
stream_url = await self._lookup_server(
lookup_url_base=player_origin,
auth_url_base=auth_url_base,
auth_data=auth_data,
headers=auth_headers
)
# Set up the final stream headers
stream_headers = {
"referer": player_url,
"origin": player_origin,
"user-agent": self.base_headers["user-agent"]
}
# Return the stream URL with headers
return {
"destination_url": stream_url,
"request_headers": stream_headers,
"mediaflow_endpoint": self.mediaflow_endpoint,
}
except Exception as e:
raise ExtractorError(f"Extraction failed: {str(e)}")
async def _handle_vecloud(self, player_url: str, channel_referer: str) -> Dict[str, Any]:
"""Handle vecloud URLs with their specific API.
Args:
player_url: The vecloud player URL
channel_referer: The referer of the channel page
Returns:
Dict containing stream URL and required headers
"""
try:
# Extract stream ID from vecloud URL
stream_id_match = re.search(r'/stream/([a-zA-Z0-9-]+)', player_url)
if not stream_id_match:
raise ExtractorError("Could not extract stream ID from vecloud URL")
stream_id = stream_id_match.group(1)
# Construct API URL
player_parsed = urlparse(player_url)
player_domain = player_parsed.netloc
player_origin = f"{player_parsed.scheme}://{player_parsed.netloc}"
api_url = f"{player_origin}/api/source/{stream_id}?type=live"
# Set up headers for API request
api_headers = {
"referer": player_url,
"origin": player_origin,
"user-agent": self.base_headers["user-agent"],
"content-type": "application/json"
}
api_data = {
"r": channel_referer,
"d": player_domain
}
# Make API request
api_response = await self._make_request(api_url, method="POST", headers=api_headers, json=api_data)
api_data = api_response.json()
# Check if request was successful
if not api_data.get("success"):
raise ExtractorError("Vecloud API request failed")
# Extract stream URL from response
stream_url = api_data.get("player", {}).get("source_file")
if not stream_url:
raise ExtractorError("Could not find stream URL in vecloud response")
# Set up stream headers
stream_headers = {
"referer": player_origin + "/",
"origin": player_origin,
"user-agent": self.base_headers["user-agent"]
}
# Return the stream URL with headers
return {
"destination_url": stream_url,
"request_headers": stream_headers,
"mediaflow_endpoint": self.mediaflow_endpoint,
}
except Exception as e:
raise ExtractorError(f"Vecloud extraction failed: {str(e)}")
def _extract_player_url(self, html_content: str) -> Optional[str]:
"""Extract player iframe URL from channel page HTML."""
try:
# Look for iframe with allowfullscreen attribute
iframe_match = re.search(
r'<iframe[^>]*src=["\']([^"\']+)["\'][^>]*allowfullscreen',
html_content,
re.IGNORECASE
)
if not iframe_match:
# Try alternative pattern without requiring allowfullscreen
iframe_match = re.search(
r'<iframe[^>]*src=["\']([^"\']+(?:premiumtv|daddylivehd|vecloud)[^"\']*)["\']',
html_content,
re.IGNORECASE
)
if iframe_match:
return iframe_match.group(1).strip()
return None
except Exception:
return None
async def _lookup_server(self, lookup_url_base: str, auth_url_base: str, auth_data: Dict[str, str], headers: Dict[str, str]) -> str:
"""Lookup server information and generate stream URL."""
try:
# Construct server lookup URL
server_lookup_url = f"{lookup_url_base}/server_lookup.php?channel_id={quote(auth_data['channel_key'])}"
# Make server lookup request
server_response = await self._make_request(
server_lookup_url,
headers=headers
)
server_data = server_response.json()
server_key = server_data.get("server_key")
if not server_key:
raise ExtractorError("Failed to get server key")
# Extract domain parts from auth URL for constructing stream URL
auth_domain_parts = urlparse(auth_url_base).netloc.split('.')
domain_suffix = '.'.join(auth_domain_parts[1:]) if len(auth_domain_parts) > 1 else auth_domain_parts[0]
# Generate the m3u8 URL based on server response pattern
if '/' in server_key:
# Handle special case like "top1/cdn"
parts = server_key.split('/')
return f"https://{parts[0]}.{domain_suffix}/{server_key}/{auth_data['channel_key']}/mono.m3u8"
else:
# Handle normal case
return f"https://{server_key}new.{domain_suffix}/{server_key}/{auth_data['channel_key']}/mono.m3u8"
except Exception as e:
raise ExtractorError(f"Server lookup failed: {str(e)}")
def _extract_auth_data(self, html_content: str) -> Dict[str, str]:
"""Extract authentication data from player page."""
try:
# Extract channel key
channel_key_match = re.search(r'var\s+channelKey\s*=\s*["\']([^"\']+)["\']', html_content)
# Extract auth timestamp
auth_ts_match = re.search(r'var\s+authTs\s*=\s*["\']([^"\']+)["\']', html_content)
# Extract auth random value
auth_rnd_match = re.search(r'var\s+authRnd\s*=\s*["\']([^"\']+)["\']', html_content)
# Extract auth signature
auth_sig_match = re.search(r'var\s+authSig\s*=\s*["\']([^"\']+)["\']', html_content)
if not all([channel_key_match, auth_ts_match, auth_rnd_match, auth_sig_match]):
return {}
return {
"channel_key": channel_key_match.group(1),
"auth_ts": auth_ts_match.group(1),
"auth_rnd": auth_rnd_match.group(1),
"auth_sig": auth_sig_match.group(1)
}
except Exception:
return {}
def _extract_auth_url_base(self, html_content: str) -> Optional[str]:
"""Extract auth URL base from player page script content."""
try:
# Look for auth URL or domain in fetchWithRetry call or similar patterns
auth_url_match = re.search(
r'fetchWithRetry\([\'"]([^\'"]*/auth\.php)',
html_content
)
if auth_url_match:
auth_url = auth_url_match.group(1)
# Extract base URL up to the auth.php part
return auth_url.split('/auth.php')[0]
# Try finding domain directly
domain_match = re.search(
r'[\'"]https://([^/\'\"]+)(?:/[^\'\"]*)?/auth\.php',
html_content
)
if domain_match:
return f"https://{domain_match.group(1)}"
return None
except Exception:
return None
def _get_origin(self, url: str) -> str:
"""Extract origin from URL."""
parsed = urlparse(url)
return f"{parsed.scheme}://{parsed.netloc}"
def _derive_auth_url_base(self, player_domain: str) -> Optional[str]:
"""Attempt to derive auth URL base from player domain."""
try:
# Typical pattern is to use a subdomain for auth domain
parsed = urlparse(player_domain)
domain_parts = parsed.netloc.split('.')
# Get the top-level domain and second-level domain
if len(domain_parts) >= 2:
base_domain = '.'.join(domain_parts[-2:])
# Try common subdomains for auth
for prefix in ['auth', 'api', 'cdn']:
potential_auth_domain = f"https://{prefix}.{base_domain}"
return potential_auth_domain
return None
except Exception:
return None

View File

@@ -1,6 +1,7 @@
from typing import Dict, Type
from mediaflow_proxy.extractors.base import BaseExtractor, ExtractorError
from mediaflow_proxy.extractors.dlhd import DLHDExtractor
from mediaflow_proxy.extractors.doodstream import DoodStreamExtractor
from mediaflow_proxy.extractors.livetv import LiveTVExtractor
from mediaflow_proxy.extractors.maxstream import MaxstreamExtractor
@@ -25,6 +26,7 @@ class ExtractorFactory:
"Okru": OkruExtractor,
"Maxstream": MaxstreamExtractor,
"LiveTV": LiveTVExtractor,
"DLHD": DLHDExtractor,
}
@classmethod

View File

@@ -76,10 +76,10 @@ async def handle_hls_stream_proxy(
Returns:
Union[Response, EnhancedStreamingResponse]: Either a processed m3u8 playlist or a streaming response.
"""
client, streamer = await setup_client_and_streamer()
_, streamer = await setup_client_and_streamer()
# Handle range requests
content_range = proxy_headers.request.get("range", "bytes=0-")
if "NaN" in content_range:
if "nan" in content_range.casefold():
# Handle invalid range requests "bytes=NaN-NaN"
raise HTTPException(status_code=416, detail="Invalid Range Header")
proxy_headers.request.update({"range": content_range})
@@ -213,9 +213,9 @@ async def fetch_and_process_m3u8(
# Initialize processor and response headers
processor = M3U8Processor(request, key_url)
response_headers = {
"Content-Disposition": "inline",
"Accept-Ranges": "none",
"Content-Type": "application/vnd.apple.mpegurl",
"content-disposition": "inline",
"accept-ranges": "none",
"content-type": "application/vnd.apple.mpegurl",
}
response_headers.update(proxy_headers.response)

View File

@@ -9,6 +9,7 @@ from starlette.responses import RedirectResponse
from starlette.staticfiles import StaticFiles
from mediaflow_proxy.configs import settings
from mediaflow_proxy.middleware import UIAccessControlMiddleware
from mediaflow_proxy.routes import proxy_router, extractor_router, speedtest_router
from mediaflow_proxy.schemas import GenerateUrlRequest, GenerateMultiUrlRequest, MultiUrlRequestItem
from mediaflow_proxy.utils.crypto_utils import EncryptionHandler, EncryptionMiddleware
@@ -26,6 +27,7 @@ app.add_middleware(
allow_headers=["*"],
)
app.add_middleware(EncryptionMiddleware)
app.add_middleware(UIAccessControlMiddleware)
async def verify_api_key(api_key: str = Security(api_password_query), api_key_alt: str = Security(api_password_header)):

View File

@@ -0,0 +1,26 @@
from fastapi import Request, Response
from starlette.middleware.base import BaseHTTPMiddleware
from mediaflow_proxy.configs import settings
class UIAccessControlMiddleware(BaseHTTPMiddleware):
"""Middleware that controls access to UI components based on settings."""
async def dispatch(self, request: Request, call_next):
path = request.url.path
# Block access to home page
if settings.disable_home_page and (path == "/" or path == "/index.html"):
return Response(status_code=403, content="Forbidden")
# Block access to API docs
if settings.disable_docs and (path == "/docs" or path == "/redoc" or path.startswith("/openapi")):
return Response(status_code=403, content="Forbidden")
# Block access to speedtest UI
if settings.disable_speedtest and path.startswith("/speedtest"):
return Response(status_code=403, content="Forbidden")
return await call_next(request)

View File

@@ -1,6 +1,7 @@
import json
from typing import Literal, Dict, Any, Optional
from pydantic import BaseModel, Field, IPvAnyAddress, ConfigDict
from pydantic import BaseModel, Field, IPvAnyAddress, ConfigDict, field_validator
class GenerateUrlRequest(BaseModel):
@@ -88,7 +89,7 @@ class MPDSegmentParams(GenericParams):
class ExtractorURLParams(GenericParams):
host: Literal[
"Doodstream", "Mixdrop", "Uqload", "Streamtape", "Supervideo", "VixCloud", "Okru", "Maxstream", "LiveTV"
"Doodstream", "Mixdrop", "Uqload", "Streamtape", "Supervideo", "VixCloud", "Okru", "Maxstream", "LiveTV", "DLHD"
] = Field(..., description="The host to extract the URL from.")
destination: str = Field(..., description="The URL of the stream.", alias="d")
redirect_stream: bool = Field(False, description="Whether to redirect to the stream endpoint automatically.")
@@ -96,3 +97,10 @@ class ExtractorURLParams(GenericParams):
default_factory=dict,
description="Additional parameters required for specific extractors (e.g., stream_title for LiveTV)",
)
@field_validator("extra_params", mode="before")
def validate_extra_params(cls, value: Any):
if isinstance(value, str):
return json.loads(value)
return value

View File

@@ -6,6 +6,7 @@ from urllib import parse
from urllib.parse import urlencode
import anyio
import h11
import httpx
import tenacity
from fastapi import Response
@@ -168,6 +169,20 @@ class Streamer:
except httpx.TimeoutException:
logger.warning("Timeout while streaming")
raise DownloadError(409, "Timeout while streaming")
except httpx.RemoteProtocolError as e:
# Special handling for connection closed errors
if "peer closed connection without sending complete message body" in str(e):
logger.warning(f"Remote server closed connection prematurely: {e}")
# If we've received some data, just log the warning and return normally
if self.bytes_transferred > 0:
logger.info(f"Partial content received ({self.bytes_transferred} bytes). Continuing with available data.")
return
else:
# If we haven't received any data, raise an error
raise DownloadError(502, f"Remote server closed connection without sending any data: {e}")
else:
logger.error(f"Protocol error while streaming: {e}")
raise DownloadError(502, f"Protocol error while streaming: {e}")
except GeneratorExit:
logger.info("Streaming session stopped by the user")
except Exception as e:
@@ -432,6 +447,7 @@ class EnhancedStreamingResponse(Response):
self.media_type = self.media_type if media_type is None else media_type
self.background = background
self.init_headers(headers)
self.actual_content_length = 0
@staticmethod
async def listen_for_disconnect(receive: Receive) -> None:
@@ -446,41 +462,109 @@ class EnhancedStreamingResponse(Response):
async def stream_response(self, send: Send) -> None:
try:
# Initialize headers
headers = list(self.raw_headers)
# Set the transfer-encoding to chunked for streamed responses with content-length
# when content-length is present. This ensures we don't hit protocol errors
# if the upstream connection is closed prematurely.
for i, (name, _) in enumerate(headers):
if name.lower() == b"content-length":
# Replace content-length with transfer-encoding: chunked for streaming
headers[i] = (b"transfer-encoding", b"chunked")
headers = [h for h in headers if h[0].lower() != b"content-length"]
logger.debug("Switched from content-length to chunked transfer-encoding for streaming")
break
# Start the response
await send(
{
"type": "http.response.start",
"status": self.status_code,
"headers": self.raw_headers,
"headers": headers,
}
)
async for chunk in self.body_iterator:
if not isinstance(chunk, (bytes, memoryview)):
chunk = chunk.encode(self.charset)
try:
await send({"type": "http.response.body", "body": chunk, "more_body": True})
except (ConnectionResetError, anyio.BrokenResourceError):
logger.info("Client disconnected during streaming")
return
await send({"type": "http.response.body", "body": b"", "more_body": False})
# Track if we've sent any data
data_sent = False
try:
async for chunk in self.body_iterator:
if not isinstance(chunk, (bytes, memoryview)):
chunk = chunk.encode(self.charset)
try:
await send({"type": "http.response.body", "body": chunk, "more_body": True})
data_sent = True
self.actual_content_length += len(chunk)
except (ConnectionResetError, anyio.BrokenResourceError):
logger.info("Client disconnected during streaming")
return
# Successfully streamed all content
await send({"type": "http.response.body", "body": b"", "more_body": False})
except (httpx.RemoteProtocolError, h11._util.LocalProtocolError) as e:
# Handle connection closed errors
if data_sent:
# We've sent some data to the client, so try to complete the response
logger.warning(f"Remote protocol error after partial streaming: {e}")
try:
await send({"type": "http.response.body", "body": b"", "more_body": False})
logger.info(f"Response finalized after partial content ({self.actual_content_length} bytes transferred)")
except Exception as close_err:
logger.warning(f"Could not finalize response after remote error: {close_err}")
else:
# No data was sent, re-raise the error
logger.error(f"Protocol error before any data was streamed: {e}")
raise
except Exception as e:
logger.exception(f"Error in stream_response: {str(e)}")
if not isinstance(e, (ConnectionResetError, anyio.BrokenResourceError)):
try:
# Try to send an error response if client is still connected
await send(
{
"type": "http.response.start",
"status": 502,
"headers": [(b"content-type", b"text/plain")],
}
)
error_message = f"Streaming error: {str(e)}".encode("utf-8")
await send({"type": "http.response.body", "body": error_message, "more_body": False})
except Exception:
# If we can't send an error response, just log it
pass
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
async with anyio.create_task_group() as task_group:
streaming_completed = False
stream_func = partial(self.stream_response, send)
listen_func = partial(self.listen_for_disconnect, receive)
async def wrap(func: typing.Callable[[], typing.Awaitable[None]]) -> None:
try:
await func()
# If this is the stream_response function and it completes successfully, mark as done
if func == stream_func:
nonlocal streaming_completed
streaming_completed = True
except Exception as e:
if not isinstance(e, anyio.get_cancelled_exc_class()):
if isinstance(e, (httpx.RemoteProtocolError, h11._util.LocalProtocolError)):
# Handle protocol errors more gracefully
logger.warning(f"Protocol error during streaming: {e}")
elif not isinstance(e, anyio.get_cancelled_exc_class()):
logger.exception("Error in streaming task")
raise
# Only re-raise if it's not a protocol error or cancellation
raise
finally:
task_group.cancel_scope.cancel()
# Only cancel the task group if we're in disconnect listener or
# if streaming_completed is True (meaning we finished normally)
if func == listen_func or streaming_completed:
task_group.cancel_scope.cancel()
task_group.start_soon(wrap, partial(self.stream_response, send))
await wrap(partial(self.listen_for_disconnect, receive))
# Start the streaming response in a separate task
task_group.start_soon(wrap, stream_func)
# Listen for disconnect events
await wrap(listen_func)
if self.background is not None:
await self.background()

View File

@@ -143,11 +143,13 @@ class M3U8Processor:
full_url = parse.urljoin(base_url, url)
query_params = dict(self.request.query_params)
has_encrypted = query_params.pop("has_encrypted", False)
# Remove the response headers from the query params to avoid it being added to the consecutive requests
[query_params.pop(key, None) for key in list(query_params.keys()) if key.startswith("r_")]
return encode_mediaflow_proxy_url(
self.mediaflow_proxy_url,
"",
full_url,
query_params=dict(self.request.query_params),
query_params=query_params,
encryption_handler=encryption_handler if has_encrypted else None,
)