new version

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

View File

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

View File

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

View File

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