mirror of
https://github.com/UrloMythus/UnHided.git
synced 2026-06-10 09:10:23 +00:00
824 lines
34 KiB
Python
824 lines
34 KiB
Python
import asyncio
|
||
import logging
|
||
import math
|
||
import statistics
|
||
import time
|
||
|
||
from aiohttp import ClientTimeout
|
||
from fastapi import Request, Response, HTTPException
|
||
|
||
from mediaflow_proxy.drm.decrypter import decrypt_segment, process_drm_init_segment
|
||
from mediaflow_proxy.utils.crypto_utils import encryption_handler
|
||
from mediaflow_proxy.utils.http_client import create_aiohttp_session
|
||
from mediaflow_proxy.utils.http_utils import (
|
||
encode_mediaflow_proxy_url,
|
||
fetch_with_retry,
|
||
get_original_scheme,
|
||
ProxyRequestHeaders,
|
||
apply_header_manipulation,
|
||
)
|
||
from mediaflow_proxy.utils.mpd_utils import parse_sidx_fragments
|
||
from mediaflow_proxy.utils.dash_prebuffer import dash_prebuffer
|
||
from mediaflow_proxy.utils.cache_utils import get_cached_processed_init, set_cached_processed_init
|
||
from mediaflow_proxy.utils.m3u8_processor import SkipSegmentFilter
|
||
from mediaflow_proxy.remuxer.ts_muxer import remux_fmp4_to_ts
|
||
from mediaflow_proxy.configs import settings
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
def _resolve_ts_mode(request: Request) -> bool:
|
||
"""Resolve the effective TS remux mode from the request query params, falling back to settings."""
|
||
override = request.query_params.get("remux_to_ts")
|
||
if override is not None:
|
||
return override.lower() in ("true", "1", "yes")
|
||
return settings.remux_to_ts
|
||
|
||
|
||
def _resolve_nominal_duration_mpd_timescale(profile: dict, segments: list[dict]) -> int | None:
|
||
"""Resolve a stable nominal segment duration (MPD timescale units) for live sequence math."""
|
||
profile_duration = profile.get("nominal_duration_mpd_timescale")
|
||
if isinstance(profile_duration, (int, float)) and profile_duration > 0:
|
||
return int(profile_duration)
|
||
|
||
durations = []
|
||
for seg in segments:
|
||
seg_duration = seg.get("duration_mpd_timescale")
|
||
if isinstance(seg_duration, (int, float)) and seg_duration > 0:
|
||
durations.append(int(seg_duration))
|
||
|
||
if durations:
|
||
# Use median to avoid jumps when first/last live segments are shorter.
|
||
return int(statistics.median_low(durations))
|
||
return None
|
||
|
||
|
||
def _compute_live_media_sequence(first_segment: dict, profile: dict, segments: list[dict]) -> int:
|
||
"""
|
||
Compute a stable HLS media sequence for live playlists.
|
||
|
||
Strategy:
|
||
1) If MPD explicitly sets @startNumber, trust segment numbering.
|
||
2) Otherwise derive sequence from timeline time / nominal duration.
|
||
3) Fall back to segment number or template start number.
|
||
"""
|
||
segment_number = first_segment.get("number")
|
||
if profile.get("segment_template_start_number_explicit") and segment_number is not None:
|
||
return max(int(segment_number), 1)
|
||
|
||
timeline_time = first_segment.get("time")
|
||
nominal_duration = _resolve_nominal_duration_mpd_timescale(profile, segments)
|
||
if timeline_time is not None and nominal_duration and nominal_duration > 0:
|
||
return max(math.floor(int(timeline_time) / nominal_duration), 1)
|
||
|
||
if segment_number is not None:
|
||
return max(int(segment_number), 1)
|
||
|
||
template_start = profile.get("segment_template_start_number")
|
||
if isinstance(template_start, int) and template_start > 0:
|
||
return template_start
|
||
|
||
return 1
|
||
|
||
|
||
def _compute_live_playlist_depth(
|
||
is_ts_mode: bool,
|
||
effective_start_offset: float | None,
|
||
extinf_values: list[float],
|
||
) -> int:
|
||
"""
|
||
Compute a resilient live playlist depth to reduce segment expiry skips.
|
||
|
||
We keep a larger floor for fMP4 live (direct mode), and further expand
|
||
depth based on requested start_offset so players have enough headroom
|
||
during transient stalls.
|
||
"""
|
||
configured_depth = max(settings.mpd_live_playlist_depth, 1)
|
||
depth_floor = 20 if is_ts_mode else 15
|
||
depth = max(configured_depth, depth_floor)
|
||
|
||
if effective_start_offset is not None and effective_start_offset < 0:
|
||
if extinf_values:
|
||
segment_duration = statistics.median(extinf_values)
|
||
if segment_duration <= 0:
|
||
segment_duration = 4.0
|
||
else:
|
||
segment_duration = 4.0
|
||
|
||
segments_behind_live_edge = math.ceil(abs(effective_start_offset) / segment_duration)
|
||
safety_margin = 10 if is_ts_mode else 12
|
||
depth = max(depth, segments_behind_live_edge + safety_margin)
|
||
|
||
return max(depth, 1)
|
||
|
||
|
||
async def process_manifest(
|
||
request: Request,
|
||
mpd_dict: dict,
|
||
proxy_headers: ProxyRequestHeaders,
|
||
key_id: str = None,
|
||
key: str = None,
|
||
resolution: str = None,
|
||
skip_segments: list = None,
|
||
) -> Response:
|
||
"""
|
||
Processes the MPD manifest and converts it to an HLS manifest.
|
||
|
||
Args:
|
||
request (Request): The incoming HTTP request.
|
||
mpd_dict (dict): The MPD manifest data.
|
||
proxy_headers (ProxyRequestHeaders): The headers to include in the request.
|
||
key_id (str, optional): The DRM key ID. Defaults to None.
|
||
key (str, optional): The DRM key. Defaults to None.
|
||
resolution (str, optional): Target resolution (e.g., '1080p', '720p'). Defaults to None.
|
||
skip_segments (list, optional): List of time segments to skip. Each item should have 'start' and 'end' keys.
|
||
|
||
Returns:
|
||
Response: The HLS manifest as an HTTP response.
|
||
"""
|
||
hls_content = build_hls(mpd_dict, request, key_id, key, resolution, skip_segments)
|
||
|
||
# Start DASH pre-buffering in background if enabled
|
||
if settings.enable_dash_prebuffer:
|
||
# Extract headers for pre-buffering
|
||
headers = {}
|
||
for key, value in request.query_params.items():
|
||
if key.startswith("h_"):
|
||
headers[key[2:]] = value
|
||
|
||
# Get the original MPD URL from the request
|
||
mpd_url = request.query_params.get("d", "")
|
||
if mpd_url:
|
||
# Start pre-buffering in background
|
||
asyncio.create_task(dash_prebuffer.prebuffer_dash_manifest(mpd_url, headers))
|
||
|
||
return Response(content=hls_content, media_type="application/vnd.apple.mpegurl", headers=proxy_headers.response)
|
||
|
||
|
||
async def _expand_segment_base_segments(profiles: list, req_headers: dict) -> None:
|
||
"""
|
||
For SegmentBase profiles with a single segment that has an @indexRange, fetch the
|
||
SIDX box and expand the single segment entry into per-fragment segments.
|
||
|
||
This allows mpv/ffmpeg to seek by requesting only the relevant fragment from the
|
||
CDN instead of re-downloading the whole file from byte 938 every time.
|
||
|
||
Modifies *profiles* in-place; on any failure the original single-segment entry
|
||
is kept as a fallback.
|
||
"""
|
||
timeout = ClientTimeout(connect=10, sock_read=30, total=60)
|
||
|
||
for profile in profiles:
|
||
segments = profile.get("segments", [])
|
||
if len(segments) != 1:
|
||
continue
|
||
|
||
seg = segments[0]
|
||
index_range: str = seg.get("indexRange") or ""
|
||
media_url: str = seg.get("media") or ""
|
||
init_range: str = seg.get("initRange") or ""
|
||
|
||
if not index_range or not media_url:
|
||
continue # no SIDX info → keep single-segment fallback
|
||
|
||
try:
|
||
headers = dict(req_headers)
|
||
headers["Range"] = f"bytes={index_range}"
|
||
|
||
async with create_aiohttp_session(media_url, timeout=timeout) as (session, proxy_url):
|
||
response = await fetch_with_retry(session, "GET", media_url, headers, proxy=proxy_url)
|
||
sidx_bytes = await response.read()
|
||
|
||
index_range_start = int(index_range.split("-")[0])
|
||
fragments = parse_sidx_fragments(sidx_bytes, index_range_start)
|
||
|
||
if not fragments:
|
||
logger.warning(f"SIDX parse returned no fragments for {media_url}, keeping single-segment fallback")
|
||
continue
|
||
|
||
new_segments = [
|
||
{
|
||
"type": "segment",
|
||
"media": media_url,
|
||
"number": i + 1,
|
||
"extinf": frag["duration_timescale"] / frag["timescale"],
|
||
"initRange": init_range,
|
||
"mediaRange": f"{frag['start']}-{frag['end']}",
|
||
}
|
||
for i, frag in enumerate(fragments)
|
||
]
|
||
profile["segments"] = new_segments
|
||
logger.info(
|
||
f"SegmentBase SIDX expanded {media_url!r} → {len(new_segments)} fragments "
|
||
f"({new_segments[0]['extinf']:.3f}s each)"
|
||
)
|
||
|
||
except Exception as exc:
|
||
logger.warning(f"SIDX expansion failed for {media_url}: {exc}; keeping single-segment fallback")
|
||
|
||
|
||
async def process_playlist(
|
||
request: Request,
|
||
mpd_dict: dict,
|
||
profile_id: str,
|
||
proxy_headers: ProxyRequestHeaders,
|
||
skip_segments: list = None,
|
||
start_offset: float = None,
|
||
) -> Response:
|
||
"""
|
||
Processes the MPD manifest and converts it to an HLS playlist for a specific profile.
|
||
|
||
Args:
|
||
request (Request): The incoming HTTP request.
|
||
mpd_dict (dict): The MPD manifest data.
|
||
profile_id (str): The profile ID to generate the playlist for.
|
||
proxy_headers (ProxyRequestHeaders): The headers to include in the request.
|
||
skip_segments (list, optional): List of time segments to skip. Each item should have 'start' and 'end' keys.
|
||
start_offset (float, optional): Start offset in seconds for live streams.
|
||
|
||
Returns:
|
||
Response: The HLS playlist as an HTTP response.
|
||
|
||
Raises:
|
||
HTTPException: If the profile is not found in the MPD manifest.
|
||
"""
|
||
matching_profiles = [p for p in mpd_dict["profiles"] if p["id"] == profile_id]
|
||
if not matching_profiles:
|
||
raise HTTPException(status_code=404, detail="Profile not found")
|
||
|
||
# For SegmentBase profiles (single large file with SIDX), expand into per-fragment
|
||
# segments so that mpv/ffmpeg can seek by requesting only the relevant fragment.
|
||
await _expand_segment_base_segments(matching_profiles, dict(proxy_headers.request))
|
||
|
||
hls_content = build_hls_playlist(mpd_dict, matching_profiles, request, skip_segments, start_offset)
|
||
|
||
# Trigger prebuffering of upcoming segments for live streams
|
||
if settings.enable_dash_prebuffer and mpd_dict.get("isLive", False):
|
||
# Extract headers for pre-buffering
|
||
headers = {}
|
||
for key, value in request.query_params.items():
|
||
if key.startswith("h_"):
|
||
headers[key[2:]] = value
|
||
|
||
# Use the new prefetch method for live playlists
|
||
asyncio.create_task(dash_prebuffer.prefetch_for_live_playlist(matching_profiles, headers))
|
||
|
||
# Don't include propagate headers for playlists - they should only apply to segments
|
||
response_headers = apply_header_manipulation({}, proxy_headers, include_propagate=False)
|
||
return Response(content=hls_content, media_type="application/vnd.apple.mpegurl", headers=response_headers)
|
||
|
||
|
||
async def process_segment(
|
||
init_content: bytes,
|
||
segment_content: bytes,
|
||
mimetype: str,
|
||
proxy_headers: ProxyRequestHeaders,
|
||
key_id: str = None,
|
||
key: str = None,
|
||
use_map: bool = False,
|
||
remux_ts: bool = None,
|
||
) -> Response:
|
||
"""
|
||
Processes and decrypts a media segment, optionally remuxing to MPEG-TS.
|
||
|
||
Args:
|
||
init_content (bytes): The initialization segment content.
|
||
segment_content (bytes): The media segment content.
|
||
mimetype (str): The MIME type of the segment.
|
||
proxy_headers (ProxyRequestHeaders): The headers to include in the request.
|
||
key_id (str, optional): The DRM key ID. Defaults to None.
|
||
key (str, optional): The DRM key. Defaults to None.
|
||
use_map (bool, optional): If True, init segment is served separately via EXT-X-MAP,
|
||
so don't concatenate init with segment. Defaults to False.
|
||
remux_ts (bool, optional): If True, remux fMP4 to MPEG-TS. Defaults to settings.remux_to_ts.
|
||
|
||
Returns:
|
||
Response: The processed segment as an HTTP response.
|
||
"""
|
||
if key_id and key:
|
||
# For DRM protected content
|
||
now = time.time()
|
||
decrypted_content = decrypt_segment(init_content, segment_content, key_id, key, include_init=not use_map)
|
||
logger.info(f"Decryption of {mimetype} segment took {time.time() - now:.4f} seconds")
|
||
else:
|
||
# For non-DRM protected content
|
||
if use_map:
|
||
# Init is served separately via EXT-X-MAP
|
||
decrypted_content = segment_content
|
||
else:
|
||
# Concatenate init and segment content
|
||
decrypted_content = init_content + segment_content
|
||
|
||
# Check if we should remux to TS
|
||
should_remux = remux_ts if remux_ts is not None else settings.remux_to_ts
|
||
|
||
# Remux both video and audio to MPEG-TS for proper HLS TS playback
|
||
if should_remux and ("video" in mimetype or "audio" in mimetype):
|
||
# Remux fMP4 to MPEG-TS for ExoPlayer/VLC compatibility
|
||
now = time.time()
|
||
try:
|
||
# For TS remuxing, we always need init_content for codec config
|
||
# preserve_timestamps=True keeps the original tfdt timestamps from the
|
||
# fMP4 segment, ensuring continuous playback across HLS segments
|
||
ts_content = remux_fmp4_to_ts(
|
||
init_content,
|
||
decrypted_content,
|
||
preserve_timestamps=True,
|
||
)
|
||
decrypted_content = ts_content
|
||
mimetype = "video/mp2t" # Update MIME type for TS (same for audio-only TS)
|
||
logger.info(f"TS remuxing took {time.time() - now:.4f} seconds")
|
||
except Exception as e:
|
||
logger.warning(f"TS remuxing failed, returning fMP4: {e}")
|
||
# Fall through to return original content
|
||
|
||
response_headers = apply_header_manipulation({}, proxy_headers)
|
||
return Response(content=decrypted_content, media_type=mimetype, headers=response_headers)
|
||
|
||
|
||
async def process_init_segment(
|
||
init_content: bytes,
|
||
mimetype: str,
|
||
proxy_headers: ProxyRequestHeaders,
|
||
key_id: str = None,
|
||
key: str = None,
|
||
init_url: str = None,
|
||
) -> Response:
|
||
"""
|
||
Processes an initialization segment for EXT-X-MAP.
|
||
|
||
Args:
|
||
init_content (bytes): The initialization segment content.
|
||
mimetype (str): The MIME type of the segment.
|
||
proxy_headers (ProxyRequestHeaders): The headers to include in the request.
|
||
key_id (str, optional): The DRM key ID. Defaults to None.
|
||
key (str, optional): The DRM key. Defaults to None.
|
||
init_url (str, optional): The init URL for caching. Defaults to None.
|
||
|
||
Returns:
|
||
Response: The processed init segment as an HTTP response.
|
||
"""
|
||
if key_id and key:
|
||
# Check if we have a cached processed version
|
||
if init_url:
|
||
cached_processed = await get_cached_processed_init(init_url, key_id)
|
||
if cached_processed:
|
||
logger.debug(f"Using cached processed init segment for {init_url}")
|
||
response_headers = apply_header_manipulation({}, proxy_headers)
|
||
return Response(content=cached_processed, media_type=mimetype, headers=response_headers)
|
||
|
||
# For DRM protected content, we need to process the init segment
|
||
# to remove encryption-related boxes but keep the moov structure
|
||
now = time.time()
|
||
processed_content = process_drm_init_segment(init_content, key_id, key)
|
||
logger.info(f"Processing of {mimetype} init segment took {time.time() - now:.4f} seconds")
|
||
|
||
# Cache the processed init segment
|
||
if init_url:
|
||
await set_cached_processed_init(init_url, key_id, processed_content, ttl=3600)
|
||
else:
|
||
# For non-DRM protected content, just return the init segment as-is
|
||
processed_content = init_content
|
||
|
||
response_headers = apply_header_manipulation({}, proxy_headers)
|
||
return Response(content=processed_content, media_type=mimetype, headers=response_headers)
|
||
|
||
|
||
def build_hls(
|
||
mpd_dict: dict,
|
||
request: Request,
|
||
key_id: str = None,
|
||
key: str = None,
|
||
resolution: str = None,
|
||
skip_segments: list = None,
|
||
) -> str:
|
||
"""
|
||
Builds an HLS manifest from the MPD manifest.
|
||
|
||
Args:
|
||
mpd_dict (dict): The MPD manifest data.
|
||
request (Request): The incoming HTTP request.
|
||
key_id (str, optional): The DRM key ID. Defaults to None.
|
||
key (str, optional): The DRM key. Defaults to None.
|
||
resolution (str, optional): Target resolution (e.g., '1080p', '720p'). Defaults to None.
|
||
skip_segments (list, optional): List of time segments to skip. Each item should have 'start' and 'end' keys.
|
||
|
||
Returns:
|
||
str: The HLS manifest as a string.
|
||
"""
|
||
is_ts_mode = _resolve_ts_mode(request)
|
||
# Use HLS v3 for TS (ExoPlayer compatibility), v6 for fMP4
|
||
version = 3 if is_ts_mode else 6
|
||
hls = ["#EXTM3U", f"#EXT-X-VERSION:{version}"]
|
||
query_params = dict(request.query_params)
|
||
|
||
# Preserve skip parameter in query params so it propagates to playlists
|
||
if skip_segments:
|
||
# Convert back to compact format for URL
|
||
skip_str = ",".join(f"{s['start']}-{s['end']}" for s in skip_segments)
|
||
query_params["skip"] = skip_str
|
||
has_encrypted = query_params.pop("has_encrypted", False)
|
||
|
||
video_profiles = {}
|
||
audio_profiles = {}
|
||
|
||
# Get the base URL for the playlist_endpoint endpoint
|
||
proxy_url = request.url_for("playlist_endpoint")
|
||
proxy_url = str(proxy_url.replace(scheme=get_original_scheme(request)))
|
||
|
||
for profile in mpd_dict["profiles"]:
|
||
query_params.update({"profile_id": profile["id"], "key_id": key_id or "", "key": key or ""})
|
||
playlist_url = encode_mediaflow_proxy_url(
|
||
proxy_url,
|
||
query_params=query_params,
|
||
encryption_handler=encryption_handler if has_encrypted else None,
|
||
)
|
||
|
||
if "video" in profile["mimeType"]:
|
||
video_profiles[profile["id"]] = (profile, playlist_url)
|
||
elif "audio" in profile["mimeType"]:
|
||
audio_profiles[profile["id"]] = (profile, playlist_url)
|
||
|
||
# Filter video profiles by resolution if specified
|
||
if resolution and video_profiles:
|
||
video_profiles = _filter_video_profiles_by_resolution(video_profiles, resolution)
|
||
|
||
# For TS mode, only expose the highest quality video variant
|
||
# ExoPlayer handles adaptive switching poorly with TS remuxing
|
||
if is_ts_mode and video_profiles:
|
||
max_height = max(p[0].get("height", 0) for p in video_profiles.values())
|
||
video_profiles = {k: v for k, v in video_profiles.items() if v[0].get("height", 0) >= max_height}
|
||
|
||
if not is_ts_mode and video_profiles:
|
||
# Sort by bandwidth descending; keep highest bandwidth per unique rep_id
|
||
sorted_by_bw = sorted(video_profiles.values(), key=lambda pv: pv[0].get("bandwidth", 0), reverse=True)
|
||
seen_rep_ids = set()
|
||
deduped = []
|
||
for profile, playlist_url in sorted_by_bw:
|
||
rep_id = profile.get("rep_id", profile["id"])
|
||
if rep_id not in seen_rep_ids:
|
||
seen_rep_ids.add(rep_id)
|
||
deduped.append((profile, playlist_url))
|
||
|
||
if resolution:
|
||
# Explicit resolution: single matching variant
|
||
deduped = deduped[:1]
|
||
else:
|
||
# Limit ABR ladder: one entry per unique height, capped at MAX_VIDEO_VARIANTS.
|
||
# libavformat (mpv/ffmpeg) fetches ALL #EXT-X-STREAM-INF playlists and their
|
||
# init segments before playback regardless of BANDWIDTH hints —
|
||
# N variants = N × ~2 s of init-segment probing at startup.
|
||
MAX_VIDEO_VARIANTS = 5
|
||
seen_heights: set = set()
|
||
height_deduped = []
|
||
for p, url in deduped:
|
||
h = p.get("height", 0)
|
||
if h not in seen_heights:
|
||
seen_heights.add(h)
|
||
height_deduped.append((p, url))
|
||
deduped = height_deduped[:MAX_VIDEO_VARIANTS]
|
||
video_profiles = {p["id"]: (p, url) for p, url in deduped}
|
||
|
||
# Determine the default audio (English preferred, else highest bandwidth).
|
||
default_audio_id = None
|
||
if audio_profiles:
|
||
all_audio = list(audio_profiles.values())
|
||
en_audio = [(p, u) for p, u in all_audio if (p.get("lang") or "").startswith("en")]
|
||
default_profile, _ = max(en_audio or all_audio, key=lambda pu: pu[0].get("bandwidth", 0))
|
||
default_audio_id = default_profile["id"]
|
||
|
||
# Audio tracks: one entry per unique language, capped at MAX_AUDIO_TRACKS.
|
||
# Sort default track first (always within cap), then by bandwidth descending
|
||
# so the highest-quality codec per language wins.
|
||
# libavformat probes every #EXT-X-MEDIA entry regardless of DEFAULT/AUTOSELECT;
|
||
# capping at 4 keeps language selection while bounding startup probing.
|
||
MAX_AUDIO_TRACKS = 4
|
||
first_audio_codec = None
|
||
if audio_profiles:
|
||
sorted_audio = sorted(
|
||
audio_profiles.values(),
|
||
key=lambda pv: (pv[0]["id"] != default_audio_id, -pv[0].get("bandwidth", 0)),
|
||
)
|
||
seen_langs = set()
|
||
count = 0
|
||
for profile, playlist_url in sorted_audio:
|
||
if count >= MAX_AUDIO_TRACKS:
|
||
break
|
||
lang = profile.get("lang", "und")
|
||
if lang in seen_langs:
|
||
continue
|
||
seen_langs.add(lang)
|
||
is_default = profile["id"] == default_audio_id
|
||
default_attr = "YES" if is_default else "NO"
|
||
autoselect_attr = "YES" if is_default else "NO"
|
||
bandwidth = profile.get("bandwidth", 128000)
|
||
name = f"Audio {lang} ({bandwidth})" if lang != "und" else f"Audio 1 ({bandwidth})"
|
||
hls.append(
|
||
f'#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="audio",NAME="{name}",DEFAULT={default_attr},AUTOSELECT={autoselect_attr},LANGUAGE="{lang}",URI="{playlist_url}"'
|
||
)
|
||
if is_default:
|
||
first_audio_codec = profile.get("codecs", "")
|
||
count += 1
|
||
|
||
# Build combined codecs string (video + audio) for EXT-X-STREAM-INF
|
||
# ExoPlayer requires CODECS to list all codecs when AUDIO group is referenced
|
||
|
||
# Add video streams
|
||
for profile, playlist_url in video_profiles.values():
|
||
# Only add AUDIO attribute if there are audio profiles available
|
||
audio_attr = ',AUDIO="audio"' if audio_profiles else ""
|
||
|
||
# Build combined codecs: video + audio
|
||
video_codec = profile["codecs"]
|
||
if first_audio_codec and audio_attr:
|
||
combined_codecs = f"{video_codec},{first_audio_codec}"
|
||
else:
|
||
combined_codecs = video_codec
|
||
|
||
# Keep full codec strings (e.g., avc1.42C01F, mp4a.40.2) for ALL modes.
|
||
# ExoPlayer's CodecSpecificDataUtil rejects simplified strings like "avc1" or "mp4a"
|
||
# as malformed, which prevents proper codec initialization.
|
||
|
||
# Omit FRAME-RATE for TS mode (ExoPlayer compatibility)
|
||
if is_ts_mode:
|
||
hls.append(
|
||
f'#EXT-X-STREAM-INF:BANDWIDTH={profile["bandwidth"]},RESOLUTION={profile["width"]}x{profile["height"]},CODECS="{combined_codecs}"{audio_attr}'
|
||
)
|
||
else:
|
||
hls.append(
|
||
f'#EXT-X-STREAM-INF:BANDWIDTH={profile["bandwidth"]},RESOLUTION={profile["width"]}x{profile["height"]},CODECS="{combined_codecs}",FRAME-RATE={profile["frameRate"]}{audio_attr}'
|
||
)
|
||
hls.append(playlist_url)
|
||
|
||
return "\n".join(hls)
|
||
|
||
|
||
def _filter_video_profiles_by_resolution(video_profiles: dict, target_resolution: str) -> dict:
|
||
"""
|
||
Filter video profiles to select the one matching the target resolution.
|
||
Falls back to closest lower resolution if exact match not found.
|
||
|
||
Args:
|
||
video_profiles: Dictionary of profile_id -> (profile, playlist_url).
|
||
target_resolution: Target resolution string (e.g., '1080p', '720p').
|
||
|
||
Returns:
|
||
Filtered dictionary with only the selected profile.
|
||
"""
|
||
# Parse target height from "1080p" -> 1080
|
||
target_height = int(target_resolution.rstrip("p"))
|
||
|
||
# Convert to list and sort by height descending
|
||
profiles_list = [
|
||
(profile_id, profile, playlist_url)
|
||
for profile_id, (profile, playlist_url) in video_profiles.items()
|
||
if profile.get("height", 0) > 0
|
||
]
|
||
|
||
if not profiles_list:
|
||
logger.warning("No video profiles with valid height found, returning all profiles")
|
||
return video_profiles
|
||
|
||
sorted_profiles = sorted(profiles_list, key=lambda x: x[1]["height"], reverse=True)
|
||
|
||
# Find exact match or closest lower
|
||
selected = None
|
||
for profile_id, profile, playlist_url in sorted_profiles:
|
||
if profile["height"] <= target_height:
|
||
selected = (profile_id, profile, playlist_url)
|
||
break
|
||
|
||
# If all profiles are higher than target, use lowest available
|
||
if selected is None:
|
||
selected = sorted_profiles[-1]
|
||
|
||
profile_id, profile, playlist_url = selected
|
||
logger.info(
|
||
f"Selected MPD video profile with resolution {profile['width']}x{profile['height']} for target {target_resolution}"
|
||
)
|
||
|
||
return {profile_id: (profile, playlist_url)}
|
||
|
||
|
||
def build_hls_playlist(
|
||
mpd_dict: dict, profiles: list[dict], request: Request, skip_segments: list = None, start_offset: float = None
|
||
) -> str:
|
||
"""
|
||
Builds an HLS playlist from the MPD manifest for specific profiles.
|
||
|
||
Args:
|
||
mpd_dict (dict): The MPD manifest data.
|
||
profiles (list[dict]): The profiles to include in the playlist.
|
||
request (Request): The incoming HTTP request.
|
||
skip_segments (list, optional): List of time segments to skip. Each item should have 'start' and 'end' keys.
|
||
start_offset (float, optional): Start offset in seconds for live streams. Defaults to settings.livestream_start_offset for live.
|
||
|
||
Returns:
|
||
str: The HLS playlist as a string.
|
||
"""
|
||
# Determine if we're in TS remux mode (per-request override > global setting)
|
||
is_ts_mode = _resolve_ts_mode(request)
|
||
|
||
# Use HLS v3 for TS (ExoPlayer compatibility), v6 for fMP4
|
||
version = 3 if is_ts_mode else 6
|
||
hls = ["#EXTM3U", f"#EXT-X-VERSION:{version}"]
|
||
|
||
added_segments = 0
|
||
skipped_segments = 0
|
||
is_live = mpd_dict.get("isLive", False)
|
||
|
||
# Inject EXT-X-START for live streams (enables prebuffering by starting behind live edge)
|
||
# User-provided start_offset always takes precedence; otherwise use default for live streams only
|
||
if is_ts_mode and is_live and start_offset is None:
|
||
# TS mode needs a larger buffer for ExoPlayer
|
||
effective_start_offset = -30.0
|
||
else:
|
||
effective_start_offset = (
|
||
start_offset if start_offset is not None else (settings.livestream_start_offset if is_live else None)
|
||
)
|
||
if effective_start_offset is not None:
|
||
# ExoPlayer doesn't handle PRECISE=YES well with TS
|
||
precise = "NO" if is_ts_mode else "YES"
|
||
hls.append(f"#EXT-X-START:TIME-OFFSET={effective_start_offset:.1f},PRECISE={precise}")
|
||
|
||
# Initialize skip filter if skip_segments provided
|
||
skip_filter = SkipSegmentFilter(skip_segments) if skip_segments else None
|
||
|
||
# In TS mode, we don't use EXT-X-MAP because TS segments are self-contained
|
||
# (PAT/PMT/VPS/SPS/PPS are embedded in each segment).
|
||
# Use EXT-X-MAP for:
|
||
# - live fMP4 streams (init changes with discontinuities)
|
||
# - SegmentBase fMP4 (init and media are different byte ranges of the same file;
|
||
# without EXT-X-MAP every segment would redundantly include the moov box)
|
||
has_segment_base = not is_ts_mode and any(p.get("initRange") for p in profiles)
|
||
use_map = not is_ts_mode and (is_live or has_segment_base)
|
||
|
||
# Select appropriate endpoint based on remux mode
|
||
if is_ts_mode:
|
||
proxy_url = request.url_for("segment_ts_endpoint") # /mpd/segment.ts
|
||
else:
|
||
proxy_url = request.url_for("segment_endpoint") # /mpd/segment.mp4
|
||
proxy_url = str(proxy_url.replace(scheme=get_original_scheme(request)))
|
||
|
||
# Get init endpoint URL for EXT-X-MAP (only used for fMP4 mode)
|
||
init_proxy_url = request.url_for("init_endpoint")
|
||
init_proxy_url = str(init_proxy_url.replace(scheme=get_original_scheme(request)))
|
||
|
||
# Merge segments from all periods into a single ordered list.
|
||
# Multi-period MPDs can produce multiple profiles with the same unique_id (one per period);
|
||
# depth trimming must be applied to the combined segment list, not per-period.
|
||
merged: list[tuple[dict, dict]] = []
|
||
for profile in profiles:
|
||
if not profile["segments"]:
|
||
logger.warning(f"No segments found for profile {profile['id']}")
|
||
continue
|
||
for seg in profile["segments"]:
|
||
merged.append((seg, profile))
|
||
|
||
if not merged:
|
||
return "\n".join(hls)
|
||
|
||
if is_live:
|
||
extinf_values_for_depth = [e[0]["extinf"] for e in merged if "extinf" in e[0]]
|
||
depth = _compute_live_playlist_depth(is_ts_mode, effective_start_offset, extinf_values_for_depth)
|
||
merged = merged[-depth:]
|
||
|
||
# Emit playlist headers using the first trimmed segment's profile
|
||
first_segment, first_profile = merged[0]
|
||
extinf_values = [e[0]["extinf"] for e in merged if "extinf" in e[0]]
|
||
|
||
if is_ts_mode:
|
||
target_duration = int(max(extinf_values)) + 1 if extinf_values else 10
|
||
else:
|
||
target_duration = math.ceil(max(extinf_values)) if extinf_values else 3
|
||
|
||
if is_live:
|
||
sequence = _compute_live_media_sequence(first_segment, first_profile, [e[0] for e in merged])
|
||
else:
|
||
mpd_start_number = first_profile.get("segment_template_start_number")
|
||
sequence = first_segment.get("number")
|
||
if sequence is None:
|
||
sequence = mpd_start_number if mpd_start_number is not None else 1
|
||
|
||
hls.extend(
|
||
[
|
||
f"#EXT-X-TARGETDURATION:{target_duration}",
|
||
f"#EXT-X-MEDIA-SEQUENCE:{sequence}",
|
||
]
|
||
)
|
||
# For live streams, don't set PLAYLIST-TYPE to allow sliding window
|
||
if not is_live:
|
||
hls.append("#EXT-X-PLAYLIST-TYPE:VOD")
|
||
|
||
query_params = dict(request.query_params)
|
||
query_params.pop("profile_id", None)
|
||
query_params.pop("d", None)
|
||
query_params.pop("remux_to_ts", None) # per-request override; already resolved into endpoint choice
|
||
has_encrypted = query_params.pop("has_encrypted", False)
|
||
|
||
current_init_url = None # track to detect period boundaries and re-emit EXT-X-MAP
|
||
need_discontinuity = False
|
||
|
||
for segment, profile in merged:
|
||
duration = segment["extinf"]
|
||
init_url = profile["initUrl"]
|
||
# For SegmentBase profiles, we may have byte range for initialization segment
|
||
init_range = profile.get("initRange")
|
||
|
||
# Check if this segment should be skipped
|
||
if skip_filter:
|
||
if skip_filter.should_skip_segment(duration):
|
||
skip_filter.advance_time(duration)
|
||
skipped_segments += 1
|
||
need_discontinuity = True
|
||
continue
|
||
skip_filter.advance_time(duration)
|
||
|
||
# Emit EXT-X-MAP when init URL changes (first segment or period boundary in fMP4 mode)
|
||
if use_map and init_url != current_init_url:
|
||
if current_init_url is not None:
|
||
# Period boundary: insert discontinuity before new init
|
||
hls.append("#EXT-X-DISCONTINUITY")
|
||
need_discontinuity = False
|
||
current_init_url = init_url
|
||
init_query_params = {
|
||
"init_url": init_url,
|
||
"mime_type": profile["mimeType"],
|
||
"is_live": "true" if is_live else "false",
|
||
}
|
||
if init_range:
|
||
init_query_params["init_range"] = init_range
|
||
# Add key parameters
|
||
if query_params.get("key_id"):
|
||
init_query_params["key_id"] = query_params["key_id"]
|
||
if query_params.get("key"):
|
||
init_query_params["key"] = query_params["key"]
|
||
# Add api_password for authentication
|
||
if query_params.get("api_password"):
|
||
init_query_params["api_password"] = query_params["api_password"]
|
||
|
||
for k, v in query_params.items():
|
||
if k.startswith("rp_") or k.startswith("h_"):
|
||
init_query_params[k] = v
|
||
|
||
init_map_url = encode_mediaflow_proxy_url(
|
||
init_proxy_url,
|
||
query_params=init_query_params,
|
||
encryption_handler=encryption_handler if has_encrypted else None,
|
||
)
|
||
hls.append(f'#EXT-X-MAP:URI="{init_map_url}"')
|
||
|
||
# Add discontinuity marker after skipped segments
|
||
if need_discontinuity:
|
||
hls.append("#EXT-X-DISCONTINUITY")
|
||
need_discontinuity = False
|
||
|
||
# Emit EXT-X-PROGRAM-DATE-TIME only for fMP4 (not TS)
|
||
program_date_time = segment.get("program_date_time")
|
||
if program_date_time and not is_ts_mode:
|
||
hls.append(f"#EXT-X-PROGRAM-DATE-TIME:{program_date_time}")
|
||
hls.append(f"#EXTINF:{duration:.3f},")
|
||
|
||
segment_query_params = {
|
||
"init_url": init_url,
|
||
"segment_url": segment["media"],
|
||
"mime_type": profile["mimeType"],
|
||
"is_live": "true" if is_live else "false",
|
||
}
|
||
|
||
# Add use_map flag so segment endpoint knows not to include init
|
||
if use_map and not is_ts_mode:
|
||
segment_query_params["use_map"] = "true"
|
||
elif is_ts_mode:
|
||
# TS segments are self-contained; init is always embedded by remuxer
|
||
segment_query_params["use_map"] = "false"
|
||
|
||
# Add byte range parameters for SegmentBase
|
||
if init_range:
|
||
segment_query_params["init_range"] = init_range
|
||
# Segment may also have its own range (for SegmentBase)
|
||
if "initRange" in segment:
|
||
segment_query_params["init_range"] = segment["initRange"]
|
||
# Media byte range: bytes after the init segment (SegmentBase only)
|
||
if segment.get("mediaRange"):
|
||
segment_query_params["segment_range"] = segment["mediaRange"]
|
||
|
||
query_params.update(segment_query_params)
|
||
hls.append(
|
||
encode_mediaflow_proxy_url(
|
||
proxy_url,
|
||
query_params=query_params,
|
||
encryption_handler=encryption_handler if has_encrypted else None,
|
||
)
|
||
)
|
||
added_segments += 1
|
||
|
||
if not mpd_dict["isLive"]:
|
||
hls.append("#EXT-X-ENDLIST")
|
||
|
||
if skip_filter and skipped_segments > 0:
|
||
logger.info(f"Added {added_segments} segments to HLS playlist (skipped {skipped_segments} segments)")
|
||
else:
|
||
logger.info(f"Added {added_segments} segments to HLS playlist")
|
||
return "\n".join(hls)
|