mirror of
https://github.com/UrloMythus/UnHided.git
synced 2026-04-09 02:40:47 +00:00
1048 lines
46 KiB
Python
1048 lines
46 KiB
Python
import asyncio
|
|
import base64
|
|
import logging
|
|
import time
|
|
from typing import Optional
|
|
from urllib.parse import urlparse, parse_qs
|
|
|
|
import aiohttp
|
|
import tenacity
|
|
from fastapi import Request, Response, HTTPException
|
|
from starlette.background import BackgroundTask
|
|
|
|
from .const import SUPPORTED_RESPONSE_HEADERS
|
|
from .mpd_processor import process_manifest, process_playlist, process_segment, process_init_segment
|
|
from .schemas import HLSManifestParams, MPDManifestParams, MPDPlaylistParams, MPDSegmentParams, MPDInitParams
|
|
from .utils.cache_utils import (
|
|
get_cached_mpd,
|
|
get_cached_init_segment,
|
|
get_cached_segment,
|
|
set_cached_segment,
|
|
get_cached_processed_segment,
|
|
set_cached_processed_segment,
|
|
)
|
|
from .utils.dash_prebuffer import dash_prebuffer
|
|
from .utils.http_utils import (
|
|
Streamer,
|
|
DownloadError,
|
|
download_file_with_retry,
|
|
request_with_retry,
|
|
EnhancedStreamingResponse,
|
|
ProxyRequestHeaders,
|
|
create_streamer,
|
|
apply_header_manipulation,
|
|
)
|
|
from .utils.m3u8_processor import M3U8Processor, generate_graceful_end_playlist
|
|
from .utils.mpd_utils import pad_base64
|
|
from .utils.redis_utils import (
|
|
acquire_stream_gate,
|
|
release_stream_gate,
|
|
get_cached_head,
|
|
set_cached_head,
|
|
check_and_set_cooldown,
|
|
is_redis_configured,
|
|
)
|
|
from .utils.stream_transformers import StreamTransformer, get_transformer
|
|
from .utils.rate_limit_handlers import get_rate_limit_handler
|
|
|
|
from .configs import settings
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def handle_exceptions(exception: Exception, context: str = "") -> Response:
|
|
"""
|
|
Handle exceptions and return appropriate HTTP responses.
|
|
|
|
Uses appropriate log levels based on exception type:
|
|
- DEBUG: Expected errors like 404 Not Found
|
|
- WARNING: Transient errors like timeouts, connection issues
|
|
- ERROR: Only for truly unexpected errors
|
|
|
|
Args:
|
|
exception (Exception): The exception that was raised.
|
|
context (str): Optional context string for better error messages.
|
|
|
|
Returns:
|
|
Response: An HTTP response corresponding to the exception type.
|
|
"""
|
|
ctx = f" [{context}]" if context else ""
|
|
|
|
if isinstance(exception, aiohttp.ClientResponseError):
|
|
if exception.status == 404:
|
|
logger.debug(f"Upstream 404{ctx}: {exception.request_info.url if exception.request_info else 'unknown'}")
|
|
return Response(status_code=404, content="Upstream resource not found")
|
|
elif exception.status in (429, 509):
|
|
# Rate limited by upstream - pass through so the player can retry on its own
|
|
logger.warning(f"Upstream rate limited ({exception.status}){ctx}")
|
|
return Response(status_code=exception.status, content=f"Upstream rate limited: {exception.status}")
|
|
elif exception.status in (502, 503, 504):
|
|
# Upstream server errors - log at warning level as these are often transient
|
|
logger.warning(f"Upstream server error{ctx}: {exception.status}")
|
|
return Response(status_code=exception.status, content=f"Upstream server error: {exception.status}")
|
|
else:
|
|
logger.warning(f"Upstream HTTP error{ctx}: {exception}")
|
|
return Response(status_code=exception.status, content=f"Upstream service error: {exception}")
|
|
elif isinstance(exception, DownloadError):
|
|
# DownloadError is expected for various upstream issues
|
|
logger.warning(f"Download error{ctx}: {exception}")
|
|
return Response(status_code=exception.status_code, content=str(exception))
|
|
elif isinstance(exception, tenacity.RetryError):
|
|
logger.warning(f"Max retries exceeded{ctx}")
|
|
return Response(status_code=502, content="Max retries exceeded while downloading content")
|
|
elif isinstance(exception, asyncio.TimeoutError):
|
|
logger.warning(f"Timeout error{ctx}: upstream did not respond in time")
|
|
return Response(status_code=504, content="Gateway timeout")
|
|
elif isinstance(exception, aiohttp.ClientError):
|
|
# Client errors are often network issues - warning level
|
|
logger.warning(f"Client error{ctx}: {exception}")
|
|
return Response(status_code=502, content=f"Upstream connection error: {exception}")
|
|
elif isinstance(exception, HTTPException):
|
|
# HTTPException is intentionally raised (e.g. segment unavailable) - not unexpected
|
|
if exception.status_code >= 500:
|
|
logger.warning(f"HTTP exception{ctx}: {exception.status_code}: {exception.detail}")
|
|
else:
|
|
logger.debug(f"HTTP exception{ctx}: {exception.status_code}: {exception.detail}")
|
|
return Response(status_code=exception.status_code, content=exception.detail)
|
|
elif isinstance(exception, ValueError) and "HTML instead of m3u8" in str(exception):
|
|
# Expected error when upstream returns error page instead of playlist
|
|
logger.warning(f"Upstream returned HTML{ctx}: stream may be offline or unavailable")
|
|
return Response(status_code=502, content=str(exception))
|
|
else:
|
|
# Only use exception() (with traceback) for truly unexpected errors
|
|
logger.exception(f"Unexpected error{ctx}: {exception}")
|
|
return Response(status_code=502, content=f"Internal server error: {exception}")
|
|
|
|
|
|
async def handle_hls_stream_proxy(
|
|
request: Request,
|
|
hls_params: HLSManifestParams,
|
|
proxy_headers: ProxyRequestHeaders,
|
|
transformer_id: Optional[str] = None,
|
|
) -> Response:
|
|
"""
|
|
Handle HLS stream proxy requests.
|
|
|
|
This function processes HLS manifest files and streams content based on the request parameters.
|
|
|
|
Args:
|
|
request (Request): The incoming FastAPI request object.
|
|
hls_params (HLSManifestParams): Parameters for the HLS manifest.
|
|
proxy_headers (ProxyRequestHeaders): Headers to be used in the proxy request.
|
|
transformer_id (str, optional): ID of the stream transformer to use for segment streaming.
|
|
|
|
Returns:
|
|
Union[Response, EnhancedStreamingResponse]: Either a processed m3u8 playlist or a streaming response.
|
|
"""
|
|
streamer = await create_streamer()
|
|
# Handle range requests
|
|
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})
|
|
|
|
try:
|
|
# Auto-detect and resolve Vavoo links
|
|
if "vavoo.to" in hls_params.destination:
|
|
try:
|
|
from mediaflow_proxy.extractors.vavoo import VavooExtractor
|
|
|
|
vavoo_extractor = VavooExtractor(proxy_headers.request)
|
|
resolved_data = await vavoo_extractor.extract(hls_params.destination)
|
|
resolved_url = resolved_data["destination_url"]
|
|
logger.info(f"Auto-resolved Vavoo URL: {hls_params.destination} -> {resolved_url}")
|
|
# Update destination with resolved URL
|
|
hls_params.destination = resolved_url
|
|
except Exception as e:
|
|
logger.warning(f"Failed to auto-resolve Vavoo URL: {e}")
|
|
# Continue with original URL if resolution fails
|
|
|
|
# Parse skip_segments from JSON string to list
|
|
skip_segments_list = hls_params.get_skip_segments()
|
|
|
|
# Get transformer instance if specified
|
|
transformer = get_transformer(transformer_id)
|
|
|
|
# If force_playlist_proxy is enabled, skip detection and directly process as m3u8
|
|
if hls_params.force_playlist_proxy:
|
|
return await fetch_and_process_m3u8(
|
|
streamer,
|
|
hls_params.destination,
|
|
proxy_headers,
|
|
request,
|
|
hls_params.key_url,
|
|
hls_params.force_playlist_proxy,
|
|
hls_params.key_only_proxy,
|
|
hls_params.no_proxy,
|
|
skip_segments_list,
|
|
transformer,
|
|
hls_params.start_offset,
|
|
)
|
|
|
|
parsed_url = urlparse(hls_params.destination)
|
|
# Check if the URL is a valid m3u8 playlist or m3u file
|
|
if parsed_url.path.endswith((".m3u", ".m3u8", ".m3u_plus")) or parse_qs(parsed_url.query).get("type", [""])[
|
|
0
|
|
] in ["m3u", "m3u8", "m3u_plus"]:
|
|
return await fetch_and_process_m3u8(
|
|
streamer,
|
|
hls_params.destination,
|
|
proxy_headers,
|
|
request,
|
|
hls_params.key_url,
|
|
hls_params.force_playlist_proxy,
|
|
hls_params.key_only_proxy,
|
|
hls_params.no_proxy,
|
|
skip_segments_list,
|
|
transformer,
|
|
hls_params.start_offset,
|
|
)
|
|
|
|
# Create initial streaming response to check content type
|
|
await streamer.create_streaming_response(hls_params.destination, proxy_headers.request)
|
|
response_headers = prepare_response_headers(
|
|
streamer.response.headers, proxy_headers.response, proxy_headers.remove, proxy_headers.propagate
|
|
)
|
|
|
|
if "mpegurl" in response_headers.get("content-type", "").lower():
|
|
return await fetch_and_process_m3u8(
|
|
streamer,
|
|
hls_params.destination,
|
|
proxy_headers,
|
|
request,
|
|
hls_params.key_url,
|
|
hls_params.force_playlist_proxy,
|
|
hls_params.key_only_proxy,
|
|
hls_params.no_proxy,
|
|
skip_segments_list,
|
|
transformer,
|
|
hls_params.start_offset,
|
|
)
|
|
|
|
# If we're removing content-range but upstream returned 206, change to 200
|
|
# (206 Partial Content requires Content-Range header per HTTP spec)
|
|
status_code = streamer.response.status
|
|
if status_code == 206 and "content-range" in [h.lower() for h in proxy_headers.remove]:
|
|
status_code = 200
|
|
|
|
return EnhancedStreamingResponse(
|
|
streamer.stream_content(transformer),
|
|
status_code=status_code,
|
|
headers=response_headers,
|
|
background=BackgroundTask(streamer.close),
|
|
)
|
|
except Exception as e:
|
|
await streamer.close()
|
|
return handle_exceptions(e)
|
|
|
|
|
|
async def handle_stream_request(
|
|
method: str,
|
|
video_url: str,
|
|
proxy_headers: ProxyRequestHeaders,
|
|
transformer_id: Optional[str] = None,
|
|
rate_limit_handler_id: Optional[str] = None,
|
|
) -> Response:
|
|
"""
|
|
Handle general stream requests.
|
|
|
|
This function processes both HEAD and GET requests for video streams.
|
|
Uses Redis for cross-worker coordination to prevent CDN rate-limiting (e.g., Vidoza 509).
|
|
|
|
Rate limiting behavior is controlled by the rate_limit_handler parameter:
|
|
- If specified, uses that handler's settings
|
|
- If not specified, auto-detects based on video URL hostname
|
|
- If no handler matches, no rate limiting is applied (fast path)
|
|
|
|
The coordination strategy (when rate limiting is enabled):
|
|
1. HEAD requests: Check/use Redis cache, skip upstream entirely if cached
|
|
2. GET requests: Check cooldown FIRST, return 503 if in cooldown
|
|
3. Only ONE request proceeds to upstream at a time via gate
|
|
4. After upstream responds, set cooldown to prevent rapid follow-up requests
|
|
|
|
Args:
|
|
method (str): The HTTP method (e.g., 'GET' or 'HEAD').
|
|
video_url (str): The URL of the video to stream.
|
|
proxy_headers (ProxyRequestHeaders): Headers to be used in the proxy request.
|
|
transformer_id (str, optional): ID of the stream transformer to use for content manipulation.
|
|
rate_limit_handler_id (str, optional): ID of the rate limit handler to use (e.g., "vidoza", "aggressive").
|
|
If not specified, auto-detects based on video URL hostname.
|
|
|
|
Returns:
|
|
Union[Response, EnhancedStreamingResponse]: Either a HEAD response with headers or a streaming response.
|
|
"""
|
|
host = urlparse(video_url).hostname or "unknown"
|
|
gate_acquired = False
|
|
|
|
# Get rate limit handler (explicit ID, auto-detect from URL, or default no-op)
|
|
rate_handler = get_rate_limit_handler(rate_limit_handler_id, video_url)
|
|
|
|
# Check if rate limiting features are needed and Redis is available
|
|
needs_rate_limiting = (
|
|
rate_handler.cooldown_seconds > 0 or rate_handler.use_head_cache or rate_handler.use_stream_gate
|
|
)
|
|
redis_available = is_redis_configured()
|
|
|
|
if needs_rate_limiting:
|
|
logger.info(
|
|
f"[handle_stream] Rate limiting ENABLED for {host}: "
|
|
f"cooldown={rate_handler.cooldown_seconds}s, gate={rate_handler.use_stream_gate}, "
|
|
f"head_cache={rate_handler.use_head_cache}, redis={redis_available}"
|
|
)
|
|
|
|
if needs_rate_limiting and not redis_available:
|
|
logger.warning(f"[handle_stream] Rate limiting requested for {host} but Redis not configured - skipping")
|
|
needs_rate_limiting = False
|
|
|
|
# Cooldown key - prevents rapid-fire requests to same CDN URL
|
|
cooldown_key = f"stream_cooldown:{video_url}"
|
|
|
|
# 1. Check Redis HEAD cache first (if enabled by handler)
|
|
cached = None
|
|
if needs_rate_limiting and rate_handler.use_head_cache:
|
|
cached = await get_cached_head(video_url)
|
|
|
|
if method == "HEAD":
|
|
if cached:
|
|
logger.info(f"[handle_stream] Serving cached HEAD response for {host}")
|
|
response_headers = prepare_response_headers(
|
|
cached["headers"], proxy_headers.response, proxy_headers.remove, proxy_headers.propagate
|
|
)
|
|
return Response(headers=response_headers, status_code=cached["status"])
|
|
# No cached HEAD - for rate-limited hosts, wait for cache via gate instead of hitting upstream
|
|
if needs_rate_limiting and rate_handler.use_stream_gate:
|
|
# Try to acquire gate - if we get it, we make the upstream HEAD request
|
|
# If another request holds the gate, we wait and then check cache again
|
|
gate_acquired = await acquire_stream_gate(video_url, timeout=30.0)
|
|
if gate_acquired:
|
|
# We got the gate - check cache again (another request may have populated it while we waited)
|
|
cached = await get_cached_head(video_url)
|
|
if cached:
|
|
await release_stream_gate(video_url)
|
|
logger.info(f"[handle_stream] Serving cached HEAD response after gate wait for {host}")
|
|
response_headers = prepare_response_headers(
|
|
cached["headers"], proxy_headers.response, proxy_headers.remove, proxy_headers.propagate
|
|
)
|
|
return Response(headers=response_headers, status_code=cached["status"])
|
|
# Cache still empty - we'll make the upstream request (gate is held)
|
|
else:
|
|
# Gate timeout - check cache one more time before giving up
|
|
cached = await get_cached_head(video_url)
|
|
if cached:
|
|
logger.info(f"[handle_stream] Serving cached HEAD after gate timeout for {host}")
|
|
response_headers = prepare_response_headers(
|
|
cached["headers"], proxy_headers.response, proxy_headers.remove, proxy_headers.propagate
|
|
)
|
|
return Response(headers=response_headers, status_code=cached["status"])
|
|
logger.warning(f"[handle_stream] HEAD gate timeout for {host}, no cached headers available")
|
|
return Response(status_code=503, content="Upstream host is busy, try again later")
|
|
# No rate limiting - proceed to upstream without gate
|
|
else:
|
|
# For GET requests with rate limiting: wait for cooldown and acquire gate
|
|
if needs_rate_limiting and rate_handler.use_stream_gate:
|
|
# Wait for gate - this serializes all requests to the same URL
|
|
gate_acquired = await acquire_stream_gate(video_url, timeout=30.0)
|
|
if not gate_acquired:
|
|
logger.warning(f"[handle_stream] Gate timeout for {host}, upstream may be slow")
|
|
return Response(status_code=503, content="Upstream host is busy, try again later")
|
|
|
|
# Got the gate - now check/set cooldown
|
|
# If in cooldown, wait for it to expire before proceeding
|
|
if rate_handler.cooldown_seconds > 0:
|
|
max_wait = rate_handler.cooldown_seconds + 1 # Wait slightly longer than cooldown
|
|
wait_start = time.time()
|
|
|
|
while not await check_and_set_cooldown(cooldown_key, rate_handler.cooldown_seconds):
|
|
# Still in cooldown - wait a bit and retry
|
|
elapsed = time.time() - wait_start
|
|
if elapsed >= max_wait:
|
|
# Cooldown still active after max wait - give up
|
|
await release_stream_gate(video_url)
|
|
logger.warning(f"[handle_stream] Cooldown wait timeout for {host}")
|
|
return Response(
|
|
status_code=503,
|
|
content="Stream busy, try again later",
|
|
headers={"Retry-After": str(rate_handler.retry_after_seconds)},
|
|
)
|
|
logger.debug(f"[handle_stream] Waiting for cooldown to expire for {host}...")
|
|
await asyncio.sleep(0.5) # Poll every 500ms
|
|
|
|
# Cooldown acquired - we can proceed to upstream
|
|
logger.info(f"[handle_stream] Cooldown acquired for {host} after {time.time() - wait_start:.1f}s wait")
|
|
|
|
streamer = await create_streamer(video_url)
|
|
|
|
try:
|
|
# Auto-detect and resolve Vavoo links
|
|
if "vavoo.to" in video_url:
|
|
try:
|
|
from mediaflow_proxy.extractors.vavoo import VavooExtractor
|
|
|
|
vavoo_extractor = VavooExtractor(proxy_headers.request)
|
|
resolved_data = await vavoo_extractor.extract(video_url)
|
|
resolved_url = resolved_data["destination_url"]
|
|
logger.info(f"Auto-resolved Vavoo URL: {video_url} -> {resolved_url}")
|
|
# Update video_url with resolved URL
|
|
video_url = resolved_url
|
|
except Exception as e:
|
|
logger.warning(f"Failed to auto-resolve Vavoo URL: {e}")
|
|
# Continue with original URL if resolution fails
|
|
|
|
# Log timing for debugging seek performance
|
|
start_time = time.time()
|
|
range_header = proxy_headers.request.get("range", "not set")
|
|
logger.info(f"[handle_stream] Starting upstream {method} request - range: {range_header}")
|
|
|
|
# Track if this is an auto-added "bytes=0-" range (client didn't send range)
|
|
# We detect this by checking if range equals exactly "bytes=0-" which indicates
|
|
# a proxy-added default range, not a client seeking request
|
|
auto_added_range = proxy_headers.request.get("range") == "bytes=0-"
|
|
|
|
# Use the same HTTP method for upstream request (HEAD for HEAD, GET for GET)
|
|
# This prevents unnecessary data download when client just wants headers
|
|
await streamer.create_streaming_response(video_url, proxy_headers.request, method=method)
|
|
|
|
elapsed = time.time() - start_time
|
|
logger.info(f"[handle_stream] Upstream responded in {elapsed:.2f}s - status: {streamer.response.status}")
|
|
logger.debug(f"Upstream response headers: {dict(streamer.response.headers)}")
|
|
|
|
response_headers = prepare_response_headers(
|
|
streamer.response.headers, proxy_headers.response, proxy_headers.remove, proxy_headers.propagate
|
|
)
|
|
logger.debug(f"Prepared response headers: {response_headers}")
|
|
|
|
# When client didn't send a Range header but upstream returns 206 Partial Content:
|
|
# - Convert status to 200 (full content, not partial)
|
|
# - Remove content-range header to avoid confusing the client
|
|
# This handles cases where we added bytes=0- range but upstream still treats it as a range request
|
|
status_code = streamer.response.status
|
|
if status_code == 206:
|
|
if "content-range" in [h.lower() for h in proxy_headers.remove]:
|
|
# Explicitly requested to remove content-range
|
|
status_code = 200
|
|
# Also remove content-range from response headers if present
|
|
response_headers.pop("content-range", None)
|
|
elif auto_added_range:
|
|
# We auto-added bytes=0- range but got 206 - convert to 200
|
|
# This happens when client didn't send a range but upstream responds with 206
|
|
status_code = 200
|
|
# Remove content-range to avoid confusing client
|
|
response_headers.pop("content-range", None)
|
|
# Update content-length to total size (remove range suffix if present)
|
|
content_range = streamer.response.headers.get("Content-Range", "")
|
|
if "/" in content_range:
|
|
# Extract total size from "bytes X-Y/total"
|
|
total_size = content_range.split("/")[-1].strip()
|
|
response_headers["content-length"] = total_size
|
|
|
|
# Get transformer instance if specified
|
|
transformer = get_transformer(transformer_id)
|
|
|
|
# Cache headers in Redis for future HEAD probes (if rate limiting enabled)
|
|
if needs_rate_limiting and rate_handler.use_head_cache and status_code in (200, 206):
|
|
await set_cached_head(video_url, dict(streamer.response.headers), status_code)
|
|
|
|
if method == "HEAD":
|
|
# HEAD requests always release gate immediately
|
|
if gate_acquired:
|
|
await release_stream_gate(video_url)
|
|
gate_acquired = False
|
|
await streamer.close()
|
|
return Response(headers=response_headers, status_code=status_code)
|
|
else:
|
|
# For GET requests: check if we need exclusive streaming
|
|
if gate_acquired and needs_rate_limiting and rate_handler.exclusive_stream:
|
|
# EXCLUSIVE MODE: Keep gate held during entire stream
|
|
# Release gate in background task when stream ends
|
|
logger.info(f"[handle_stream] Exclusive stream mode - gate held during stream for {host}")
|
|
|
|
async def cleanup_exclusive():
|
|
await streamer.close()
|
|
await release_stream_gate(video_url)
|
|
logger.info(f"[handle_stream] Exclusive stream ended - gate released for {host}")
|
|
|
|
gate_acquired = False # Background task will release it
|
|
return EnhancedStreamingResponse(
|
|
streamer.stream_content(transformer),
|
|
headers=response_headers,
|
|
status_code=status_code,
|
|
background=BackgroundTask(cleanup_exclusive),
|
|
)
|
|
else:
|
|
# NORMAL MODE: Release gate after headers, stream continues freely
|
|
if gate_acquired:
|
|
await release_stream_gate(video_url)
|
|
gate_acquired = False
|
|
return EnhancedStreamingResponse(
|
|
streamer.stream_content(transformer),
|
|
headers=response_headers,
|
|
status_code=status_code,
|
|
background=BackgroundTask(streamer.close),
|
|
)
|
|
except Exception as e:
|
|
await streamer.close()
|
|
return handle_exceptions(e)
|
|
finally:
|
|
# Safety: release gate if not already released (error path before headers received)
|
|
if gate_acquired:
|
|
await release_stream_gate(video_url)
|
|
|
|
|
|
def prepare_response_headers(
|
|
original_headers, proxy_response_headers, remove_headers=None, propagate_headers=None
|
|
) -> dict:
|
|
"""
|
|
Prepare response headers for the proxy response.
|
|
|
|
This function filters the original headers, ensures proper transfer encoding,
|
|
and merges them with the proxy response headers.
|
|
|
|
Args:
|
|
original_headers: The original headers from the upstream response (aiohttp CIMultiDictProxy).
|
|
proxy_response_headers (dict): Additional headers to be included in the proxy response.
|
|
remove_headers (list, optional): List of header names to remove from the response. Defaults to None.
|
|
propagate_headers (dict, optional): Headers that propagate to segments (rp_ prefix). Defaults to None.
|
|
|
|
Returns:
|
|
dict: The prepared headers for the proxy response.
|
|
"""
|
|
remove_set = set(h.lower() for h in (remove_headers or []))
|
|
response_headers = {}
|
|
|
|
# Handle aiohttp CIMultiDictProxy
|
|
for k, v in original_headers.items():
|
|
k_lower = k.lower()
|
|
if k_lower in SUPPORTED_RESPONSE_HEADERS and k_lower not in remove_set:
|
|
response_headers[k_lower] = v
|
|
|
|
# Apply propagate headers first (for segments), then response headers (response takes precedence)
|
|
if propagate_headers:
|
|
response_headers.update(propagate_headers)
|
|
response_headers.update(proxy_response_headers)
|
|
return response_headers
|
|
|
|
|
|
async def proxy_stream(
|
|
method: str,
|
|
destination: str,
|
|
proxy_headers: ProxyRequestHeaders,
|
|
transformer_id: Optional[str] = None,
|
|
rate_limit_handler_id: Optional[str] = None,
|
|
):
|
|
"""
|
|
Proxies the stream request to the given video URL.
|
|
|
|
Args:
|
|
method (str): The HTTP method (e.g., GET, HEAD).
|
|
destination (str): The URL of the stream to be proxied.
|
|
proxy_headers (ProxyRequestHeaders): The headers to include in the request.
|
|
transformer_id (str, optional): ID of the stream transformer to use.
|
|
rate_limit_handler_id (str, optional): ID of the rate limit handler to use (e.g., "vidoza").
|
|
If not specified, auto-detects based on destination URL hostname.
|
|
|
|
Returns:
|
|
Response: The HTTP response with the streamed content.
|
|
"""
|
|
return await handle_stream_request(method, destination, proxy_headers, transformer_id, rate_limit_handler_id)
|
|
|
|
|
|
async def fetch_and_process_m3u8(
|
|
streamer: Streamer,
|
|
url: str,
|
|
proxy_headers: ProxyRequestHeaders,
|
|
request: Request,
|
|
key_url: str = None,
|
|
force_playlist_proxy: bool = None,
|
|
key_only_proxy: bool = False,
|
|
no_proxy: bool = False,
|
|
skip_segments: list = None,
|
|
transformer: Optional[StreamTransformer] = None,
|
|
start_offset: float = None,
|
|
):
|
|
"""
|
|
Fetches and processes the m3u8 playlist on-the-fly, converting it to an HLS playlist.
|
|
|
|
Args:
|
|
streamer (Streamer): The HTTP client to use for streaming.
|
|
url (str): The URL of the m3u8 playlist.
|
|
proxy_headers (ProxyRequestHeaders): The headers to include in the request.
|
|
request (Request): The incoming HTTP request.
|
|
key_url (str, optional): The HLS Key URL to replace the original key URL. Defaults to None.
|
|
force_playlist_proxy (bool, optional): Force all playlist URLs to be proxied through MediaFlow. Defaults to None.
|
|
key_only_proxy (bool, optional): Only proxy the key URL, leaving segment URLs direct. Defaults to False.
|
|
no_proxy (bool, optional): If True, returns the manifest without proxying any URLs. Defaults to False.
|
|
skip_segments (list, optional): List of time segments to skip. Each item should have
|
|
'start', 'end' (in seconds), and optionally 'type'.
|
|
transformer (StreamTransformer, optional): Transformer to apply to the stream content.
|
|
start_offset (float, optional): Time offset in seconds for EXT-X-START tag. Use negative
|
|
values for live streams to start behind the live edge.
|
|
|
|
Returns:
|
|
Response: The HTTP response with the processed m3u8 playlist.
|
|
"""
|
|
try:
|
|
# Create streaming response if not already created
|
|
if not streamer.response:
|
|
await streamer.create_streaming_response(url, proxy_headers.request)
|
|
|
|
# Initialize processor and response headers
|
|
# skip_segments is already a list of dicts with 'start' and 'end' keys
|
|
processor = M3U8Processor(
|
|
request, key_url, force_playlist_proxy, key_only_proxy, no_proxy, skip_segments, start_offset
|
|
)
|
|
base_headers = {
|
|
"content-disposition": "inline",
|
|
"accept-ranges": "none",
|
|
"content-type": "application/vnd.apple.mpegurl",
|
|
}
|
|
# Don't include propagate headers for manifests - they should only apply to segments
|
|
response_headers = apply_header_manipulation(base_headers, proxy_headers, include_propagate=False)
|
|
|
|
# Get the generator for processing
|
|
m3u8_generator = processor.process_m3u8_streaming(
|
|
streamer.stream_content(transformer), str(streamer.response.url)
|
|
)
|
|
|
|
# Pre-fetch the first chunk to validate the content before starting the response
|
|
# This allows us to return a proper HTTP error if the upstream returns HTML
|
|
first_chunk = None
|
|
try:
|
|
first_chunk = await m3u8_generator.__anext__()
|
|
except ValueError as e:
|
|
# Upstream returned HTML instead of m3u8 - expected error, log at warning level
|
|
logger.warning(f"Upstream error for {url}: {e}")
|
|
await streamer.close()
|
|
# Return graceful end playlist if enabled, otherwise raise error
|
|
if settings.graceful_stream_end:
|
|
graceful_content = generate_graceful_end_playlist("Stream offline or unavailable")
|
|
return Response(
|
|
content=graceful_content,
|
|
media_type="application/vnd.apple.mpegurl",
|
|
headers=response_headers,
|
|
)
|
|
raise HTTPException(status_code=502, detail=str(e))
|
|
except StopAsyncIteration:
|
|
# Empty response - this shouldn't happen for valid m3u8
|
|
logger.warning(f"Upstream returned empty m3u8 playlist: {url}")
|
|
await streamer.close()
|
|
# Return graceful end playlist if enabled, otherwise raise error
|
|
if settings.graceful_stream_end:
|
|
graceful_content = generate_graceful_end_playlist("Empty upstream response")
|
|
return Response(
|
|
content=graceful_content,
|
|
media_type="application/vnd.apple.mpegurl",
|
|
headers=response_headers,
|
|
)
|
|
raise HTTPException(status_code=502, detail="Upstream returned empty m3u8 playlist")
|
|
|
|
# Create a wrapper that yields the first chunk then continues with the rest
|
|
async def prefetched_generator():
|
|
yield first_chunk
|
|
try:
|
|
async for chunk in m3u8_generator:
|
|
yield chunk
|
|
except ValueError as e:
|
|
# This shouldn't happen since we already validated the first chunk,
|
|
# but handle it gracefully if it does
|
|
logger.warning(f"ValueError during m3u8 streaming (after initial validation): {e}")
|
|
|
|
# Create streaming response with on-the-fly processing
|
|
return EnhancedStreamingResponse(
|
|
prefetched_generator(),
|
|
headers=response_headers,
|
|
background=BackgroundTask(streamer.close),
|
|
)
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
await streamer.close()
|
|
return handle_exceptions(e)
|
|
|
|
|
|
def _normalize_drm_key_value(value: str) -> str:
|
|
"""
|
|
Normalize a DRM key_id or key value to lowercase hex.
|
|
|
|
Accepts either:
|
|
- A 32-char hex string (returned as-is, lowercased).
|
|
- A base64url-encoded value (decoded to hex).
|
|
- A comma-separated list of the above, for multi-key DRM scenarios.
|
|
|
|
Commas are treated as list separators, NOT as characters to pass into
|
|
base64 decoding. Previously the ``len() != 32`` check was applied to
|
|
the entire comma-joined string, and ``urlsafe_b64decode`` silently
|
|
strips non-alphabet characters (including commas), causing adjacent keys
|
|
to be concatenated into an oversized byte string.
|
|
|
|
Args:
|
|
value: The key value string, or None.
|
|
|
|
Returns:
|
|
Normalized hex string (or comma-joined hex strings for multi-key),
|
|
or the original value unchanged if it is falsy.
|
|
"""
|
|
if not value:
|
|
return value
|
|
if "," in value:
|
|
parts = [_normalize_single_key(p.strip()) for p in value.split(",") if p.strip()]
|
|
return ",".join(parts)
|
|
return _normalize_single_key(value)
|
|
|
|
|
|
def _normalize_single_key(value: str) -> str:
|
|
"""Convert a single key_id or key to a 32-char lowercase hex string."""
|
|
if len(value) != 32:
|
|
return base64.urlsafe_b64decode(pad_base64(value)).hex()
|
|
return value.lower()
|
|
|
|
|
|
async def handle_drm_key_data(key_id, key, drm_info):
|
|
"""
|
|
Handles the DRM key data, retrieving the key ID and key from the DRM info if not provided.
|
|
|
|
Args:
|
|
key_id (str): The DRM key ID.
|
|
key (str): The DRM key.
|
|
drm_info (dict): The DRM information from the MPD manifest.
|
|
|
|
Returns:
|
|
tuple: The key ID and key.
|
|
"""
|
|
if drm_info and not drm_info.get("isDrmProtected"):
|
|
return None, None
|
|
|
|
if not key_id or not key:
|
|
if "keyId" in drm_info and "key" in drm_info:
|
|
key_id = drm_info["keyId"]
|
|
key = drm_info["key"]
|
|
elif "laUrl" in drm_info and "keyId" in drm_info:
|
|
# License URL with keyId - license acquisition should have been attempted already
|
|
# If we still don't have a key, it means acquisition failed
|
|
pass
|
|
else:
|
|
# Try to use extracted KID if available (from MPD/init segment analysis)
|
|
if not key_id and drm_info.get("extracted_kids"):
|
|
# Use the first extracted KID
|
|
extracted_kids = drm_info["extracted_kids"]
|
|
if extracted_kids:
|
|
key_id = extracted_kids[0]
|
|
logger.info(f"Using extracted KID from MPD/init segment: {key_id}")
|
|
|
|
# Still require the actual decryption key
|
|
if not key:
|
|
if drm_info.get("extracted_kids"):
|
|
license_urls = drm_info.get("license_urls", [])
|
|
license_url_msg = f" License server: {license_urls[0]}" if license_urls else ""
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=(
|
|
f"Key ID (KID) was automatically extracted: {key_id}. "
|
|
f"However, the actual decryption key must be provided via the 'key' parameter. "
|
|
f"The key cannot be extracted from the MPD or init segment and must be obtained "
|
|
f"from the license server or source website.{license_url_msg}"
|
|
),
|
|
)
|
|
else:
|
|
raise HTTPException(
|
|
status_code=400, detail="Unable to determine key_id and key, and they were not provided"
|
|
)
|
|
|
|
return key_id, key
|
|
|
|
|
|
async def get_manifest(
|
|
request: Request,
|
|
manifest_params: MPDManifestParams,
|
|
proxy_headers: ProxyRequestHeaders,
|
|
):
|
|
"""
|
|
Retrieves and processes the MPD manifest, converting it to an HLS manifest.
|
|
|
|
Args:
|
|
request (Request): The incoming HTTP request.
|
|
manifest_params (MPDManifestParams): The parameters for the manifest request.
|
|
proxy_headers (ProxyRequestHeaders): The headers to include in the request.
|
|
|
|
Returns:
|
|
Response: The HTTP response with the HLS manifest.
|
|
"""
|
|
try:
|
|
mpd_dict = await get_cached_mpd(
|
|
manifest_params.destination,
|
|
headers=proxy_headers.request,
|
|
parse_drm=not manifest_params.key_id and not manifest_params.key,
|
|
)
|
|
except DownloadError as e:
|
|
raise HTTPException(status_code=e.status_code, detail=f"Failed to download MPD: {e.message}")
|
|
drm_info = mpd_dict.get("drmInfo", {})
|
|
|
|
# Get skip segments if provided
|
|
skip_segments = manifest_params.get_skip_segments()
|
|
|
|
if drm_info and not drm_info.get("isDrmProtected"):
|
|
# For non-DRM protected MPD, we still create an HLS manifest
|
|
return await process_manifest(
|
|
request, mpd_dict, proxy_headers, None, None, manifest_params.resolution, skip_segments
|
|
)
|
|
|
|
key_id, key = await handle_drm_key_data(manifest_params.key_id, manifest_params.key, drm_info)
|
|
|
|
# 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
|
|
# normalized independently so that commas are never passed to urlsafe_b64decode
|
|
# (base64 silently ignores non-alphabet characters, stripping commas and
|
|
# concatenating the keys into a single oversized value).
|
|
key_id = _normalize_drm_key_value(key_id)
|
|
key = _normalize_drm_key_value(key)
|
|
|
|
return await process_manifest(
|
|
request, mpd_dict, proxy_headers, key_id, key, manifest_params.resolution, skip_segments
|
|
)
|
|
|
|
|
|
async def get_playlist(
|
|
request: Request,
|
|
playlist_params: MPDPlaylistParams,
|
|
proxy_headers: ProxyRequestHeaders,
|
|
):
|
|
"""
|
|
Retrieves and processes the MPD manifest, converting it to an HLS playlist for a specific profile.
|
|
|
|
Args:
|
|
request (Request): The incoming HTTP request.
|
|
playlist_params (MPDPlaylistParams): The parameters for the playlist request.
|
|
proxy_headers (ProxyRequestHeaders): The headers to include in the request.
|
|
|
|
Returns:
|
|
Response: The HTTP response with the HLS playlist.
|
|
"""
|
|
try:
|
|
mpd_dict = await get_cached_mpd(
|
|
playlist_params.destination,
|
|
headers=proxy_headers.request,
|
|
parse_drm=not playlist_params.key_id and not playlist_params.key,
|
|
parse_segment_profile_id=playlist_params.profile_id,
|
|
)
|
|
except DownloadError as e:
|
|
raise HTTPException(status_code=e.status_code, detail=f"Failed to download MPD: {e.message}")
|
|
|
|
# Get skip segments if provided
|
|
skip_segments = playlist_params.get_skip_segments()
|
|
|
|
return await process_playlist(
|
|
request, mpd_dict, playlist_params.profile_id, proxy_headers, skip_segments, playlist_params.start_offset
|
|
)
|
|
|
|
|
|
async def get_segment(
|
|
segment_params: MPDSegmentParams,
|
|
proxy_headers: ProxyRequestHeaders,
|
|
force_remux_ts: bool = None,
|
|
):
|
|
"""
|
|
Retrieves and processes a media segment, decrypting it if necessary.
|
|
|
|
Uses event-based coordination with the DASH prebuffer to prevent duplicate
|
|
downloads. The prebuffer's get_or_download() handles cache checks, waiting
|
|
for existing downloads, and starting new downloads as needed.
|
|
|
|
Args:
|
|
segment_params (MPDSegmentParams): The parameters for the segment request.
|
|
proxy_headers (ProxyRequestHeaders): The headers to include in the request.
|
|
force_remux_ts (bool, optional): If True, force remuxing to MPEG-TS regardless
|
|
of global settings. Used by /mpd/segment.ts endpoint. Defaults to None.
|
|
|
|
Returns:
|
|
Response: The HTTP response with the processed segment.
|
|
"""
|
|
try:
|
|
live_cache_ttl = settings.mpd_live_init_cache_ttl if segment_params.is_live else None
|
|
segment_url = segment_params.segment_url
|
|
should_remux = force_remux_ts if force_remux_ts is not None else settings.remux_to_ts
|
|
|
|
# Check processed segment cache first (avoids re-decrypting/re-remuxing)
|
|
is_processed = bool(segment_params.key_id or should_remux)
|
|
if is_processed:
|
|
processed_content = await get_cached_processed_segment(segment_url, segment_params.key_id, should_remux)
|
|
if processed_content:
|
|
logger.info(f"Serving processed segment from cache: {segment_url}")
|
|
mimetype = "video/mp2t" if should_remux else segment_params.mime_type
|
|
response_headers = apply_header_manipulation({}, proxy_headers)
|
|
return Response(content=processed_content, media_type=mimetype, headers=response_headers)
|
|
|
|
# Use event-based coordination for segment download
|
|
# get_or_download() handles:
|
|
# - Cache check
|
|
# - Waiting for existing downloads (via asyncio.Event)
|
|
# - Starting new download if needed
|
|
# - Caching the result
|
|
# Use a short timeout (1s) for player requests to avoid blocking if prebuffer is busy
|
|
# This ensures players get fast responses even when background prefetching is active
|
|
if settings.enable_dash_prebuffer:
|
|
segment_content = await dash_prebuffer.get_or_download(segment_url, proxy_headers.request, timeout=1.0)
|
|
else:
|
|
# Prebuffer disabled - check cache then download directly
|
|
segment_content = await get_cached_segment(segment_url)
|
|
if not segment_content:
|
|
try:
|
|
segment_content = await download_file_with_retry(segment_url, proxy_headers.request)
|
|
# Cache for future requests (synchronous to ensure it's cached before returning)
|
|
if segment_content and segment_params.is_live:
|
|
# Use create_task for non-blocking cache write, but segment is already downloaded
|
|
asyncio.create_task(
|
|
set_cached_segment(segment_url, segment_content, ttl=settings.dash_segment_cache_ttl)
|
|
)
|
|
except Exception as dl_err:
|
|
logger.warning(f"Direct download failed when prebuffer disabled: {dl_err}")
|
|
segment_content = None
|
|
|
|
# If prebuffer returned None (lock timeout or coordination failure),
|
|
# check cache one more time - the download may have completed while we waited
|
|
# Then fall back to a direct download if still not cached.
|
|
# This is critical for live streams where the prebuffer may be busy
|
|
# downloading other segments/profiles.
|
|
if not segment_content:
|
|
# Final cache check - download may have completed during lock wait
|
|
segment_content = await get_cached_segment(segment_url)
|
|
if segment_content:
|
|
logger.info(f"Segment found in cache after prebuffer timeout: {segment_url}")
|
|
else:
|
|
logger.info(f"Prebuffer returned no content, falling back to direct download: {segment_url}")
|
|
try:
|
|
segment_content = await download_file_with_retry(segment_url, proxy_headers.request)
|
|
# Cache on success for future requests
|
|
if segment_content and segment_params.is_live:
|
|
asyncio.create_task(
|
|
set_cached_segment(segment_url, segment_content, ttl=settings.dash_segment_cache_ttl)
|
|
)
|
|
except Exception as dl_err:
|
|
logger.warning(f"Direct download fallback also failed: {dl_err}")
|
|
|
|
if not segment_content:
|
|
# Return 404 instead of 502 so players can skip and continue
|
|
# Most video players handle 404s gracefully by skipping the segment
|
|
raise HTTPException(status_code=404, detail="Segment unavailable")
|
|
|
|
# Fetch init segment (uses its own cache)
|
|
init_content = await get_cached_init_segment(
|
|
segment_params.init_url,
|
|
proxy_headers.request,
|
|
cache_token=segment_params.key_id,
|
|
ttl=live_cache_ttl,
|
|
byte_range=segment_params.init_range,
|
|
)
|
|
|
|
# Trigger continuous prefetch for live streams
|
|
if settings.enable_dash_prebuffer and segment_params.is_live:
|
|
for mpd_url in dash_prebuffer.active_streams:
|
|
asyncio.create_task(
|
|
dash_prebuffer.prefetch_upcoming_segments(
|
|
mpd_url,
|
|
segment_url,
|
|
proxy_headers.request,
|
|
)
|
|
)
|
|
break # Only need to trigger once
|
|
|
|
except Exception as e:
|
|
return handle_exceptions(e)
|
|
|
|
try:
|
|
response = await process_segment(
|
|
init_content,
|
|
segment_content,
|
|
segment_params.mime_type,
|
|
proxy_headers,
|
|
segment_params.key_id,
|
|
segment_params.key,
|
|
use_map=segment_params.use_map,
|
|
remux_ts=force_remux_ts,
|
|
)
|
|
except Exception as e:
|
|
return handle_exceptions(e)
|
|
|
|
# Cache processed segment for future requests (avoids re-decrypting/re-remuxing)
|
|
if is_processed and response.status_code == 200:
|
|
asyncio.create_task(
|
|
set_cached_processed_segment(
|
|
segment_url,
|
|
response.body,
|
|
segment_params.key_id,
|
|
should_remux,
|
|
ttl=settings.processed_segment_cache_ttl,
|
|
)
|
|
)
|
|
|
|
return response
|
|
|
|
|
|
async def get_init_segment(
|
|
init_params: MPDInitParams,
|
|
proxy_headers: ProxyRequestHeaders,
|
|
):
|
|
"""
|
|
Retrieves and processes an initialization segment for EXT-X-MAP.
|
|
|
|
Args:
|
|
init_params (MPDInitParams): The parameters for the init segment request.
|
|
proxy_headers (ProxyRequestHeaders): The headers to include in the request.
|
|
|
|
Returns:
|
|
Response: The HTTP response with the processed init segment.
|
|
"""
|
|
try:
|
|
live_cache_ttl = settings.mpd_live_init_cache_ttl if init_params.is_live else None
|
|
init_content = await get_cached_init_segment(
|
|
init_params.init_url,
|
|
proxy_headers.request,
|
|
cache_token=init_params.key_id,
|
|
ttl=live_cache_ttl,
|
|
byte_range=init_params.init_range,
|
|
)
|
|
except Exception as e:
|
|
return handle_exceptions(e)
|
|
|
|
return await process_init_segment(
|
|
init_content,
|
|
init_params.mime_type,
|
|
proxy_headers,
|
|
init_params.key_id,
|
|
init_params.key,
|
|
init_params.init_url,
|
|
)
|
|
|
|
|
|
IP_LOOKUP_SERVICES = [
|
|
{"url": "https://api.ipify.org?format=json", "key": "ip"},
|
|
{"url": "https://ipinfo.io/json", "key": "ip"},
|
|
{"url": "https://httpbin.org/ip", "key": "origin"},
|
|
]
|
|
|
|
|
|
async def get_public_ip():
|
|
"""
|
|
Retrieves the public IP address of the MediaFlow proxy.
|
|
Tries multiple services for reliability.
|
|
|
|
Returns:
|
|
dict: A dictionary with the public IP address {"ip": "x.x.x.x"}.
|
|
|
|
Raises:
|
|
DownloadError: If all IP lookup services fail.
|
|
"""
|
|
for service in IP_LOOKUP_SERVICES:
|
|
try:
|
|
response = await request_with_retry("GET", service["url"], {})
|
|
content = await response.text()
|
|
import json
|
|
|
|
data = json.loads(content)
|
|
ip = data.get(service["key"])
|
|
if ip:
|
|
return {"ip": ip.strip()}
|
|
except Exception:
|
|
continue
|
|
|
|
raise DownloadError(503, "Failed to retrieve public IP from all services")
|