Files
UnHided/mediaflow_proxy/mpd_processor.py
T
UrloMythus bd208c63ff new version
2026-05-19 20:28:26 +02:00

824 lines
34 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)