mirror of
https://github.com/nirvana-7777/script.service.ultimate.git
synced 2026-09-18 07:02:27 +02:00
373 lines
14 KiB
Python
373 lines
14 KiB
Python
# streaming_providers/base/utils/mpd_rewriter.py
|
|
import xml.etree.ElementTree as ET
|
|
import base64
|
|
from typing import Optional, Tuple
|
|
from urllib.parse import urljoin, urlparse
|
|
from .logger import logger
|
|
|
|
|
|
class MPDRewriter:
|
|
"""
|
|
Utility for rewriting MPD (MPEG-DASH) manifest URLs to point to proxy endpoints
|
|
|
|
Strategy:
|
|
- Remove all BaseURL elements
|
|
- Convert all relative URLs to absolute URLs
|
|
- Rewrite all absolute URLs to proxy endpoint
|
|
- Keep template variables visible for client-side substitution
|
|
"""
|
|
|
|
# MPD namespace
|
|
MPD_NAMESPACE = {'mpd': 'urn:mpeg:dash:schema:mpd:2011'}
|
|
|
|
def __init__(self, proxy_base_url: str, provider_name: str):
|
|
"""
|
|
Initialize MPD rewriter
|
|
|
|
Args:
|
|
proxy_base_url: Base URL of the proxy service (e.g., http://localhost:7777)
|
|
provider_name: Name of the provider for proxy routing
|
|
"""
|
|
self.proxy_base_url = proxy_base_url.rstrip('/')
|
|
self.provider_name = provider_name
|
|
|
|
@staticmethod
|
|
def encode_url(url: str) -> str:
|
|
"""Encode URL to base64 for use in proxy endpoint"""
|
|
return base64.urlsafe_b64encode(url.encode('utf-8')).decode('utf-8')
|
|
|
|
@staticmethod
|
|
def decode_url(encoded: str) -> str:
|
|
"""Decode base64 URL from proxy endpoint"""
|
|
return base64.urlsafe_b64decode(encoded.encode('utf-8')).decode('utf-8')
|
|
|
|
def build_proxy_url(self, original_url: str, template_pattern: Optional[str] = None) -> str:
|
|
"""
|
|
Build proxy URL for an original media URL
|
|
|
|
Args:
|
|
original_url: Original URL to be proxied (base path for templates)
|
|
template_pattern: Optional template pattern to append (e.g., "segment-$Number$.m4s")
|
|
|
|
Returns:
|
|
Proxy URL
|
|
"""
|
|
encoded = self.encode_url(original_url)
|
|
proxy_url = f"{self.proxy_base_url}/api/proxy/{self.provider_name}/{encoded}"
|
|
|
|
# Append template pattern if provided (keeps variables visible for client)
|
|
if template_pattern:
|
|
proxy_url += f"/{template_pattern}"
|
|
|
|
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
|
|
"""
|
|
if '$' not in url:
|
|
return url, None
|
|
|
|
# Find the position of the first template variable
|
|
first_template_pos = url.find('$')
|
|
|
|
# Find the last slash BEFORE the first template variable
|
|
# This handles cases like:
|
|
# - https://cdn.com/path/segment-$Number$.m4s
|
|
# - https://cdn.com/path/$RepresentationID$/init.mp4
|
|
last_slash_before_template = url.rfind('/', 0, first_template_pos)
|
|
|
|
if last_slash_before_template == -1:
|
|
# No slash found before template, entire URL is template (unusual but handle it)
|
|
return '', url
|
|
|
|
base_path = url[:last_slash_before_template]
|
|
template_pattern = url[last_slash_before_template + 1:]
|
|
|
|
return base_path, template_pattern
|
|
|
|
def rewrite_mpd(self, mpd_content: str, manifest_url: str) -> str:
|
|
"""
|
|
Rewrite MPD content to use proxy URLs
|
|
|
|
Strategy:
|
|
1. Parse MPD XML
|
|
2. Extract and remove all BaseURL elements
|
|
3. Resolve all relative URLs to absolute using BaseURLs and manifest URL
|
|
4. Rewrite all absolute URLs to proxy endpoints
|
|
5. Keep template variables visible for client substitution
|
|
|
|
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
|
|
ET.register_namespace('', self.MPD_NAMESPACE['mpd'])
|
|
|
|
# Get base URL for relative resolution
|
|
base_url = self._extract_base_url(root, manifest_url)
|
|
|
|
# Remove all BaseURL elements (Option 3 strategy)
|
|
self._remove_base_urls(root)
|
|
|
|
# Rewrite all URLs in the MPD
|
|
self._rewrite_urls_recursive(root, base_url)
|
|
|
|
# Convert back to string
|
|
rewritten = ET.tostring(root, encoding='unicode', method='xml')
|
|
|
|
# Add XML declaration if not present
|
|
if not rewritten.startswith('<?xml'):
|
|
rewritten = '<?xml version="1.0" encoding="UTF-8"?>\n' + rewritten
|
|
|
|
logger.debug(f"Successfully rewrote MPD for provider '{self.provider_name}'")
|
|
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:
|
|
1. First BaseURL element in MPD (resolved relative to manifest URL if relative)
|
|
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
|
|
base_url_elem = root.find('.//mpd:BaseURL', self.MPD_NAMESPACE)
|
|
if base_url_elem is not None and base_url_elem.text:
|
|
base_url_text = base_url_elem.text.strip()
|
|
|
|
# Check if the BaseURL is relative (doesn't start with http:// or https://)
|
|
if not base_url_text.startswith(('http://', 'https://')):
|
|
# 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)
|
|
logger.debug(f"Resolved relative BaseURL '{base_url_text}' to: {resolved_base}")
|
|
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
|
|
|
|
# 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:
|
|
"""
|
|
Remove all BaseURL elements from MPD (Option 3 strategy)
|
|
|
|
Args:
|
|
root: MPD root element
|
|
"""
|
|
# Find all BaseURL elements at any level
|
|
for parent in root.findall('.//*'):
|
|
for base_url_elem in list(parent.findall('mpd:BaseURL', self.MPD_NAMESPACE)):
|
|
parent.remove(base_url_elem)
|
|
logger.debug("Removed BaseURL element")
|
|
|
|
def _rewrite_urls_recursive(self, element: ET.Element, base_url: str) -> None:
|
|
"""
|
|
Recursively rewrite all URLs in MPD element tree
|
|
|
|
Args:
|
|
element: Current XML element
|
|
base_url: Base URL for resolving relative URLs
|
|
"""
|
|
# Attributes that contain URLs
|
|
url_attributes = [
|
|
'media', # SegmentTemplate
|
|
'initialization', # SegmentTemplate
|
|
'sourceURL', # Initialization, RepresentationIndex
|
|
'indexRange', # SegmentBase (not a URL but can be affected)
|
|
]
|
|
|
|
# Rewrite URL attributes in current element
|
|
for attr in url_attributes:
|
|
if attr in element.attrib:
|
|
original_url = element.attrib[attr]
|
|
|
|
if not original_url:
|
|
continue
|
|
|
|
# Resolve to absolute URL first
|
|
resolved = urljoin(base_url, original_url)
|
|
|
|
# Check if URL contains template variables
|
|
if '$' in resolved:
|
|
# Split into base path and template pattern
|
|
base_path, template_pattern = self.split_template_url(resolved)
|
|
element.attrib[attr] = self.build_proxy_url(base_path, template_pattern)
|
|
logger.debug(f"Rewrote template URL: {original_url} -> proxy with template {template_pattern}")
|
|
else:
|
|
# Regular URL without templates
|
|
element.attrib[attr] = self.build_proxy_url(resolved)
|
|
logger.debug(f"Rewrote URL: {original_url} -> proxy")
|
|
|
|
# Handle SegmentURL elements (used in SegmentList)
|
|
if element.tag.endswith('SegmentURL'):
|
|
if 'media' in element.attrib:
|
|
original_url = element.attrib['media']
|
|
resolved = urljoin(base_url, original_url)
|
|
|
|
# SegmentURL typically doesn't have templates, but handle it just in case
|
|
if '$' in resolved:
|
|
base_path, template_pattern = self.split_template_url(resolved)
|
|
element.attrib['media'] = self.build_proxy_url(base_path, template_pattern)
|
|
else:
|
|
element.attrib['media'] = self.build_proxy_url(resolved)
|
|
|
|
# Recurse to child elements
|
|
for child in element:
|
|
self._rewrite_urls_recursive(child, base_url)
|
|
|
|
@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
|
|
cache_control = headers.get('Cache-Control', headers.get('cache-control', ''))
|
|
if 'max-age=' in cache_control:
|
|
try:
|
|
# Extract max-age value
|
|
for directive in cache_control.split(','):
|
|
directive = directive.strip()
|
|
if directive.startswith('max-age='):
|
|
max_age = int(directive.split('=')[1])
|
|
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
|
|
expires = headers.get('Expires', headers.get('expires'))
|
|
if expires:
|
|
try:
|
|
from email.utils import parsedate_to_datetime
|
|
from datetime import datetime, timezone
|
|
|
|
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
|
|
mpd_type = root.attrib.get('type', 'static')
|
|
if mpd_type != 'dynamic':
|
|
return None
|
|
|
|
# Get minimumUpdatePeriod
|
|
update_period = root.attrib.get('minimumUpdatePeriod')
|
|
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
|
|
duration = duration.replace('PT', '')
|
|
|
|
# Parse hours, minutes, seconds
|
|
hours = minutes = seconds = 0
|
|
|
|
h_match = re.search(r'(\d+)H', duration)
|
|
if h_match:
|
|
hours = int(h_match.group(1))
|
|
|
|
m_match = re.search(r'(\d+)M', duration)
|
|
if m_match:
|
|
minutes = int(m_match.group(1))
|
|
|
|
s_match = re.search(r'(\d+(?:\.\d+)?)S', duration)
|
|
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")
|
|
return total_seconds |