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

547 lines
20 KiB
Python
Raw Normal View History

2025-10-29 20:23:50 +01:00
# streaming_providers/base/utils/mpd_rewriter.py
import base64
2026-01-06 16:41:03 +01:00
import xml.etree.ElementTree as ET
2026-01-21 12:48:42 +01:00
from typing import Optional, Tuple, Set
2026-01-23 16:03:54 +01:00
from urllib.parse import urljoin, urlparse, quote, urlencode
2026-01-06 16:41:03 +01:00
2025-10-29 20:23:50 +01:00
from .logger import logger
class MPDRewriter:
"""
2026-01-20 15:26:41 +01:00
Utility for rewriting MPD (MPEG-DASH) manifest URLs to point to media proxy endpoints
2025-10-29 20:23:50 +01:00
Strategy:
- Remove all BaseURL elements
- Convert all relative URLs to absolute URLs
2026-01-20 15:26:41 +01:00
- Rewrite all absolute URLs to media proxy endpoint
2025-10-29 20:23:50 +01:00
- Keep template variables visible for client-side substitution
2026-01-21 12:48:42 +01:00
- When decrypting: Remove ContentProtection and only add keys to encrypted segments
2025-10-29 20:23:50 +01:00
"""
# MPD namespace
2026-01-06 16:41:03 +01:00
MPD_NAMESPACE = {"mpd": "urn:mpeg:dash:schema:mpd:2011"}
2025-10-29 20:23:50 +01:00
2026-01-23 15:52:35 +01:00
def __init__(
self,
media_proxy_url: str,
provider_proxy_url: Optional[str] = None,
clearkey_keyids: Optional[dict] = None,
):
2025-10-29 20:23:50 +01:00
"""
Initialize MPD rewriter
Args:
2026-01-20 15:26:41 +01:00
media_proxy_url: Base URL of the media proxy service (e.g., http://10.77.77.7:7775)
provider_proxy_url: Optional proxy URL for the provider (e.g., http://nordlynx_germany:8888)
clearkey_keyids: Optional dict of kid:key pairs for decrypted playback
2025-10-29 20:23:50 +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
self.clearkey_keyids = clearkey_keyids or {}
2026-01-21 12:48:42 +01:00
self.encrypted_adaptation_sets: Set[str] = set()
2025-10-29 20:23:50 +01:00
@staticmethod
def encode_url(url: str) -> str:
2026-01-20 15:26:41 +01:00
"""
Encode URL to base64 for use in media proxy endpoint.
Strips padding as required by media proxy.
"""
encoded = base64.urlsafe_b64encode(url.encode("utf-8")).decode("utf-8")
# Strip padding
return encoded.rstrip("=")
2025-10-29 20:23:50 +01:00
@staticmethod
def decode_url(encoded: str) -> str:
2026-01-20 15:26:41 +01:00
"""
Decode base64 URL from media proxy endpoint.
Adds back padding if needed.
"""
# Add back padding if needed
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(
self,
original_url: str,
template_pattern: Optional[str] = None,
segment_type: Optional[str] = None,
is_encrypted: bool = True,
) -> str:
2025-10-29 20:23:50 +01:00
"""
2026-01-23 15:52:35 +01:00
Build media proxy URL with structured parameter encoding
2025-10-29 20:23:50 +01:00
2026-01-23 15:52:35 +01:00
Format: url={original}&key={key}&kid={kid}&proxy={proxy}
Then base64 encode the entire string
2025-10-29 20:23:50 +01:00
"""
2026-01-23 16:03:54 +01:00
# Build parameters dict
params = {"url": original_url}
2026-01-23 15:52:35 +01:00
# Add clearkey parameters if needed
if self.clearkey_keyids and is_encrypted:
for kid, key in self.clearkey_keyids.items():
if segment_type == "initialization":
2026-01-23 16:03:54 +01:00
params["kid"] = kid
2026-01-23 15:52:35 +01:00
elif segment_type == "media":
2026-01-23 16:03:54 +01:00
params["key"] = key
2026-01-23 15:52:35 +01:00
else:
2026-01-23 16:03:54 +01:00
params["kid"] = kid
params["key"] = key
2026-01-23 15:52:35 +01:00
# Add provider proxy parameter
if self.provider_proxy_url:
2026-01-23 16:03:54 +01:00
params["proxy"] = self.provider_proxy_url
# Encode as query string (properly URL-encodes all values)
param_string = urlencode(params)
2026-01-23 15:52:35 +01:00
# Encode the complete parameter string
encoded = self.encode_url(param_string)
2026-01-20 15:26:41 +01:00
2026-01-23 15:52:35 +01:00
# Choose endpoint
2026-01-21 12:48:42 +01:00
if self.clearkey_keyids and is_encrypted:
2026-01-20 15:26:41 +01:00
proxy_url = f"{self.media_proxy_url}/api/decrypt/{encoded}"
else:
proxy_url = f"{self.media_proxy_url}/api/proxy/{encoded}"
2025-10-29 20:23:50 +01:00
2026-01-23 15:52:35 +01:00
# Append template pattern if provided
2025-10-29 20:23:50 +01:00
if template_pattern:
2026-01-20 15:26:41 +01:00
encoded_pattern = quote(template_pattern, safe=".-_$")
proxy_url += f"/{encoded_pattern}"
2025-10-29 20:23:50 +01:00
return proxy_url
@staticmethod
def split_template_url(url: str) -> Tuple[str, Optional[str]]:
"""
Split a URL with template variables into base path and template pattern
Args:
url: URL potentially containing template variables (e.g., $Number$)
Returns:
Tuple of (base_path, template_pattern)
- base_path: URL up to the last slash before any template variable
- template_pattern: Path with template variables, or None if no templates
"""
2026-01-06 16:41:03 +01:00
if "$" not in url:
2025-10-29 20:23:50 +01:00
return url, None
# Find the position of the first template variable
2026-01-06 16:41:03 +01:00
first_template_pos = url.find("$")
2025-10-29 20:23:50 +01:00
# Find the last slash BEFORE the first template variable
2026-01-06 16:41:03 +01:00
last_slash_before_template = url.rfind("/", 0, first_template_pos)
2025-10-29 20:23:50 +01:00
if last_slash_before_template == -1:
# No slash found before template, entire URL is template (unusual but handle it)
2026-01-06 16:41:03 +01:00
return "", url
2025-10-29 20:23:50 +01:00
base_path = url[:last_slash_before_template]
2026-01-23 15:52:35 +01:00
template_pattern = url[last_slash_before_template + 1 :]
2025-10-29 20:23:50 +01:00
return base_path, template_pattern
def rewrite_mpd(self, mpd_content: str, manifest_url: str) -> str:
"""
2026-01-20 15:26:41 +01:00
Rewrite MPD content to use media proxy URLs
2025-10-29 20:23:50 +01:00
Strategy:
1. Parse MPD XML
2. Extract and remove all BaseURL elements
2026-01-21 12:48:42 +01:00
3. If decrypting: Identify encrypted AdaptationSets before removing ContentProtection
4. If decrypting: Remove ContentProtection elements
5. Resolve all relative URLs to absolute using BaseURLs and manifest URL
6. Rewrite all absolute URLs to media proxy endpoints
7. Keep template variables visible for client substitution
2025-10-29 20:23:50 +01:00
Args:
mpd_content: Original MPD XML content
manifest_url: URL where the manifest was fetched from (for relative URL resolution)
Returns:
Rewritten MPD XML content
"""
try:
# Parse XML
root = ET.fromstring(mpd_content)
# Register namespace to preserve it in output
2026-01-06 16:41:03 +01:00
ET.register_namespace("", self.MPD_NAMESPACE["mpd"])
2025-10-29 20:23:50 +01:00
# Get base URL for relative resolution
base_url = self._extract_base_url(root, manifest_url)
2026-01-20 15:26:41 +01:00
# Remove all BaseURL elements
2025-10-29 20:23:50 +01:00
self._remove_base_urls(root)
2026-01-21 12:48:42 +01:00
# If we're in decryption mode, identify encrypted AdaptationSets first
if self.clearkey_keyids:
self._identify_encrypted_adaptation_sets(root)
2026-01-23 15:52:35 +01:00
logger.debug(
f"Identified {len(self.encrypted_adaptation_sets)} encrypted AdaptationSets"
)
2026-01-21 12:48:42 +01:00
# Then remove ContentProtection elements
self._remove_content_protection(root)
2026-01-23 15:52:35 +01:00
logger.debug(
"Removed ContentProtection elements for decrypted playback"
)
2026-01-21 12:48:42 +01:00
2025-10-29 20:23:50 +01:00
# Rewrite all URLs in the MPD
self._rewrite_urls_recursive(root, base_url)
# Convert back to string
2026-01-06 16:41:03 +01:00
rewritten = ET.tostring(root, encoding="unicode", method="xml")
2025-10-29 20:23:50 +01:00
# Add XML declaration if not present
2026-01-06 16:41:03 +01:00
if not rewritten.startswith("<?xml"):
2025-10-29 20:23:50 +01:00
rewritten = '<?xml version="1.0" encoding="UTF-8"?>\n' + rewritten
2026-01-20 15:26:41 +01:00
logger.debug(f"Successfully rewrote MPD for media proxy")
2025-10-29 20:23:50 +01:00
return rewritten
except ET.ParseError as e:
logger.error(f"Failed to parse MPD XML: {e}")
raise ValueError(f"Invalid MPD XML: {e}")
except Exception as e:
logger.error(f"Failed to rewrite MPD: {e}")
raise
def _extract_base_url(self, root: ET.Element, manifest_url: str) -> str:
"""
Extract base URL from MPD or use manifest URL
Priority:
2025-12-02 18:00:43 +01:00
1. First BaseURL element in MPD (resolved relative to manifest URL if relative)
2025-10-29 20:23:50 +01:00
2. Manifest URL's directory
Args:
root: MPD root element
manifest_url: URL where manifest was fetched
Returns:
Base URL for resolving relative URLs
"""
# Try to find BaseURL element
2026-01-06 16:41:03 +01:00
base_url_elem = root.find(".//mpd:BaseURL", self.MPD_NAMESPACE)
2025-10-29 20:23:50 +01:00
if base_url_elem is not None and base_url_elem.text:
2025-12-02 18:00:43 +01:00
base_url_text = base_url_elem.text.strip()
# Check if the BaseURL is relative (doesn't start with http:// or https://)
2026-01-06 16:41:03 +01:00
if not base_url_text.startswith(("http://", "https://")):
2025-12-02 18:00:43 +01:00
# It's a relative BaseURL, resolve it against the manifest URL's directory
parsed_manifest = urlparse(manifest_url)
manifest_dir = f"{parsed_manifest.scheme}://{parsed_manifest.netloc}{parsed_manifest.path.rsplit('/', 1)[0]}/"
resolved_base = urljoin(manifest_dir, base_url_text)
2026-01-23 15:52:35 +01:00
logger.debug(
f"Resolved relative BaseURL '{base_url_text}' to: {resolved_base}"
)
2025-12-02 18:00:43 +01:00
return resolved_base
else:
# It's already an absolute URL
logger.debug(f"Using absolute BaseURL from MPD: {base_url_text}")
return base_url_text
2025-10-29 20:23:50 +01:00
# Fall back to manifest URL directory
parsed = urlparse(manifest_url)
base_url = f"{parsed.scheme}://{parsed.netloc}{parsed.path.rsplit('/', 1)[0]}/"
logger.debug(f"Using manifest URL directory as base: {base_url}")
return base_url
def _remove_base_urls(self, root: ET.Element) -> None:
"""
2026-01-20 15:26:41 +01:00
Remove all BaseURL elements from MPD
2025-10-29 20:23:50 +01:00
Args:
root: MPD root element
"""
# Find all BaseURL elements at any level
2026-01-06 16:41:03 +01:00
for parent in root.findall(".//*"):
2026-01-23 15:52:35 +01:00
for base_url_elem in list(
parent.findall("mpd:BaseURL", self.MPD_NAMESPACE)
):
2025-10-29 20:23:50 +01:00
parent.remove(base_url_elem)
logger.debug("Removed BaseURL element")
2026-01-21 12:48:42 +01:00
def _identify_encrypted_adaptation_sets(self, root: ET.Element) -> None:
"""
Identify and track which AdaptationSets have ContentProtection
Must be called BEFORE removing ContentProtection elements
Args:
root: MPD root element
"""
# Find all AdaptationSet elements
for adaptation_set in root.findall(".//mpd:AdaptationSet", self.MPD_NAMESPACE):
# Check if this AdaptationSet has ContentProtection
if adaptation_set.findall("mpd:ContentProtection", self.MPD_NAMESPACE):
# Get AdaptationSet ID for tracking
2026-01-23 15:52:35 +01:00
as_id = adaptation_set.get(
"id", id(adaptation_set)
) # Use object id as fallback
2026-01-21 12:48:42 +01:00
self.encrypted_adaptation_sets.add(str(as_id))
logger.debug(f"AdaptationSet id={as_id} is encrypted")
def _remove_content_protection(self, root: ET.Element) -> None:
"""
Remove all ContentProtection elements from MPD
Called when serving decrypted content
Args:
root: MPD root element
"""
# Find all ContentProtection elements at any level
for parent in root.findall(".//*"):
2026-01-23 15:52:35 +01:00
for cp_elem in list(
parent.findall("mpd:ContentProtection", self.MPD_NAMESPACE)
):
2026-01-21 12:48:42 +01:00
parent.remove(cp_elem)
logger.debug("Removed ContentProtection element")
2026-01-23 15:52:35 +01:00
def _is_element_in_encrypted_adaptation_set(
self, element: ET.Element, root: ET.Element
) -> bool:
2026-01-21 12:48:42 +01:00
"""
Check if an element is within an encrypted AdaptationSet
Args:
element: Current element
root: MPD root element
Returns:
True if element is within an encrypted AdaptationSet
"""
# Find the parent AdaptationSet
# We need to walk up the tree to find it
# Since ElementTree doesn't support parent traversal easily,
# we'll search from root to find the AdaptationSet containing this element
for adaptation_set in root.findall(".//mpd:AdaptationSet", self.MPD_NAMESPACE):
# Check if element is a descendant of this AdaptationSet
if self._is_descendant(adaptation_set, element):
as_id = adaptation_set.get("id", id(adaptation_set))
return str(as_id) in self.encrypted_adaptation_sets
# Not in any AdaptationSet (shouldn't happen in valid MPD)
return False
@staticmethod
def _is_descendant(parent: ET.Element, element: ET.Element) -> bool:
"""
Check if element is a descendant of parent
Args:
parent: Potential parent element
element: Element to check
Returns:
True if element is a descendant of parent
"""
for child in parent.iter():
if child is element:
return True
return False
2026-01-23 15:52:35 +01:00
def _rewrite_urls_recursive(
self, element: ET.Element, base_url: str, root: Optional[ET.Element] = None
) -> None:
2025-10-29 20:23:50 +01:00
"""
Recursively rewrite all URLs in MPD element tree
Args:
element: Current XML element
base_url: Base URL for resolving relative URLs
2026-01-21 12:48:42 +01:00
root: MPD root element (for checking encrypted AdaptationSets)
2025-10-29 20:23:50 +01:00
"""
2026-01-21 12:48:42 +01:00
# Store root on first call
if root is None:
root = element
# Determine if this element is in an encrypted AdaptationSet
is_encrypted = False
if self.clearkey_keyids:
is_encrypted = self._is_element_in_encrypted_adaptation_set(element, root)
2026-01-21 12:25:59 +01:00
# Determine segment type based on attribute name
segment_type_map = {
2026-01-23 15:52:35 +01:00
"initialization": "initialization",
"media": "media",
"sourceURL": None, # Could be either, keep default
2026-01-21 12:25:59 +01:00
}
2025-10-29 20:23:50 +01:00
# Rewrite URL attributes in current element
2026-01-23 15:52:35 +01:00
for attr in ["media", "initialization", "sourceURL"]:
2025-10-29 20:23:50 +01:00
if attr in element.attrib:
original_url = element.attrib[attr]
if not original_url:
continue
2026-01-21 12:25:59 +01:00
# Determine segment type for selective DRM params
segment_type = segment_type_map.get(attr)
2025-10-29 20:23:50 +01:00
# Resolve to absolute URL first
resolved = urljoin(base_url, original_url)
# Check if URL contains template variables
2026-01-06 16:41:03 +01:00
if "$" in resolved:
2025-10-29 20:23:50 +01:00
# Split into base path and template pattern
base_path, template_pattern = self.split_template_url(resolved)
2026-01-21 12:48:42 +01:00
element.attrib[attr] = self.build_proxy_url(
base_path, template_pattern, segment_type, is_encrypted
)
2026-01-06 16:41:03 +01:00
logger.debug(
2026-01-21 12:48:42 +01:00
f"Rewrote template URL ({attr}, encrypted={is_encrypted}): {original_url} -> media proxy"
2026-01-06 16:41:03 +01:00
)
2025-10-29 20:23:50 +01:00
else:
# Regular URL without templates
2026-01-21 12:48:42 +01:00
element.attrib[attr] = self.build_proxy_url(
resolved, None, segment_type, is_encrypted
)
2026-01-23 15:52:35 +01:00
logger.debug(
f"Rewrote URL ({attr}, encrypted={is_encrypted}): {original_url} -> media proxy"
)
2025-10-29 20:23:50 +01:00
# Handle SegmentURL elements (used in SegmentList)
2026-01-06 16:41:03 +01:00
if element.tag.endswith("SegmentURL"):
if "media" in element.attrib:
original_url = element.attrib["media"]
2025-10-29 20:23:50 +01:00
resolved = urljoin(base_url, original_url)
2026-01-21 12:25:59 +01:00
# SegmentURL media is always a media segment
2026-01-06 16:41:03 +01:00
if "$" in resolved:
2025-10-29 20:23:50 +01:00
base_path, template_pattern = self.split_template_url(resolved)
2026-01-21 12:48:42 +01:00
element.attrib["media"] = self.build_proxy_url(
2026-01-23 15:52:35 +01:00
base_path, template_pattern, "media", is_encrypted
2026-01-21 12:48:42 +01:00
)
2025-10-29 20:23:50 +01:00
else:
2026-01-21 12:48:42 +01:00
element.attrib["media"] = self.build_proxy_url(
2026-01-23 15:52:35 +01:00
resolved, None, "media", is_encrypted
2026-01-21 12:48:42 +01:00
)
2025-10-29 20:23:50 +01:00
# Recurse to child elements
for child in element:
2026-01-21 12:48:42 +01:00
self._rewrite_urls_recursive(child, base_url, root)
2025-10-29 20:23:50 +01:00
@staticmethod
def extract_cache_ttl(headers: dict) -> int:
"""
Extract cache TTL from HTTP response headers
Priority:
1. Cache-Control: max-age=X
2. Expires header
3. Default to 300 seconds (5 minutes)
Args:
headers: HTTP response headers dict
Returns:
Cache TTL in seconds
"""
# Check Cache-Control header
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:
# Extract max-age value
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="):
max_age = int(directive.split("=")[1])
2025-10-29 20:23:50 +01:00
logger.debug(f"Cache TTL from Cache-Control: {max_age}s")
return max_age
except (ValueError, IndexError) as e:
logger.warning(f"Failed to parse max-age from Cache-Control: {e}")
# Check Expires header
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:
from datetime import datetime, timezone
2026-01-06 16:41:03 +01:00
from email.utils import parsedate_to_datetime
2025-10-29 20:23:50 +01:00
expires_dt = parsedate_to_datetime(expires)
now = datetime.now(timezone.utc)
ttl = int((expires_dt - now).total_seconds())
if ttl > 0:
logger.debug(f"Cache TTL from Expires: {ttl}s")
return ttl
except Exception as e:
logger.warning(f"Failed to parse Expires header: {e}")
# Default TTL
default_ttl = 300
logger.debug(f"Using default cache TTL: {default_ttl}s")
return default_ttl
@staticmethod
def extract_mpd_update_period(mpd_content: str) -> Optional[int]:
"""
Extract minimumUpdatePeriod from MPD as fallback TTL
Args:
mpd_content: MPD XML content
Returns:
Update period in seconds, or None if not found/applicable
"""
try:
root = ET.fromstring(mpd_content)
# Check if dynamic manifest
2026-01-06 16:41:03 +01:00
mpd_type = root.attrib.get("type", "static")
if mpd_type != "dynamic":
2025-10-29 20:23:50 +01:00
return None
# Get minimumUpdatePeriod
2026-01-06 16:41:03 +01:00
update_period = root.attrib.get("minimumUpdatePeriod")
2025-10-29 20:23:50 +01:00
if update_period:
# Parse ISO 8601 duration (e.g., "PT5S" = 5 seconds)
return MPDRewriter._parse_iso_duration(update_period)
except Exception as e:
logger.debug(f"Could not extract MPD update period: {e}")
return None
@staticmethod
def _parse_iso_duration(duration: str) -> int:
"""
Parse ISO 8601 duration to seconds
Supports formats like: PT5S, PT1M30S, PT1H
Args:
duration: ISO 8601 duration string
Returns:
Duration in seconds
"""
import re
# Remove PT prefix
2026-01-06 16:41:03 +01:00
duration = duration.replace("PT", "")
2025-10-29 20:23:50 +01:00
# Parse hours, minutes, seconds
hours = minutes = seconds = 0
2026-01-06 16:41:03 +01:00
h_match = re.search(r"(\d+)H", duration)
2025-10-29 20:23:50 +01:00
if h_match:
hours = int(h_match.group(1))
2026-01-06 16:41:03 +01:00
m_match = re.search(r"(\d+)M", duration)
2025-10-29 20:23:50 +01:00
if m_match:
minutes = int(m_match.group(1))
2026-01-06 16:41:03 +01:00
s_match = re.search(r"(\d+(?:\.\d+)?)S", duration)
2025-10-29 20:23:50 +01:00
if s_match:
seconds = float(s_match.group(1))
total_seconds = int(hours * 3600 + minutes * 60 + seconds)
logger.debug(f"Parsed ISO duration '{duration}' to {total_seconds}s")
2026-01-23 15:52:35 +01:00
return total_seconds