Files
script.service.ultimate/lib/streaming_providers/base/utils/mpd_rewriter.py
T

1064 lines
47 KiB
Python
Raw Normal View History

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-01-25 12:03:57 +01:00
import re
2026-01-29 17:20:48 +01:00
from typing import Optional, Tuple, Set, Dict, List
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-27 11:57:39 +01:00
from dataclasses import dataclass, field
2026-01-06 16:41:03 +01:00
2025-10-29 20:23:50 +01:00
from .logger import logger
2026-01-30 15:50:47 +01:00
from .vfs import get_vfs
2026-02-14 08:48:14 +01:00
from .url_resolver import URLResolver
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-25 12:03:57 +01:00
# Pre-compile regex for ISO duration parsing at module level
ISO_8601_PERIOD_RE = re.compile(
r"P(?:(?P<years>\d+)Y)?(?:(?P<months>\d+)M)?(?:(?P<weeks>\d+)W)?(?:(?P<days>\d+)D)?(?:T(?:(?P<hours>\d+)H)?(?:(?P<minutes>\d+)M)?(?:(?P<seconds>\d+(?:\.\d+)?)S)?)?"
)
2025-10-29 20:23:50 +01:00
2026-01-27 11:57:39 +01:00
@dataclass
class KeyConfiguration:
"""Configuration for DRM key management with validation and normalization."""
keys: Dict[str, str] = field(default_factory=dict)
single_key_mode: bool = field(init=False)
default_kid: Optional[str] = field(init=False, default=None)
default_key: Optional[str] = field(init=False, default=None)
def __post_init__(self):
"""Normalize and validate keys on initialization."""
normalized = {}
for kid, key in self.keys.items():
norm_kid = kid.replace("-", "").lower()
norm_key = key.replace("-", "").lower()
# Validate hex format (32 characters = 16 bytes)
if len(norm_kid) != 32 or not all(c in '0123456789abcdef' for c in norm_kid):
logger.warning(f"Invalid KID format (expected 32 hex chars): {kid}")
continue
if len(norm_key) != 32 or not all(c in '0123456789abcdef' for c in norm_key):
logger.warning(f"Invalid key format for KID {kid}: {key}")
continue
normalized[norm_kid] = norm_key
self.keys = normalized
self.single_key_mode = len(self.keys) <= 1
if self.single_key_mode and self.keys:
self.default_kid, self.default_key = next(iter(self.keys.items()))
logger.debug(f"Single key mode: KID={self.default_kid[:8]}...")
elif self.keys:
logger.debug(f"Multi-key mode: {len(self.keys)} keys available")
2026-01-30 15:50:47 +01:00
class RepresentationBlocklist:
"""
Manages blocklist of problematic Representation IDs that cause 500 errors.
Blocklist format (JSON):
{
"provider_name": {
"channel_name": ["rep_id_1", "rep_id_2"],
"another_channel": ["rep_id_3"]
}
}
"""
def __init__(self, blocklist_path: str = "representation_blocklist.json"):
"""
Initialize blocklist manager.
Args:
blocklist_path: Path to JSON file containing blocklist configuration
"""
self.blocklist_path = blocklist_path
self.blocklist: Dict[str, Dict[str, List[str]]] = {}
self._load_blocklist()
def _load_blocklist(self):
"""Load blocklist from JSON file using VFS."""
try:
vfs = get_vfs()
data = vfs.read_json(self.blocklist_path)
if data:
self.blocklist = data
total_blocked = sum(
len(rep_ids)
for provider in self.blocklist.values()
for rep_ids in provider.values()
)
logger.info(
f"Loaded representation blocklist: "
f"{len(self.blocklist)} providers, {total_blocked} total blocked representations"
)
else:
logger.info(f"No blocklist found at {self.blocklist_path}, starting with empty blocklist")
except Exception as e:
logger.warning(f"Failed to load representation blocklist from {self.blocklist_path}: {e}")
self.blocklist = {}
def is_blocked(self, provider: str, channel: str, representation_id: str) -> bool:
"""
Check if a representation ID is blocked for a given provider/channel.
Args:
provider: Provider name (e.g., "magenta_tv", "ht_iptv")
channel: Channel name/ID
representation_id: Representation ID to check
Returns:
True if blocked, False otherwise
"""
if not provider or not channel:
return False
provider_data = self.blocklist.get(provider, {})
channel_data = provider_data.get(channel, [])
return representation_id in channel_data
def get_blocked_ids(self, provider: str, channel: str) -> Set[str]:
"""
Get set of all blocked representation IDs for a provider/channel.
Args:
provider: Provider name
channel: Channel name/ID
Returns:
Set of blocked representation IDs
"""
if not provider or not channel:
return set()
provider_data = self.blocklist.get(provider, {})
channel_data = provider_data.get(channel, [])
return set(channel_data)
2026-01-29 17:20:48 +01:00
@dataclass
class VideoRepresentation:
"""Represents a video representation with its quality metrics."""
element: ET.Element
adaptation_set: ET.Element
period: ET.Element
bandwidth: int
width: Optional[int] = None
height: Optional[int] = None
frame_rate: Optional[float] = None
representation_id: str = ""
period_id: str = ""
as_id: str = ""
def get_quality_score(self) -> Tuple[int, int, int]:
"""
Returns a tuple for comparison: (resolution_pixels, bandwidth, frame_rate_score)
Higher values = better quality
"""
resolution = (self.width or 0) * (self.height or 0)
frame_rate_score = int((self.frame_rate or 0) * 100)
return resolution, self.bandwidth, frame_rate_score
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-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:
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
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-01-25 12:03:57 +01: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-01-25 12:03:57 +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"])
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-01-29 17:20:48 +01:00
# FIRST: Filter out encrypted AdaptationSets without available 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-03-05 14:22:32 +01:00
elif not self.key_config.keys:
# Proxy-only: remove all encrypted sets (player has no keys either)
2026-01-27 11:57:39 +01:00
self._remove_all_encrypted_adaptationsets(root)
2026-03-05 14:22:32 +01:00
# else: receiver-side clearkey — keep all sets; receiver will decrypt using injected keys
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:
best_video_info = self._filter_to_highest_quality_video(root)
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-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-01-29 17:20:48 +01:00
def _filter_to_highest_quality_video(self, root: ET.Element) -> Optional[VideoRepresentation]:
"""
Find the absolute highest quality video representation across all periods and adaptation sets.
Remove all other video representations, keep audio/subtitles unchanged.
Returns information about the best video representation found.
"""
all_video_reps: List[VideoRepresentation] = []
# Scan all periods and adaptation sets to find video representations
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):
# Check if this is a video adaptation set
if not self._is_video_adaptation_set(adaptation_set):
continue
as_id = adaptation_set.get("id", str(id(adaptation_set)))
# Collect all representations from this video adaptation set
for representation in adaptation_set.findall("mpd:Representation", self.MPD_NAMESPACE):
video_rep = self._parse_video_representation(
representation, adaptation_set, period, period_id, as_id
)
if video_rep:
all_video_reps.append(video_rep)
if not all_video_reps:
logger.warning("No video representations found for filtering")
return None
# Find the best video representation
best_video = max(all_video_reps, key=lambda v: v.get_quality_score())
logger.debug(
f"Found {len(all_video_reps)} video representations, "
f"best: {best_video.width}x{best_video.height} @ {best_video.bandwidth}bps"
)
# Now remove all video representations EXCEPT the best one
removal_count = 0
for period in root.findall(".//mpd:Period", self.MPD_NAMESPACE):
period_id = period.get("id", "")
adaptation_sets_to_remove = []
for adaptation_set in period.findall("mpd:AdaptationSet", self.MPD_NAMESPACE):
if not self._is_video_adaptation_set(adaptation_set):
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)
# Remove empty video adaptation sets
for adaptation_set in adaptation_sets_to_remove:
period.remove(adaptation_set)
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.
CODEC_SWITCH_SCHEME = "urn:mpeg:dash:adaptation-set-switching:2016"
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)
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
return best_video
def _is_video_adaptation_set(self, adaptation_set: ET.Element) -> bool:
"""Determine if an AdaptationSet is video based on mimeType or contentType."""
mime_type = adaptation_set.get("mimeType", "")
content_type = adaptation_set.get("contentType", "")
# Check AdaptationSet level
if mime_type.startswith("video/") or content_type == "video":
return True
# Check Representation level if not specified at AdaptationSet level
for representation in adaptation_set.findall("mpd:Representation", self.MPD_NAMESPACE):
rep_mime = representation.get("mimeType", "")
if rep_mime.startswith("video/"):
return True
return False
2026-02-13 16:50:09 +01:00
@staticmethod
2026-01-29 17:20:48 +01:00
def _parse_video_representation(
representation: ET.Element,
adaptation_set: ET.Element,
period: ET.Element,
period_id: str,
as_id: str
) -> Optional[VideoRepresentation]:
"""Parse a Representation element and extract video quality information."""
try:
# Bandwidth is required
bandwidth = int(representation.get("bandwidth", "0"))
if bandwidth == 0:
return None
# Width and height (can be on Representation or AdaptationSet)
width = representation.get("width") or adaptation_set.get("width")
height = representation.get("height") or adaptation_set.get("height")
width = int(width) if width else None
height = int(height) if height else None
# Frame rate (can be on Representation or AdaptationSet)
frame_rate_str = representation.get("frameRate") or adaptation_set.get("frameRate")
frame_rate = None
if frame_rate_str:
# Handle both "30" and "30000/1001" formats
if "/" in frame_rate_str:
num, denom = frame_rate_str.split("/")
frame_rate = float(num) / float(denom)
else:
frame_rate = float(frame_rate_str)
representation_id = representation.get("id", "")
return VideoRepresentation(
element=representation,
adaptation_set=adaptation_set,
period=period,
bandwidth=bandwidth,
width=width,
height=height,
frame_rate=frame_rate,
representation_id=representation_id,
period_id=period_id,
as_id=as_id
)
except (ValueError, AttributeError) as e:
logger.warning(f"Failed to parse video representation: {e}")
return None
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-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
logger.debug(f"AdaptationSet {unique_id} KID: {extracted_kid[:8]}...")
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()
logger.debug(f"Extracted KID from default_KID attribute: {normalized}")
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()
logger.debug(f"Extracted KID from PSSH header: {kid_hex}")
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
logger.debug(f"Available keys: {list(self.key_config.keys.keys())}")
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
def _remove_all_encrypted_adaptationsets(self, root: ET.Element):
2026-02-14 08:48:14 +01:00
"""Remove all encrypted AdaptationSets when no keys are available."""
removed_count = 0
2026-01-27 11:57:39 +01:00
for period in root.findall(".//mpd:Period", self.MPD_NAMESPACE):
adaptationsets_to_remove = []
for adaptation_set in period.findall("mpd:AdaptationSet", self.MPD_NAMESPACE):
2026-02-14 08:48:14 +01:00
# Check for ContentProtection
2026-01-27 11:57:39 +01:00
cp_elements = adaptation_set.findall("mpd:ContentProtection", self.MPD_NAMESPACE)
2026-02-14 08:48:14 +01:00
has_cp = len(cp_elements) > 0
# Also check Representation level
if not has_cp:
for representation in adaptation_set.findall("mpd:Representation", self.MPD_NAMESPACE):
rep_cp = representation.findall("mpd:ContentProtection", self.MPD_NAMESPACE)
if rep_cp:
has_cp = True
break
if has_cp:
as_id = adaptation_set.get("id", "unknown")
logger.info(f"Removing encrypted AdaptationSet {as_id} - no keys available")
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} encrypted AdaptationSet(s)")
2026-01-25 12:03:57 +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", "")
# Update state when entering an AdaptationSet
2026-01-27 17:22:30 +01:00
current_as_id = None
2026-02-14 08:48:14 +01:00
current_rep_id = None
2026-01-25 12:03:57 +01:00
if element.tag.endswith("AdaptationSet"):
as_id = element.get("id", str(id(element)))
2026-01-27 17:22:30 +01:00
current_as_id = as_id
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-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-05 20:44:45 +01:00
# Track representation ID for template substitution.
# For SegmentBase manifests, also handle <BaseURL> + <Initialization>
# together here at the Representation level, so both share the same
# resolved CDN URL without risk of double-proxying.
2026-01-29 17:20:48 +01:00
if element.tag.endswith("Representation"):
current_rep_id = element.get("id", "")
2026-03-05 21:00:14 +01:00
# Detect SegmentBase pattern: a Representation with a <BaseURL> child.
# We rewrite the <BaseURL> text to a proxy URL here at the Representation
# level so we have control over the resolved CDN URL before recursion
# descends into children.
2026-03-05 20:44:45 +01:00
base_url_child = None
for child in element:
if child.tag.endswith("BaseURL") and child.text:
base_url_child = child
2026-03-05 21:00:14 +01:00
break
2026-03-05 20:44:45 +01:00
if base_url_child is not None:
raw_url = base_url_child.text.strip()
if raw_url:
# Resolve the relative BaseURL against the current base.
resolved_cdn_url = self._urljoin_preserve_query(base_url, raw_url)
2026-03-05 21:00:14 +01:00
# Rewrite the <BaseURL> text to a proxy URL.
# For SegmentBase manifests the player uses <BaseURL> for ALL
# requests (init segment, index, and media), issuing HTTP Range
# requests as directed by indexRange / <Initialization range="…"/>.
# No sourceURL is needed — the range attribute on <Initialization>
# stays untouched and the player combines it with the proxied
# <BaseURL> automatically.
2026-03-05 20:44:45 +01:00
base_url_child.text = self.build_proxy_url(
resolved_cdn_url, None, None,
current_encrypted, current_kid,
representation_id=current_rep_id,
)
2026-03-05 15:59:09 +01:00
2026-03-05 21:00:14 +01:00
# Update base_url for any further descendants.
2026-03-05 20:44:45 +01:00
base_url = resolved_cdn_url
2025-10-29 20:23:50 +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-05 20:44:45 +01:00
# Recurse to children.
# For SegmentBase Representations, the BaseURL and Initialization children
# were already fully rewritten above at the Representation level, so when
# the recursion visits them they will be no-ops (no matching attributes).
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-05 18:40:23 +01:00
Extract and resolve MPD-level BaseURL, preserving the manifest URL's
query string on the result.
Why query preservation is needed here:
Some CDNs (e.g. Discovery+/Max) embed their auth token in the manifest
URL's query string (e.g. ?manifest-params=...). URLResolver strips the
filename — and therefore the query string — when deriving the directory
base URL from a .mpd filename URL. All segment paths in the manifest
are relative (e.g. "v/0_9cd1a5/v5.mp4") with no query component, so
without this fix the CDN token is silently dropped from every resolved
segment URL.
Re-attaching the manifest query string here means _urljoin_preserve_query
sees it on the base and propagates it to every resolved segment URL whose
relative path carries no query of its own.
2026-02-14 08:48:14 +01:00
"""
2026-03-05 18:40:23 +01:00
from urllib.parse import urlparse, urlunparse
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-02-14 08:48:14 +01:00
# Use shared utility - it handles special service prefixes
2026-03-05 18:40:23 +01:00
resolved = URLResolver.resolve_base_url_with_element(manifest_url, base_url_text)
# Re-attach the manifest query string if the resolved base lost it.
# Only do this when:
# 1. The manifest URL had a query string, AND
# 2. The resolved base has none (URLResolver stripped it), AND
# 3. No MPD-level <BaseURL> element introduced its own query
# (if it did, that takes full precedence — leave it alone).
manifest_query = urlparse(manifest_url).query
if manifest_query:
parsed = urlparse(resolved)
if not parsed.query:
resolved = str(urlunparse(parsed._replace(query=manifest_query)))
return resolved
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:
return MPDRewriter._parse_iso_duration(update_period)
except Exception:
pass
2025-10-29 20:23:50 +01:00
return None
@staticmethod
def _parse_iso_duration(duration: str) -> int:
2026-01-25 12:03:57 +01:00
match = ISO_8601_PERIOD_RE.match(duration)
if not match:
return 0
d = match.groupdict()
return int(
int(d["hours"] or 0) * 3600
+ int(d["minutes"] or 0) * 60
+ float(d["seconds"] or 0)
2026-01-27 11:57:39 +01:00
)