2025-10-29 20:23:50 +01:00
|
|
|
|
# streaming_providers/base/utils/mpd_rewriter.py
|
2026-02-14 08:48:14 +01:00
|
|
|
|
"""
|
|
|
|
|
|
MPD rewriter for DASH manifests.
|
|
|
|
|
|
Handles URL proxying, DRM key injection, quality filtering, and representation blocklisting.
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
2025-10-29 20:23:50 +01:00
|
|
|
|
import base64
|
2026-02-13 16:50:09 +01:00
|
|
|
|
import struct
|
2026-01-06 16:41:03 +01:00
|
|
|
|
import xml.etree.ElementTree as ET
|
2026-03-06 13:40:57 +01:00
|
|
|
|
from typing import Optional, Tuple, Set, Dict
|
2026-02-14 18:40:17 +01:00
|
|
|
|
from urllib.parse import urljoin, quote, urlencode
|
2026-01-25 12:03:57 +01:00
|
|
|
|
from datetime import datetime, timezone
|
|
|
|
|
|
from email.utils import parsedate_to_datetime
|
2026-01-06 16:41:03 +01:00
|
|
|
|
|
2025-10-29 20:23:50 +01:00
|
|
|
|
from .logger import logger
|
2026-02-14 08:48:14 +01:00
|
|
|
|
from .url_resolver import URLResolver
|
2026-03-06 13:40:57 +01:00
|
|
|
|
from .drm_key_manager import KeyConfiguration
|
|
|
|
|
|
from .representation_blocklist import RepresentationBlocklist
|
|
|
|
|
|
from .video_quality import VideoQualityFilter, VideoRepresentation
|
|
|
|
|
|
from .time_utils import parse_iso_duration
|
2026-03-05 14:22:32 +01:00
|
|
|
|
from ..models.drm.constants import DRM_SYSTEM_NAMES
|
2025-10-29 20:23:50 +01:00
|
|
|
|
|
2026-01-29 17:20:48 +01:00
|
|
|
|
|
2025-10-29 20:23:50 +01:00
|
|
|
|
class MPDRewriter:
|
2026-01-06 16:41:03 +01:00
|
|
|
|
MPD_NAMESPACE = {"mpd": "urn:mpeg:dash:schema:mpd:2011"}
|
2026-01-27 11:57:39 +01:00
|
|
|
|
CENC_NAMESPACE = {"cenc": "urn:mpeg:cenc:2013"}
|
2025-10-29 20:23:50 +01:00
|
|
|
|
|
2026-03-05 14:22:32 +01:00
|
|
|
|
# Derive from shared DRM constants — format raw hex UUID as urn:uuid:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
|
|
|
|
|
|
_ck_hex = DRM_SYSTEM_NAMES["CLEARKEY"] # e2719d58a985b3c9781ab030af78d30e
|
|
|
|
|
|
CLEARKEY_SCHEME_URI = (
|
|
|
|
|
|
f"urn:uuid:{_ck_hex[0:8]}-{_ck_hex[8:12]}-{_ck_hex[12:16]}-{_ck_hex[16:20]}-{_ck_hex[20:32]}"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-01-23 15:52:35 +01:00
|
|
|
|
def __init__(
|
2026-01-27 11:57:39 +01:00
|
|
|
|
self,
|
|
|
|
|
|
media_proxy_url: str,
|
|
|
|
|
|
provider_proxy_url: Optional[str] = None,
|
|
|
|
|
|
clearkey_keyids: Optional[dict] = None,
|
2026-02-14 08:48:14 +01:00
|
|
|
|
highest_quality_video_only: bool = False,
|
|
|
|
|
|
provider: Optional[str] = None,
|
|
|
|
|
|
channel: Optional[str] = None,
|
|
|
|
|
|
blocklist_path: str = "representation_blocklist.json",
|
2026-03-05 14:22:32 +01:00
|
|
|
|
clearkey_receiver_side: bool = False,
|
2026-03-29 21:46:57 +02:00
|
|
|
|
segment_headers: Optional[Dict[str, str]] = None,
|
2026-01-23 15:52:35 +01:00
|
|
|
|
):
|
2026-01-20 15:26:41 +01:00
|
|
|
|
self.media_proxy_url = media_proxy_url.rstrip("/")
|
|
|
|
|
|
self.provider_proxy_url = provider_proxy_url
|
2026-01-27 11:57:39 +01:00
|
|
|
|
self.key_config = KeyConfiguration(clearkey_keyids or {})
|
2026-01-29 17:20:48 +01:00
|
|
|
|
self.highest_quality_video_only = highest_quality_video_only
|
2026-03-05 14:22:32 +01:00
|
|
|
|
self.clearkey_receiver_side = clearkey_receiver_side
|
|
|
|
|
|
|
|
|
|
|
|
if self.clearkey_receiver_side and not self.key_config.keys:
|
2026-03-06 11:03:28 +01:00
|
|
|
|
logger.warning(
|
|
|
|
|
|
"clearkey_receiver_side=True but no keys provided; receiver will have no keys to decrypt with")
|
2026-01-27 11:57:39 +01:00
|
|
|
|
|
2026-02-14 08:48:14 +01:00
|
|
|
|
# Blocklist configuration
|
2026-01-30 15:50:47 +01:00
|
|
|
|
self.provider = provider
|
|
|
|
|
|
self.channel = channel
|
|
|
|
|
|
self.blocklist = RepresentationBlocklist(blocklist_path)
|
|
|
|
|
|
|
2026-01-25 12:03:57 +01:00
|
|
|
|
# Pre-calculate query params that don't change to save cycles during rewrite
|
|
|
|
|
|
self._static_params = {}
|
|
|
|
|
|
if self.provider_proxy_url:
|
|
|
|
|
|
self._static_params["proxy"] = self.provider_proxy_url
|
2026-03-29 21:46:57 +02:00
|
|
|
|
if segment_headers:
|
|
|
|
|
|
import json
|
|
|
|
|
|
encoded = base64.urlsafe_b64encode(
|
|
|
|
|
|
json.dumps(segment_headers).encode()
|
|
|
|
|
|
).decode().rstrip("=")
|
|
|
|
|
|
self._static_params["headers"] = encoded
|
2025-10-29 20:23:50 +01:00
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def encode_url(url: str) -> str:
|
2026-01-25 12:03:57 +01:00
|
|
|
|
return base64.urlsafe_b64encode(url.encode("utf-8")).decode("utf-8").rstrip("=")
|
2025-10-29 20:23:50 +01:00
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def decode_url(encoded: str) -> str:
|
2026-01-20 15:26:41 +01:00
|
|
|
|
padding = 4 - (len(encoded) % 4)
|
|
|
|
|
|
if padding != 4:
|
|
|
|
|
|
encoded += "=" * padding
|
2026-01-06 16:41:03 +01:00
|
|
|
|
return base64.urlsafe_b64decode(encoded.encode("utf-8")).decode("utf-8")
|
2025-10-29 20:23:50 +01:00
|
|
|
|
|
2026-01-23 15:52:35 +01:00
|
|
|
|
def build_proxy_url(
|
2026-01-27 11:57:39 +01:00
|
|
|
|
self,
|
|
|
|
|
|
original_url: str,
|
|
|
|
|
|
template_pattern: Optional[str] = None,
|
|
|
|
|
|
segment_type: Optional[str] = None,
|
|
|
|
|
|
is_encrypted: bool = False,
|
2026-02-14 08:48:14 +01:00
|
|
|
|
kid: Optional[str] = None,
|
|
|
|
|
|
representation_id: Optional[str] = None,
|
2026-01-23 15:52:35 +01:00
|
|
|
|
) -> str:
|
2026-01-25 12:03:57 +01:00
|
|
|
|
params = {"url": original_url, **self._static_params}
|
2026-01-23 15:52:35 +01:00
|
|
|
|
|
2026-03-05 14:22:32 +01:00
|
|
|
|
if self.key_config.keys and is_encrypted and not self.clearkey_receiver_side:
|
2026-01-27 11:57:39 +01:00
|
|
|
|
if self.key_config.single_key_mode:
|
|
|
|
|
|
# Single key mode: use the default key for everything
|
|
|
|
|
|
if segment_type == "initialization":
|
|
|
|
|
|
params["kid"] = self.key_config.default_kid
|
|
|
|
|
|
elif segment_type == "media":
|
|
|
|
|
|
params["key"] = self.key_config.default_key
|
|
|
|
|
|
else:
|
|
|
|
|
|
params["kid"] = self.key_config.default_kid
|
|
|
|
|
|
params["key"] = self.key_config.default_key
|
2026-01-25 12:03:57 +01:00
|
|
|
|
else:
|
2026-01-27 11:57:39 +01:00
|
|
|
|
# Multi-key mode: use specific KID if provided
|
|
|
|
|
|
if kid and kid in self.key_config.keys:
|
|
|
|
|
|
key = self.key_config.keys[kid]
|
|
|
|
|
|
if segment_type == "initialization":
|
|
|
|
|
|
params["kid"] = kid
|
|
|
|
|
|
elif segment_type == "media":
|
|
|
|
|
|
params["key"] = key
|
|
|
|
|
|
else:
|
|
|
|
|
|
params["kid"] = kid
|
|
|
|
|
|
params["key"] = key
|
|
|
|
|
|
else:
|
2026-02-14 08:48:14 +01:00
|
|
|
|
# Fallback to first key
|
2026-01-27 11:57:39 +01:00
|
|
|
|
if segment_type == "initialization":
|
|
|
|
|
|
params["kid"] = self.key_config.default_kid
|
|
|
|
|
|
elif segment_type == "media":
|
|
|
|
|
|
params["key"] = self.key_config.default_key
|
|
|
|
|
|
else:
|
|
|
|
|
|
params["kid"] = self.key_config.default_kid
|
|
|
|
|
|
params["key"] = self.key_config.default_key
|
|
|
|
|
|
logger.warning(f"No KID provided for encrypted segment, using fallback key")
|
2026-01-23 15:52:35 +01:00
|
|
|
|
|
2026-01-25 12:03:57 +01:00
|
|
|
|
encoded = self.encode_url(urlencode(params))
|
2026-03-05 14:22:32 +01:00
|
|
|
|
decrypt_server_side = self.key_config.keys and is_encrypted and not self.clearkey_receiver_side
|
|
|
|
|
|
endpoint = "decrypt" if decrypt_server_side else "proxy"
|
2026-07-22 14:25:00 +02:00
|
|
|
|
proxy_url = f"{self.media_proxy_url}/api/{endpoint}/{encoded}"
|
2026-01-20 15:26:41 +01:00
|
|
|
|
|
2025-10-29 20:23:50 +01:00
|
|
|
|
if template_pattern:
|
2026-02-14 08:48:14 +01:00
|
|
|
|
# Substitute $RepresentationID$ if we have it and highest_quality_video_only is enabled
|
2026-01-29 17:20:48 +01:00
|
|
|
|
if self.highest_quality_video_only and representation_id and "$RepresentationID$" in template_pattern:
|
|
|
|
|
|
template_pattern = template_pattern.replace("$RepresentationID$", representation_id)
|
|
|
|
|
|
|
2026-03-06 14:49:02 +01:00
|
|
|
|
proxy_url += f"/{quote(template_pattern, safe='.-_$%')}"
|
2026-01-20 15:26:41 +01:00
|
|
|
|
|
2025-10-29 20:23:50 +01:00
|
|
|
|
return proxy_url
|
|
|
|
|
|
|
|
|
|
|
|
def rewrite_mpd(self, mpd_content: str, manifest_url: str) -> str:
|
|
|
|
|
|
try:
|
|
|
|
|
|
root = ET.fromstring(mpd_content)
|
2026-01-06 16:41:03 +01:00
|
|
|
|
ET.register_namespace("", self.MPD_NAMESPACE["mpd"])
|
2026-03-06 11:03:28 +01:00
|
|
|
|
ET.register_namespace("cenc", self.CENC_NAMESPACE["cenc"])
|
2025-10-29 20:23:50 +01:00
|
|
|
|
|
2026-02-14 08:48:14 +01:00
|
|
|
|
# Extract MPD-level base URL using shared utility
|
2026-01-27 17:22:30 +01:00
|
|
|
|
mpd_base_url = self._extract_mpd_base_url(root, manifest_url)
|
|
|
|
|
|
|
|
|
|
|
|
# Single-pass tree preparation with BaseURL extraction
|
|
|
|
|
|
encrypted_ids, as_id_to_kid, base_url_map = self._prepare_tree_and_extract_kids(root, mpd_base_url)
|
2025-10-29 20:23:50 +01:00
|
|
|
|
|
2026-03-06 10:18:00 +01:00
|
|
|
|
# FIRST: Filter out encrypted AdaptationSets without available keys.
|
|
|
|
|
|
# Only applies to server-side decrypt mode — we can only decrypt what
|
|
|
|
|
|
# we have keys for, so remove sets whose KID we don't have a key for.
|
|
|
|
|
|
#
|
|
|
|
|
|
# Proxy-only mode (no keys): pass everything through unchanged.
|
|
|
|
|
|
# The proxy just forwards bytes; the player uses its own DRM stack
|
|
|
|
|
|
# (Widevine/PlayReady) to decrypt. Stripping encrypted sets in proxy
|
|
|
|
|
|
# mode would break multi-period manifests where the main content is
|
|
|
|
|
|
# encrypted and only a short free bumper is unencrypted.
|
|
|
|
|
|
#
|
|
|
|
|
|
# Receiver-side clearkey (keys + clearkey_receiver_side): keep all
|
|
|
|
|
|
# sets; receiver will decrypt using injected keys.
|
2026-03-05 14:22:32 +01:00
|
|
|
|
if self.key_config.keys and not self.clearkey_receiver_side:
|
|
|
|
|
|
# Server-side decrypt: remove sets we can't decrypt
|
2026-01-27 11:57:39 +01:00
|
|
|
|
self._remove_adaptationsets_without_keys(root, as_id_to_kid)
|
2026-01-21 12:48:42 +01:00
|
|
|
|
|
2026-01-30 15:50:47 +01:00
|
|
|
|
# SECOND: Filter out blocked representations that cause 500 errors
|
|
|
|
|
|
if self.provider and self.channel:
|
|
|
|
|
|
self._remove_blocked_representations(root)
|
|
|
|
|
|
|
2026-01-29 17:20:48 +01:00
|
|
|
|
# THEN: Filter to highest quality video from remaining decryptable content
|
|
|
|
|
|
best_video_info = None
|
|
|
|
|
|
if self.highest_quality_video_only:
|
2026-03-06 13:40:57 +01:00
|
|
|
|
best_video_info = VideoQualityFilter.find_best_video(root)
|
2026-01-29 17:20:48 +01:00
|
|
|
|
if best_video_info:
|
|
|
|
|
|
logger.info(
|
|
|
|
|
|
f"Filtered to highest quality video: {best_video_info.width}x{best_video_info.height} "
|
|
|
|
|
|
f"@ {best_video_info.bandwidth}bps, RepID={best_video_info.representation_id}"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-03-06 13:40:57 +01:00
|
|
|
|
# Remove all other video representations, keep only the best one
|
|
|
|
|
|
self._filter_to_single_best_video(root, best_video_info)
|
|
|
|
|
|
|
2026-01-27 11:57:39 +01:00
|
|
|
|
# Verify we have playable content remaining
|
|
|
|
|
|
remaining_sets = root.findall(".//mpd:AdaptationSet", self.MPD_NAMESPACE)
|
|
|
|
|
|
if not remaining_sets:
|
|
|
|
|
|
raise ValueError("No AdaptationSets remain after key filtering - manifest would be empty")
|
|
|
|
|
|
|
2026-01-27 17:22:30 +01:00
|
|
|
|
# Rewrite URLs with appropriate keys and context-aware base URLs
|
2026-01-29 17:20:48 +01:00
|
|
|
|
self._rewrite_node(root, mpd_base_url, encrypted_ids, as_id_to_kid, base_url_map,
|
|
|
|
|
|
False, None, "", best_video_info)
|
2025-10-29 20:23:50 +01:00
|
|
|
|
|
2026-01-06 16:41:03 +01:00
|
|
|
|
rewritten = ET.tostring(root, encoding="unicode", method="xml")
|
|
|
|
|
|
if not rewritten.startswith("<?xml"):
|
2025-10-29 20:23:50 +01:00
|
|
|
|
rewritten = '<?xml version="1.0" encoding="UTF-8"?>\n' + rewritten
|
|
|
|
|
|
return rewritten
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"Failed to rewrite MPD: {e}")
|
|
|
|
|
|
raise
|
|
|
|
|
|
|
2026-03-06 13:40:57 +01:00
|
|
|
|
def _filter_to_single_best_video(self, root: ET.Element, best_video: VideoRepresentation):
|
2026-01-29 17:20:48 +01:00
|
|
|
|
"""
|
2026-03-06 13:40:57 +01:00
|
|
|
|
Remove all video representations except the best one.
|
|
|
|
|
|
Keeps audio/subtitles unchanged.
|
2026-01-29 17:20:48 +01:00
|
|
|
|
"""
|
|
|
|
|
|
removal_count = 0
|
2026-03-06 13:40:57 +01:00
|
|
|
|
adaptation_sets_to_remove = []
|
|
|
|
|
|
|
2026-01-29 17:20:48 +01:00
|
|
|
|
for period in root.findall(".//mpd:Period", self.MPD_NAMESPACE):
|
|
|
|
|
|
period_id = period.get("id", "")
|
|
|
|
|
|
|
|
|
|
|
|
for adaptation_set in period.findall("mpd:AdaptationSet", self.MPD_NAMESPACE):
|
2026-03-06 13:40:57 +01:00
|
|
|
|
if not VideoQualityFilter.is_video_adaptation_set(adaptation_set):
|
2026-01-29 17:20:48 +01:00
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
as_id = adaptation_set.get("id", str(id(adaptation_set)))
|
|
|
|
|
|
|
|
|
|
|
|
# Check if this adaptation set contains the best representation
|
|
|
|
|
|
representations_to_remove = []
|
|
|
|
|
|
contains_best = False
|
|
|
|
|
|
|
|
|
|
|
|
for representation in adaptation_set.findall("mpd:Representation", self.MPD_NAMESPACE):
|
|
|
|
|
|
rep_id = representation.get("id", "")
|
|
|
|
|
|
|
|
|
|
|
|
# Is this the best representation?
|
|
|
|
|
|
if (period_id == best_video.period_id and
|
|
|
|
|
|
as_id == best_video.as_id and
|
|
|
|
|
|
rep_id == best_video.representation_id):
|
|
|
|
|
|
contains_best = True
|
|
|
|
|
|
else:
|
|
|
|
|
|
representations_to_remove.append(representation)
|
|
|
|
|
|
|
|
|
|
|
|
# Remove non-best representations
|
|
|
|
|
|
for rep in representations_to_remove:
|
|
|
|
|
|
adaptation_set.remove(rep)
|
|
|
|
|
|
removal_count += 1
|
|
|
|
|
|
|
|
|
|
|
|
# If this adaptation set no longer has any representations, mark for removal
|
|
|
|
|
|
if not contains_best:
|
|
|
|
|
|
adaptation_sets_to_remove.append(adaptation_set)
|
|
|
|
|
|
|
2026-03-06 13:40:57 +01:00
|
|
|
|
# Remove empty video adaptation sets
|
|
|
|
|
|
for period in root.findall(".//mpd:Period", self.MPD_NAMESPACE):
|
|
|
|
|
|
for adaptation_set in list(period.findall("mpd:AdaptationSet", self.MPD_NAMESPACE)):
|
|
|
|
|
|
if adaptation_set in adaptation_sets_to_remove:
|
|
|
|
|
|
period.remove(adaptation_set)
|
2026-01-29 17:20:48 +01:00
|
|
|
|
|
|
|
|
|
|
logger.info(f"Removed {removal_count} video representation(s), kept highest quality only")
|
2026-03-05 15:59:09 +01:00
|
|
|
|
|
|
|
|
|
|
# Clean up SupplementalProperty elements that reference removed AdaptationSet IDs.
|
|
|
|
|
|
# These are used for codec-switching hints (e.g. AVC↔HEVC) and become dangling
|
|
|
|
|
|
# after we discard all but one video AdaptationSet.
|
2026-03-06 13:40:57 +01:00
|
|
|
|
self._cleanup_codec_switch_properties(root)
|
|
|
|
|
|
|
|
|
|
|
|
def _cleanup_codec_switch_properties(self, root: ET.Element):
|
|
|
|
|
|
"""Remove or update SupplementalProperty elements for codec switching."""
|
2026-03-05 15:59:09 +01:00
|
|
|
|
CODEC_SWITCH_SCHEME = "urn:mpeg:dash:adaptation-set-switching:2016"
|
2026-03-06 13:40:57 +01:00
|
|
|
|
|
|
|
|
|
|
# Collect all surviving AdaptationSet IDs
|
2026-03-05 15:59:09 +01:00
|
|
|
|
surviving_as_ids: Set[str] = set()
|
|
|
|
|
|
for period in root.findall(".//mpd:Period", self.MPD_NAMESPACE):
|
|
|
|
|
|
for adaptation_set in period.findall("mpd:AdaptationSet", self.MPD_NAMESPACE):
|
|
|
|
|
|
as_id = adaptation_set.get("id")
|
|
|
|
|
|
if as_id:
|
|
|
|
|
|
surviving_as_ids.add(as_id)
|
|
|
|
|
|
|
2026-03-06 13:40:57 +01:00
|
|
|
|
# Clean up SupplementalProperty elements
|
2026-03-05 15:59:09 +01:00
|
|
|
|
for period in root.findall(".//mpd:Period", self.MPD_NAMESPACE):
|
|
|
|
|
|
for adaptation_set in period.findall("mpd:AdaptationSet", self.MPD_NAMESPACE):
|
|
|
|
|
|
for sp in list(adaptation_set.findall("mpd:SupplementalProperty", self.MPD_NAMESPACE)):
|
|
|
|
|
|
if sp.get("schemeIdUri") == CODEC_SWITCH_SCHEME:
|
|
|
|
|
|
raw_value = sp.get("value", "")
|
|
|
|
|
|
# Keep only IDs that still exist in the tree
|
|
|
|
|
|
kept = [
|
|
|
|
|
|
v.strip()
|
|
|
|
|
|
for v in raw_value.split(",")
|
|
|
|
|
|
if v.strip() in surviving_as_ids
|
|
|
|
|
|
]
|
|
|
|
|
|
if not kept:
|
|
|
|
|
|
# No surviving peers — remove the property entirely
|
|
|
|
|
|
adaptation_set.remove(sp)
|
|
|
|
|
|
else:
|
|
|
|
|
|
sp.set("value", ",".join(kept))
|
|
|
|
|
|
|
2026-01-29 17:20:48 +01:00
|
|
|
|
def _prepare_tree_and_extract_kids(self, root: ET.Element, mpd_base_url: str) -> Tuple[
|
|
|
|
|
|
Set[str], Dict[str, str], Dict[str, str]]:
|
2026-01-27 11:57:39 +01:00
|
|
|
|
"""
|
2026-01-27 17:22:30 +01:00
|
|
|
|
Single-pass optimization: clean tree, identify encrypted sets, extract KIDs and BaseURLs.
|
|
|
|
|
|
Returns: (encrypted_adaptation_set_ids, as_id_to_kid_mapping, base_url_mapping)
|
2026-01-27 11:57:39 +01:00
|
|
|
|
"""
|
2026-01-25 12:03:57 +01:00
|
|
|
|
encrypted_ids = set()
|
2026-01-27 11:57:39 +01:00
|
|
|
|
as_id_to_kid = {}
|
2026-01-27 17:22:30 +01:00
|
|
|
|
base_url_map = {} # Maps period_id:as_id -> resolved base URL
|
2026-01-27 11:57:39 +01:00
|
|
|
|
|
|
|
|
|
|
# Process all Periods (handles multi-period manifests correctly)
|
|
|
|
|
|
for period in root.findall(".//mpd:Period", self.MPD_NAMESPACE):
|
|
|
|
|
|
period_id = period.get("id", "")
|
|
|
|
|
|
|
2026-01-27 17:22:30 +01:00
|
|
|
|
# Extract Period-level BaseURL BEFORE removing it
|
|
|
|
|
|
period_base_url = mpd_base_url
|
|
|
|
|
|
period_base_elem = period.find("mpd:BaseURL", self.MPD_NAMESPACE)
|
|
|
|
|
|
if period_base_elem is not None and period_base_elem.text:
|
|
|
|
|
|
period_base_text = period_base_elem.text.strip()
|
2026-03-05 17:00:19 +01:00
|
|
|
|
period_base_url = self._urljoin_preserve_query(mpd_base_url, period_base_text)
|
2026-01-27 17:22:30 +01:00
|
|
|
|
logger.debug(f"Period {period_id} BaseURL: {period_base_url}")
|
|
|
|
|
|
|
2026-03-06 11:03:28 +01:00
|
|
|
|
# Store period-level base URL in base_url_map with a special key
|
|
|
|
|
|
period_key = f"period_{period_id}" if period_id else "period_root"
|
|
|
|
|
|
base_url_map[period_key] = period_base_url
|
|
|
|
|
|
|
2026-01-27 11:57:39 +01:00
|
|
|
|
# Remove Period-level BaseURL elements
|
|
|
|
|
|
for bu in list(period.findall("mpd:BaseURL", self.MPD_NAMESPACE)):
|
|
|
|
|
|
period.remove(bu)
|
|
|
|
|
|
|
|
|
|
|
|
for adaptation_set in period.findall("mpd:AdaptationSet", self.MPD_NAMESPACE):
|
|
|
|
|
|
as_id = adaptation_set.get("id")
|
|
|
|
|
|
if not as_id:
|
|
|
|
|
|
as_id = str(id(adaptation_set))
|
|
|
|
|
|
|
|
|
|
|
|
unique_id = f"{period_id}_{as_id}" if period_id else as_id
|
|
|
|
|
|
|
2026-01-27 17:22:30 +01:00
|
|
|
|
# Extract AdaptationSet-level BaseURL BEFORE removing it
|
|
|
|
|
|
as_base_url = period_base_url
|
|
|
|
|
|
as_base_elem = adaptation_set.find("mpd:BaseURL", self.MPD_NAMESPACE)
|
|
|
|
|
|
if as_base_elem is not None and as_base_elem.text:
|
|
|
|
|
|
as_base_text = as_base_elem.text.strip()
|
2026-03-05 17:00:19 +01:00
|
|
|
|
as_base_url = self._urljoin_preserve_query(period_base_url, as_base_text)
|
2026-01-27 17:22:30 +01:00
|
|
|
|
logger.debug(f"AdaptationSet {unique_id} BaseURL: {as_base_url}")
|
|
|
|
|
|
|
|
|
|
|
|
# Store the resolved base URL for this AdaptationSet
|
|
|
|
|
|
base_url_map[unique_id] = as_base_url
|
|
|
|
|
|
|
2026-01-27 11:57:39 +01:00
|
|
|
|
# Remove AdaptationSet-level BaseURL elements
|
|
|
|
|
|
for bu in list(adaptation_set.findall("mpd:BaseURL", self.MPD_NAMESPACE)):
|
|
|
|
|
|
adaptation_set.remove(bu)
|
|
|
|
|
|
|
2026-01-27 17:22:30 +01:00
|
|
|
|
# Check if encrypted
|
|
|
|
|
|
cp_elements = adaptation_set.findall("mpd:ContentProtection", self.MPD_NAMESPACE)
|
|
|
|
|
|
has_content_protection = len(cp_elements) > 0
|
2026-01-27 11:57:39 +01:00
|
|
|
|
|
2026-01-27 17:22:30 +01:00
|
|
|
|
# Also check Representation-level ContentProtection
|
|
|
|
|
|
for representation in adaptation_set.findall("mpd:Representation", self.MPD_NAMESPACE):
|
|
|
|
|
|
rep_cp = representation.findall("mpd:ContentProtection", self.MPD_NAMESPACE)
|
|
|
|
|
|
if rep_cp:
|
|
|
|
|
|
has_content_protection = True
|
|
|
|
|
|
break
|
|
|
|
|
|
|
|
|
|
|
|
if has_content_protection:
|
2026-01-27 11:57:39 +01:00
|
|
|
|
encrypted_ids.add(unique_id)
|
|
|
|
|
|
|
2026-01-27 17:22:30 +01:00
|
|
|
|
# Extract KID
|
|
|
|
|
|
extracted_kid = self._extract_kid_from_adaptationset(adaptation_set)
|
|
|
|
|
|
if extracted_kid:
|
|
|
|
|
|
as_id_to_kid[unique_id] = extracted_kid
|
2026-07-25 15:40:41 +02:00
|
|
|
|
# logger.debug(f"AdaptationSet {unique_id} KID: {extracted_kid[:8]}...")
|
2026-01-27 17:22:30 +01:00
|
|
|
|
|
2026-03-05 14:22:32 +01:00
|
|
|
|
# Handle ContentProtection elements based on mode:
|
|
|
|
|
|
# - server-side decrypt: strip all (player sees plain stream)
|
|
|
|
|
|
# - receiver-side clearkey: replace with ClearKey signaling so player can decrypt
|
|
|
|
|
|
# - proxy-only (no keys): preserve as-is so player can handle DRM itself
|
|
|
|
|
|
if self.key_config.keys and not self.clearkey_receiver_side:
|
|
|
|
|
|
# Server-side decrypt mode: strip everything
|
|
|
|
|
|
for cp in list(adaptation_set.findall("mpd:ContentProtection", self.MPD_NAMESPACE)):
|
|
|
|
|
|
adaptation_set.remove(cp)
|
|
|
|
|
|
for representation in adaptation_set.findall("mpd:Representation", self.MPD_NAMESPACE):
|
|
|
|
|
|
for cp in list(representation.findall("mpd:ContentProtection", self.MPD_NAMESPACE)):
|
|
|
|
|
|
representation.remove(cp)
|
|
|
|
|
|
|
|
|
|
|
|
elif self.clearkey_receiver_side and has_content_protection:
|
|
|
|
|
|
# Receiver-side clearkey mode: strip original DRM signaling, inject ClearKey
|
|
|
|
|
|
for cp in list(adaptation_set.findall("mpd:ContentProtection", self.MPD_NAMESPACE)):
|
|
|
|
|
|
adaptation_set.remove(cp)
|
|
|
|
|
|
for representation in adaptation_set.findall("mpd:Representation", self.MPD_NAMESPACE):
|
|
|
|
|
|
for cp in list(representation.findall("mpd:ContentProtection", self.MPD_NAMESPACE)):
|
|
|
|
|
|
representation.remove(cp)
|
|
|
|
|
|
|
|
|
|
|
|
# Inject ClearKey ContentProtection element
|
|
|
|
|
|
kid = as_id_to_kid.get(unique_id)
|
|
|
|
|
|
self._inject_clearkey_content_protection(adaptation_set, kid)
|
|
|
|
|
|
|
|
|
|
|
|
# else: proxy-only, no keys — leave ContentProtection untouched
|
2026-01-27 17:22:30 +01:00
|
|
|
|
|
|
|
|
|
|
return encrypted_ids, as_id_to_kid, base_url_map
|
|
|
|
|
|
|
|
|
|
|
|
def _extract_kid_from_adaptationset(self, adaptation_set: ET.Element) -> Optional[str]:
|
2026-02-14 08:48:14 +01:00
|
|
|
|
"""Extract KID from ContentProtection elements in an AdaptationSet."""
|
|
|
|
|
|
# Try cenc:default_KID first
|
2026-01-27 17:22:30 +01:00
|
|
|
|
for cp in adaptation_set.findall("mpd:ContentProtection", self.MPD_NAMESPACE):
|
2026-02-14 08:48:14 +01:00
|
|
|
|
default_kid = cp.get("{urn:mpeg:cenc:2013}default_KID")
|
|
|
|
|
|
if default_kid:
|
2026-02-14 11:44:15 +01:00
|
|
|
|
normalized = default_kid.replace("-", "").lower()
|
2026-07-25 15:40:41 +02:00
|
|
|
|
# logger.debug(f"Extracted KID from default_KID attribute: {normalized}")
|
2026-02-14 11:44:15 +01:00
|
|
|
|
return normalized
|
2026-01-27 11:57:39 +01:00
|
|
|
|
|
2026-02-14 08:48:14 +01:00
|
|
|
|
# Try PSSH box
|
|
|
|
|
|
for cp in adaptation_set.findall("mpd:ContentProtection", self.MPD_NAMESPACE):
|
|
|
|
|
|
pssh_elem = cp.find("cenc:pssh", self.CENC_NAMESPACE)
|
|
|
|
|
|
if pssh_elem is not None and pssh_elem.text:
|
2026-01-27 11:57:39 +01:00
|
|
|
|
try:
|
2026-02-14 08:48:14 +01:00
|
|
|
|
pssh_data = base64.b64decode(pssh_elem.text)
|
2026-02-14 11:44:15 +01:00
|
|
|
|
logger.debug(f"PSSH box size: {len(pssh_data)} bytes")
|
|
|
|
|
|
|
|
|
|
|
|
if len(pssh_data) >= 36:
|
|
|
|
|
|
version = pssh_data[8] if len(pssh_data) > 8 else 0
|
|
|
|
|
|
system_id = pssh_data[12:28].hex() if len(pssh_data) >= 28 else "unknown"
|
|
|
|
|
|
logger.debug(f"PSSH version: {version}, System ID: {system_id}")
|
|
|
|
|
|
|
|
|
|
|
|
if version > 0:
|
2026-02-13 16:50:09 +01:00
|
|
|
|
kid_count = struct.unpack(">I", pssh_data[28:32])[0]
|
2026-02-14 11:44:15 +01:00
|
|
|
|
logger.debug(f"KID count from header: {kid_count}")
|
|
|
|
|
|
|
|
|
|
|
|
if kid_count > 0 and len(pssh_data) >= 48:
|
|
|
|
|
|
kid_bytes = pssh_data[32:48]
|
|
|
|
|
|
kid_hex = kid_bytes.hex().lower()
|
2026-07-25 15:40:41 +02:00
|
|
|
|
# logger.debug(f"Extracted KID from PSSH header: {kid_hex}")
|
2026-02-14 11:44:15 +01:00
|
|
|
|
return kid_hex
|
2026-02-14 07:45:37 +01:00
|
|
|
|
except Exception as e:
|
2026-02-14 11:44:15 +01:00
|
|
|
|
logger.debug(f"Error extracting KID from PSSH: {e}")
|
2026-01-27 11:57:39 +01:00
|
|
|
|
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
2026-03-05 14:22:32 +01:00
|
|
|
|
def _inject_clearkey_content_protection(self, adaptation_set: ET.Element, kid: Optional[str]):
|
|
|
|
|
|
"""
|
|
|
|
|
|
Inject a ClearKey ContentProtection element so the receiver can decrypt.
|
|
|
|
|
|
|
|
|
|
|
|
If a KID is available (extracted from the original ContentProtection), it is
|
|
|
|
|
|
formatted as a UUID and set as cenc:default_KID. Otherwise only the scheme URI
|
|
|
|
|
|
is signaled and the receiver must derive the KID from the stream itself.
|
|
|
|
|
|
"""
|
|
|
|
|
|
ET.register_namespace("cenc", "urn:mpeg:cenc:2013")
|
|
|
|
|
|
|
|
|
|
|
|
attribs = {"schemeIdUri": self.CLEARKEY_SCHEME_URI}
|
|
|
|
|
|
|
|
|
|
|
|
if kid:
|
|
|
|
|
|
# Format 32-char hex KID as UUID (xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx)
|
|
|
|
|
|
kid_uuid = f"{kid[0:8]}-{kid[8:12]}-{kid[12:16]}-{kid[16:20]}-{kid[20:32]}"
|
|
|
|
|
|
attribs["{urn:mpeg:cenc:2013}default_KID"] = kid_uuid
|
|
|
|
|
|
logger.debug(f"Injecting ClearKey ContentProtection with KID={kid_uuid}")
|
|
|
|
|
|
else:
|
|
|
|
|
|
logger.debug("Injecting ClearKey ContentProtection without KID (not found in original)")
|
|
|
|
|
|
|
|
|
|
|
|
cp_elem = ET.Element("ContentProtection", attribs)
|
|
|
|
|
|
# Insert at the front of the AdaptationSet so players encounter it early
|
|
|
|
|
|
adaptation_set.insert(0, cp_elem)
|
|
|
|
|
|
|
2026-01-27 17:22:30 +01:00
|
|
|
|
def _remove_adaptationsets_without_keys(self, root: ET.Element, as_id_to_kid: Dict[str, str]):
|
2026-02-14 08:48:14 +01:00
|
|
|
|
"""Remove encrypted AdaptationSets for which we don't have keys."""
|
|
|
|
|
|
removed_count = 0
|
2026-02-14 07:24:57 +01:00
|
|
|
|
|
2026-02-14 11:44:15 +01:00
|
|
|
|
# Debug: Log what keys we have
|
2026-07-25 15:47:01 +02:00
|
|
|
|
# logger.debug(f"Available keys: {list(self.key_config.keys.keys())}")
|
2026-02-14 11:44:15 +01:00
|
|
|
|
|
2026-01-27 11:57:39 +01:00
|
|
|
|
for period in root.findall(".//mpd:Period", self.MPD_NAMESPACE):
|
|
|
|
|
|
period_id = period.get("id", "")
|
|
|
|
|
|
adaptationsets_to_remove = []
|
|
|
|
|
|
|
|
|
|
|
|
for adaptation_set in period.findall("mpd:AdaptationSet", self.MPD_NAMESPACE):
|
2026-02-14 08:48:14 +01:00
|
|
|
|
as_id = adaptation_set.get("id", str(id(adaptation_set)))
|
2026-01-27 11:57:39 +01:00
|
|
|
|
unique_id = f"{period_id}_{as_id}" if period_id else as_id
|
|
|
|
|
|
|
|
|
|
|
|
if unique_id in as_id_to_kid:
|
2026-02-14 08:48:14 +01:00
|
|
|
|
kid = as_id_to_kid[unique_id]
|
|
|
|
|
|
if kid not in self.key_config.keys:
|
2026-02-14 11:44:15 +01:00
|
|
|
|
logger.warning(
|
|
|
|
|
|
f"Removing AdaptationSet {unique_id} - "
|
|
|
|
|
|
f"missing key for KID: {kid[:8]}..."
|
2026-01-27 11:57:39 +01:00
|
|
|
|
)
|
|
|
|
|
|
adaptationsets_to_remove.append(adaptation_set)
|
2026-02-14 08:48:14 +01:00
|
|
|
|
removed_count += 1
|
2026-01-27 11:57:39 +01:00
|
|
|
|
|
|
|
|
|
|
for adaptation_set in adaptationsets_to_remove:
|
|
|
|
|
|
period.remove(adaptation_set)
|
|
|
|
|
|
|
2026-02-14 08:48:14 +01:00
|
|
|
|
if removed_count > 0:
|
|
|
|
|
|
logger.info(f"Removed {removed_count} AdaptationSet(s) without available keys")
|
2026-01-27 11:57:39 +01:00
|
|
|
|
|
2026-01-30 15:50:47 +01:00
|
|
|
|
def _remove_blocked_representations(self, root: ET.Element):
|
2026-02-14 08:48:14 +01:00
|
|
|
|
"""Remove representations that are blocklisted for this provider/channel."""
|
2026-01-30 15:50:47 +01:00
|
|
|
|
blocked_ids = self.blocklist.get_blocked_ids(self.provider, self.channel)
|
2026-02-14 08:48:14 +01:00
|
|
|
|
|
2026-01-30 15:50:47 +01:00
|
|
|
|
if not blocked_ids:
|
|
|
|
|
|
return
|
|
|
|
|
|
|
2026-02-14 08:48:14 +01:00
|
|
|
|
logger.info(
|
|
|
|
|
|
f"Applying representation blocklist for {self.provider}/{self.channel}: "
|
|
|
|
|
|
f"{len(blocked_ids)} representation(s) blocked"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-01-30 15:50:47 +01:00
|
|
|
|
total_reps_removed = 0
|
|
|
|
|
|
total_as_removed = 0
|
|
|
|
|
|
|
|
|
|
|
|
for period in root.findall(".//mpd:Period", self.MPD_NAMESPACE):
|
|
|
|
|
|
adaptationsets_to_remove = []
|
|
|
|
|
|
|
|
|
|
|
|
for adaptation_set in period.findall("mpd:AdaptationSet", self.MPD_NAMESPACE):
|
|
|
|
|
|
representations = adaptation_set.findall("mpd:Representation", self.MPD_NAMESPACE)
|
|
|
|
|
|
representations_to_remove = []
|
|
|
|
|
|
|
|
|
|
|
|
# Check each representation in this AdaptationSet
|
|
|
|
|
|
for representation in representations:
|
|
|
|
|
|
rep_id = representation.get("id", "")
|
|
|
|
|
|
|
|
|
|
|
|
if rep_id in blocked_ids:
|
|
|
|
|
|
logger.info(
|
|
|
|
|
|
f"Blocking representation '{rep_id}' for {self.provider}/{self.channel} "
|
|
|
|
|
|
f"(known to cause 500 errors)"
|
|
|
|
|
|
)
|
|
|
|
|
|
representations_to_remove.append(representation)
|
|
|
|
|
|
|
|
|
|
|
|
# If all representations are blocked, mark the entire AdaptationSet for removal
|
|
|
|
|
|
if representations_to_remove and len(representations_to_remove) == len(representations):
|
|
|
|
|
|
as_id = adaptation_set.get("id", "unknown")
|
|
|
|
|
|
logger.info(
|
|
|
|
|
|
f"Removing entire AdaptationSet '{as_id}' - "
|
|
|
|
|
|
f"all {len(representations)} representation(s) are blocked"
|
|
|
|
|
|
)
|
|
|
|
|
|
adaptationsets_to_remove.append(adaptation_set)
|
|
|
|
|
|
total_as_removed += 1
|
|
|
|
|
|
else:
|
|
|
|
|
|
# Remove only the blocked representations
|
|
|
|
|
|
for representation in representations_to_remove:
|
|
|
|
|
|
adaptation_set.remove(representation)
|
|
|
|
|
|
total_reps_removed += 1
|
|
|
|
|
|
|
|
|
|
|
|
# Remove marked AdaptationSets
|
|
|
|
|
|
for adaptation_set in adaptationsets_to_remove:
|
|
|
|
|
|
period.remove(adaptation_set)
|
|
|
|
|
|
|
|
|
|
|
|
if total_reps_removed > 0 or total_as_removed > 0:
|
|
|
|
|
|
logger.info(
|
|
|
|
|
|
f"Blocklist filtering complete: removed {total_reps_removed} representation(s) "
|
|
|
|
|
|
f"and {total_as_removed} AdaptationSet(s) for {self.provider}/{self.channel}"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-01-25 12:03:57 +01:00
|
|
|
|
def _rewrite_node(
|
2026-01-27 11:57:39 +01:00
|
|
|
|
self,
|
|
|
|
|
|
element: ET.Element,
|
|
|
|
|
|
base_url: str,
|
|
|
|
|
|
encrypted_ids: Set[str],
|
|
|
|
|
|
as_id_to_kid: Dict[str, str],
|
2026-01-27 17:22:30 +01:00
|
|
|
|
base_url_map: Dict[str, str],
|
2026-01-27 11:57:39 +01:00
|
|
|
|
current_encrypted: bool,
|
|
|
|
|
|
current_kid: Optional[str] = None,
|
|
|
|
|
|
current_period_id: str = "",
|
2026-02-14 08:48:14 +01:00
|
|
|
|
best_video_info: Optional[VideoRepresentation] = None,
|
2026-01-25 12:03:57 +01:00
|
|
|
|
):
|
2026-01-27 17:22:30 +01:00
|
|
|
|
"""Recursive node rewriter with KID-aware key selection and context-aware base URLs."""
|
2026-01-27 11:57:39 +01:00
|
|
|
|
# Track period ID as we traverse
|
|
|
|
|
|
if element.tag.endswith("Period"):
|
|
|
|
|
|
current_period_id = element.get("id", "")
|
|
|
|
|
|
|
2026-03-06 12:11:37 +01:00
|
|
|
|
# When entering a new period, check if we have a period-level base URL stored
|
2026-03-06 11:03:28 +01:00
|
|
|
|
period_key = f"period_{current_period_id}" if current_period_id else "period_root"
|
|
|
|
|
|
if period_key in base_url_map:
|
|
|
|
|
|
# Update base_url to the period-specific base URL
|
|
|
|
|
|
base_url = base_url_map[period_key]
|
2026-07-25 15:32:20 +02:00
|
|
|
|
# logger.debug(f"Period {current_period_id} using stored base URL: {base_url}")
|
2026-03-06 11:03:28 +01:00
|
|
|
|
|
2026-03-06 11:43:47 +01:00
|
|
|
|
# Process all children of this period
|
|
|
|
|
|
for child in list(element):
|
|
|
|
|
|
self._rewrite_node(
|
|
|
|
|
|
child, base_url, encrypted_ids, as_id_to_kid, base_url_map,
|
|
|
|
|
|
current_encrypted, current_kid, current_period_id, best_video_info
|
|
|
|
|
|
)
|
|
|
|
|
|
return # Don't process further - we've handled all children
|
|
|
|
|
|
|
2026-03-06 12:11:37 +01:00
|
|
|
|
# Track representation ID for template substitution
|
2026-02-14 08:48:14 +01:00
|
|
|
|
current_rep_id = None
|
2026-03-06 12:11:37 +01:00
|
|
|
|
if element.tag.endswith("Representation"):
|
|
|
|
|
|
current_rep_id = element.get("id", "")
|
|
|
|
|
|
|
|
|
|
|
|
# Update state when entering an AdaptationSet
|
2026-01-25 12:03:57 +01:00
|
|
|
|
if element.tag.endswith("AdaptationSet"):
|
|
|
|
|
|
as_id = element.get("id", str(id(element)))
|
2026-01-27 11:57:39 +01:00
|
|
|
|
# Use same unique ID logic as _prepare_tree_and_extract_kids
|
|
|
|
|
|
unique_id = f"{current_period_id}_{as_id}" if current_period_id else as_id
|
|
|
|
|
|
current_encrypted = unique_id in encrypted_ids
|
2026-01-25 12:03:57 +01:00
|
|
|
|
|
2026-01-27 17:22:30 +01:00
|
|
|
|
# Update base_url to the AdaptationSet-specific base URL
|
|
|
|
|
|
if unique_id in base_url_map:
|
|
|
|
|
|
base_url = base_url_map[unique_id]
|
2026-07-25 15:32:20 +02:00
|
|
|
|
# logger.debug(f"AdaptationSet {unique_id} using stored base URL: {base_url}")
|
2026-01-27 17:22:30 +01:00
|
|
|
|
|
2026-01-27 11:57:39 +01:00
|
|
|
|
# Get specific KID for this AdaptationSet (multi-key mode only)
|
|
|
|
|
|
if current_encrypted and not self.key_config.single_key_mode:
|
|
|
|
|
|
current_kid = as_id_to_kid.get(unique_id)
|
|
|
|
|
|
|
2026-03-06 12:11:37 +01:00
|
|
|
|
# Handle BaseURL elements - THESE MUST BE REWRITTEN TO PROXY URLS
|
2026-03-06 11:50:08 +01:00
|
|
|
|
if element.tag.endswith("BaseURL") and element.text:
|
|
|
|
|
|
raw_url = element.text.strip()
|
|
|
|
|
|
if raw_url:
|
|
|
|
|
|
# Resolve the relative BaseURL against the current base
|
|
|
|
|
|
resolved_cdn_url = self._urljoin_preserve_query(base_url, raw_url)
|
|
|
|
|
|
|
|
|
|
|
|
# Rewrite the BaseURL text to a proxy URL
|
2026-03-06 12:11:37 +01:00
|
|
|
|
# In receiver-side clearkey mode, this will use /api/proxy/
|
2026-03-06 11:50:08 +01:00
|
|
|
|
element.text = self.build_proxy_url(
|
|
|
|
|
|
resolved_cdn_url, None, None,
|
|
|
|
|
|
current_encrypted, current_kid,
|
|
|
|
|
|
representation_id=current_rep_id
|
|
|
|
|
|
)
|
2026-01-29 17:20:48 +01:00
|
|
|
|
|
2026-03-05 20:44:45 +01:00
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
# Standard SegmentTemplate / SegmentList attribute rewriting.
|
|
|
|
|
|
# Handles media/initialization/sourceURL attributes on SegmentTemplate,
|
|
|
|
|
|
# SegmentList, etc.
|
|
|
|
|
|
#
|
2026-03-05 21:00:14 +01:00
|
|
|
|
attr_map = {
|
|
|
|
|
|
"media": "media",
|
|
|
|
|
|
"initialization": "initialization",
|
|
|
|
|
|
"sourceURL": None,
|
|
|
|
|
|
}
|
2025-10-29 20:23:50 +01:00
|
|
|
|
|
2026-03-05 21:00:14 +01:00
|
|
|
|
for attr, seg_type in attr_map.items():
|
|
|
|
|
|
if attr in element.attrib:
|
|
|
|
|
|
val = element.attrib[attr]
|
|
|
|
|
|
if not val:
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
resolved = self._urljoin_preserve_query(base_url, val)
|
|
|
|
|
|
if "$" in resolved:
|
|
|
|
|
|
# Use shared utility for splitting template URLs
|
|
|
|
|
|
path, pattern = URLResolver.split_template_url(resolved)
|
|
|
|
|
|
element.attrib[attr] = self.build_proxy_url(
|
|
|
|
|
|
path, pattern, seg_type, current_encrypted, current_kid,
|
|
|
|
|
|
representation_id=current_rep_id
|
|
|
|
|
|
)
|
|
|
|
|
|
else:
|
|
|
|
|
|
element.attrib[attr] = self.build_proxy_url(
|
|
|
|
|
|
resolved, None, seg_type, current_encrypted, current_kid,
|
|
|
|
|
|
representation_id=current_rep_id
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# Handle SegmentURL (always 'media' type)
|
|
|
|
|
|
if element.tag.endswith("SegmentURL") and "media" in element.attrib:
|
|
|
|
|
|
resolved = self._urljoin_preserve_query(base_url, element.attrib["media"])
|
|
|
|
|
|
path, pattern = (
|
|
|
|
|
|
URLResolver.split_template_url(resolved)
|
|
|
|
|
|
if "$" in resolved
|
|
|
|
|
|
else (resolved, None)
|
|
|
|
|
|
)
|
|
|
|
|
|
element.attrib["media"] = self.build_proxy_url(
|
|
|
|
|
|
path, pattern, "media", current_encrypted, current_kid,
|
|
|
|
|
|
representation_id=current_rep_id
|
|
|
|
|
|
)
|
2026-01-25 12:03:57 +01:00
|
|
|
|
|
2026-03-06 14:37:20 +01:00
|
|
|
|
# Recurse to children
|
2026-01-25 12:03:57 +01:00
|
|
|
|
for child in element:
|
2026-01-27 11:57:39 +01:00
|
|
|
|
self._rewrite_node(
|
2026-01-27 17:22:30 +01:00
|
|
|
|
child, base_url, encrypted_ids, as_id_to_kid, base_url_map,
|
2026-02-14 08:48:14 +01:00
|
|
|
|
current_encrypted, current_kid, current_period_id, best_video_info
|
2026-01-27 11:57:39 +01:00
|
|
|
|
)
|
2025-10-29 20:23:50 +01:00
|
|
|
|
|
2026-03-05 17:00:19 +01:00
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _urljoin_preserve_query(base: str, url: str) -> str:
|
|
|
|
|
|
"""
|
|
|
|
|
|
Like urljoin(base, url) but preserves the query string of *base* when
|
|
|
|
|
|
the relative *url* itself carries no query string.
|
|
|
|
|
|
|
|
|
|
|
|
Why this is needed:
|
|
|
|
|
|
Some CDNs (e.g. Discovery+/Max) embed the auth token in the manifest
|
|
|
|
|
|
URL's query string (e.g. ?manifest-params=…). The segment paths
|
|
|
|
|
|
inside such manifests are plain relative paths like "v/0_9cd1a5/v13.mp4"
|
|
|
|
|
|
with no query component. Standard urljoin() discards the base query
|
|
|
|
|
|
string when joining with a relative path (RFC 3986 §5.3 – correct
|
|
|
|
|
|
behaviour in general, wrong here). The CDN requires the same token on
|
|
|
|
|
|
every segment request, so we must re-append it.
|
|
|
|
|
|
|
|
|
|
|
|
Rules (mirrors RFC 3986 §5.3 reference-resolution):
|
|
|
|
|
|
- If *url* is absolute (has a scheme) → plain urljoin, no query merging.
|
|
|
|
|
|
- If *url* already has its own query string → plain urljoin; the
|
|
|
|
|
|
relative URL's query takes full precedence.
|
|
|
|
|
|
- If *url* has no query string and *base* does → urljoin then re-attach
|
|
|
|
|
|
base's query string to the result.
|
|
|
|
|
|
"""
|
|
|
|
|
|
from urllib.parse import urlparse, urlunparse
|
|
|
|
|
|
|
|
|
|
|
|
parsed_url = urlparse(url)
|
|
|
|
|
|
|
2026-03-05 21:27:43 +01:00
|
|
|
|
# Absolute URL: standard behaviour, no query merging.
|
|
|
|
|
|
if parsed_url.scheme:
|
|
|
|
|
|
return urljoin(base, url)
|
|
|
|
|
|
|
|
|
|
|
|
# URL contains $ template variables (e.g. $RepresentationID$, $Number$):
|
|
|
|
|
|
# these are SegmentTemplate patterns that manage their own query strings.
|
|
|
|
|
|
# Never re-attach the base query — it would be injected into the middle of
|
|
|
|
|
|
# the template pattern and corrupt the CDN URL the proxy encodes.
|
|
|
|
|
|
if "$" in url:
|
|
|
|
|
|
return urljoin(base, url)
|
|
|
|
|
|
|
|
|
|
|
|
# URL already has its own query string — it takes full precedence.
|
|
|
|
|
|
if parsed_url.query:
|
2026-03-05 17:00:19 +01:00
|
|
|
|
return urljoin(base, url)
|
|
|
|
|
|
|
|
|
|
|
|
joined = urljoin(base, url)
|
|
|
|
|
|
|
|
|
|
|
|
# Re-attach the base query string if it has one and the result lost it.
|
2026-03-05 21:27:43 +01:00
|
|
|
|
# This handles CDNs that embed auth tokens in the manifest URL query string
|
|
|
|
|
|
# (e.g. ?manifest-params=...) which must be forwarded to every segment URL.
|
2026-03-05 17:00:19 +01:00
|
|
|
|
base_query = urlparse(base).query
|
|
|
|
|
|
if base_query:
|
|
|
|
|
|
parsed_joined = urlparse(joined)
|
|
|
|
|
|
if not parsed_joined.query:
|
|
|
|
|
|
# urlunparse is typed as returning str | bytes depending on input;
|
|
|
|
|
|
# our inputs are always str so cast to make the type checker happy.
|
|
|
|
|
|
joined = str(urlunparse(parsed_joined._replace(query=base_query)))
|
|
|
|
|
|
|
|
|
|
|
|
return joined
|
|
|
|
|
|
|
2026-01-27 17:22:30 +01:00
|
|
|
|
def _extract_mpd_base_url(self, root: ET.Element, manifest_url: str) -> str:
|
2026-02-14 08:48:14 +01:00
|
|
|
|
"""
|
2026-03-06 09:46:00 +01:00
|
|
|
|
Extract and resolve MPD-level BaseURL.
|
|
|
|
|
|
Uses shared URLResolver utility.
|
2026-02-14 08:48:14 +01:00
|
|
|
|
"""
|
2026-01-27 17:22:30 +01:00
|
|
|
|
base_url_elem = root.find("mpd:BaseURL", self.MPD_NAMESPACE)
|
2026-02-14 08:48:14 +01:00
|
|
|
|
base_url_text = None
|
2026-01-27 15:02:26 +01:00
|
|
|
|
|
2026-01-25 12:03:57 +01:00
|
|
|
|
if base_url_elem is not None and base_url_elem.text:
|
|
|
|
|
|
base_url_text = base_url_elem.text.strip()
|
2025-10-29 20:23:50 +01:00
|
|
|
|
|
2026-03-06 09:46:00 +01:00
|
|
|
|
# Use shared utility - it handles special service prefixes.
|
|
|
|
|
|
# Note: URLResolver intentionally strips the manifest URL's query string
|
|
|
|
|
|
# when deriving the base directory URL. This is correct — query strings
|
|
|
|
|
|
# like ?manifest-params= are only for the .mpd fetch itself and must NOT
|
|
|
|
|
|
# be forwarded to segment requests.
|
|
|
|
|
|
return URLResolver.resolve_base_url_with_element(manifest_url, base_url_text)
|
2025-10-29 20:23:50 +01:00
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def extract_cache_ttl(headers: dict) -> int:
|
2026-01-06 16:41:03 +01:00
|
|
|
|
cache_control = headers.get("Cache-Control", headers.get("cache-control", ""))
|
|
|
|
|
|
if "max-age=" in cache_control:
|
2025-10-29 20:23:50 +01:00
|
|
|
|
try:
|
2026-01-06 16:41:03 +01:00
|
|
|
|
for directive in cache_control.split(","):
|
2025-10-29 20:23:50 +01:00
|
|
|
|
directive = directive.strip()
|
2026-01-06 16:41:03 +01:00
|
|
|
|
if directive.startswith("max-age="):
|
2026-01-25 12:03:57 +01:00
|
|
|
|
return int(directive.split("=")[1])
|
|
|
|
|
|
except (ValueError, IndexError):
|
|
|
|
|
|
pass
|
2025-10-29 20:23:50 +01:00
|
|
|
|
|
2026-01-06 16:41:03 +01:00
|
|
|
|
expires = headers.get("Expires", headers.get("expires"))
|
2025-10-29 20:23:50 +01:00
|
|
|
|
if expires:
|
|
|
|
|
|
try:
|
|
|
|
|
|
expires_dt = parsedate_to_datetime(expires)
|
|
|
|
|
|
now = datetime.now(timezone.utc)
|
|
|
|
|
|
ttl = int((expires_dt - now).total_seconds())
|
|
|
|
|
|
if ttl > 0:
|
|
|
|
|
|
return ttl
|
2026-01-25 12:03:57 +01:00
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
2025-10-29 20:23:50 +01:00
|
|
|
|
|
2026-01-25 12:03:57 +01:00
|
|
|
|
return 300
|
2025-10-29 20:23:50 +01:00
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def extract_mpd_update_period(mpd_content: str) -> Optional[int]:
|
|
|
|
|
|
try:
|
|
|
|
|
|
root = ET.fromstring(mpd_content)
|
2026-01-25 12:03:57 +01:00
|
|
|
|
if root.attrib.get("type") == "dynamic":
|
|
|
|
|
|
update_period = root.attrib.get("minimumUpdatePeriod")
|
|
|
|
|
|
if update_period:
|
2026-03-06 13:40:57 +01:00
|
|
|
|
return parse_iso_duration(update_period)
|
2026-01-25 12:03:57 +01:00
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
2026-03-06 13:40:57 +01:00
|
|
|
|
return None
|