mirror of
https://github.com/nirvana-7777/script.service.ultimate.git
synced 2026-09-18 23:22:38 +02:00
Prepare recordings
This commit is contained in:
@@ -2,7 +2,6 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import base64
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime, timedelta
|
||||
@@ -16,6 +15,7 @@ from ...base.network import HTTPManagerFactory, ProxyConfigManager
|
||||
from ...base.provider import StreamingProvider
|
||||
from ...base.utils.logger import logger
|
||||
from .recordings_manager import RecordingsManager
|
||||
from .smil_manager import SmilManager
|
||||
from .vod_manager import VodManager
|
||||
from .auth import Magenta2Authenticator, Magenta2Credentials, Magenta2UserCredentials
|
||||
from .config_models import ProviderConfig
|
||||
@@ -30,17 +30,11 @@ from .constants import (
|
||||
DRM_SYSTEM_WIDEVINE,
|
||||
ERROR_CODES,
|
||||
MAGENTA2_CLIENT_IDS,
|
||||
MAGENTA2_FALLBACK_ACCOUNT_URI,
|
||||
MAGENTA2_LOGO,
|
||||
MAGENTA2_PLATFORMS,
|
||||
MODE_LIVE,
|
||||
QUALITY_RANK,
|
||||
SMIL_CACHE_DURATION,
|
||||
SMIL_CLIENT_ID,
|
||||
SUPPORTED_COUNTRIES,
|
||||
VOD_PREFIX_EPISODE,
|
||||
VOD_PREFIX_MOVIE_MV,
|
||||
VOD_PREFIX_MOVIE_SH,
|
||||
)
|
||||
from .discovery import DiscoveryService
|
||||
from .endpoint_manager import EndpointManager
|
||||
@@ -123,6 +117,7 @@ class Magenta2Provider(StreamingProvider):
|
||||
self.provider_config: Optional[ProviderConfig] = None
|
||||
self._vod_manager: Optional[VodManager] = None
|
||||
self._recordings_manager: Optional[RecordingsManager] = None
|
||||
self._smil_manager: Optional[SmilManager] = None
|
||||
|
||||
# 🚨 INITIALIZE AUTHENTICATOR FIRST (with minimal config)
|
||||
# Use fallback client IDs initially
|
||||
@@ -299,12 +294,22 @@ class Magenta2Provider(StreamingProvider):
|
||||
)
|
||||
logger.info("✓ RecordingsManager initialized with discovered config")
|
||||
|
||||
self._smil_manager = SmilManager(
|
||||
http_manager=self.http_manager,
|
||||
provider_name=self.provider_name,
|
||||
session_id=self.session_id,
|
||||
call_id_callback=self._generate_call_id,
|
||||
auth_callback=self._ensure_authenticated,
|
||||
platform_config=self.platform_config,
|
||||
endpoint_manager=self.endpoint_manager,
|
||||
provider_config=self.endpoint_manager.config,
|
||||
vod_manager=self._vod_manager,
|
||||
)
|
||||
logger.info("✓ SmilManager initialized with discovered config")
|
||||
|
||||
# Initialize auth tokens (lazy - populated on first use)
|
||||
self.device_token = None
|
||||
self._persona_cache: Optional[PersonaResult] = None
|
||||
self._smil_cache: Dict[str, Tuple[float, Dict]] = {} # channel_id -> (timestamp, smil_data)
|
||||
self._smil_cache_ttl = SMIL_CACHE_DURATION
|
||||
|
||||
logger.info("Magenta2 provider initialization completed successfully")
|
||||
|
||||
@staticmethod
|
||||
@@ -1125,6 +1130,7 @@ class Magenta2Provider(StreamingProvider):
|
||||
"accept-encoding": "gzip",
|
||||
}
|
||||
|
||||
|
||||
def _pvr_auth_headers(self) -> Dict[str, str]:
|
||||
"""
|
||||
Build authentication headers for nPVR (Audience) recording endpoints.
|
||||
@@ -1164,27 +1170,9 @@ class Magenta2Provider(StreamingProvider):
|
||||
raise RuntimeError("VodManager not available - configuration discovery may have failed")
|
||||
return self._vod_manager.get_children(content_id=content_id, **kwargs)
|
||||
|
||||
def get_recordings(
|
||||
self,
|
||||
include_deleted: bool = False,
|
||||
**kwargs,
|
||||
):
|
||||
"""
|
||||
Return a list of Recording objects from the nPVR backend.
|
||||
|
||||
Args:
|
||||
include_deleted: When True, include recordings with status
|
||||
TO_DELETE or DELETED. Defaults to False.
|
||||
limit: Max recordings per request (default/max 500).
|
||||
offset: 1-based page offset (default 1).
|
||||
|
||||
Returns:
|
||||
List of :class:`Recording` objects.
|
||||
|
||||
Raises:
|
||||
RuntimeError: When RecordingsManager is not initialised or the
|
||||
API request fails.
|
||||
"""
|
||||
def get_recordings(self, include_deleted: bool = False, **kwargs):
|
||||
"""Return a list of Recording objects from the nPVR backend."""
|
||||
if not self._recordings_manager:
|
||||
raise RuntimeError(
|
||||
"RecordingsManager not available — configuration discovery may have failed"
|
||||
@@ -1194,41 +1182,21 @@ class Magenta2Provider(StreamingProvider):
|
||||
)
|
||||
|
||||
def delete_recording(self, recording_id: str, **kwargs) -> None:
|
||||
"""
|
||||
Permanently delete a recording on the nPVR backend.
|
||||
|
||||
Args:
|
||||
recording_id: The recording's ``id`` field as returned by
|
||||
get_recordings() (not externalRecordingId).
|
||||
|
||||
Raises:
|
||||
RuntimeError: When RecordingsManager is not initialised or the
|
||||
API returns a non-success status.
|
||||
KeyError: When the recording does not exist on the provider.
|
||||
"""
|
||||
"""Permanently delete a recording on the nPVR backend."""
|
||||
if not self._recordings_manager:
|
||||
raise RuntimeError(
|
||||
"RecordingsManager not available — configuration discovery may have failed"
|
||||
)
|
||||
self._recordings_manager.delete_recording(recording_id)
|
||||
|
||||
def get_recording_manifest(
|
||||
self, recording_id: str, **kwargs
|
||||
) -> Optional[str]:
|
||||
def get_recording_manifest(self, recording_id: str, **kwargs) -> Optional[str]:
|
||||
"""
|
||||
Return the playback URL for a specific recording by ID.
|
||||
|
||||
For recordings already in memory (returned by get_recordings), the
|
||||
``manifest_script`` field on the Recording object already contains the
|
||||
playback URL. This method performs a fresh API lookup — use it only
|
||||
when the Recording object is not available.
|
||||
|
||||
Args:
|
||||
recording_id: The recording's ``id`` field.
|
||||
|
||||
Returns:
|
||||
The ``playbackUrl`` string, or None when unavailable (e.g.
|
||||
recording is still PENDING or FAILED).
|
||||
manifest_script field already contains the playback URL. This method
|
||||
performs a fresh API lookup — use it only when the Recording object is
|
||||
not available.
|
||||
"""
|
||||
if not self._recordings_manager:
|
||||
return None
|
||||
@@ -1292,403 +1260,28 @@ class Magenta2Provider(StreamingProvider):
|
||||
logger.error(f"Error getting manifest for {channel.name}: {e}")
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _parse_smil_for_mpd(smil_content: str, channel_id: str) -> Optional[str]:
|
||||
"""
|
||||
Parse SMIL response to extract MPD URL from <video src="..."> tag
|
||||
|
||||
Args:
|
||||
smil_content: Raw SMIL XML content
|
||||
channel_id: Channel ID for logging
|
||||
|
||||
Returns:
|
||||
MPD URL string or None if not found
|
||||
"""
|
||||
try:
|
||||
logger.debug(
|
||||
f"Parsing SMIL response for channel {channel_id} (length: {len(smil_content)} chars)"
|
||||
)
|
||||
|
||||
# First check for error cases
|
||||
error_title_pattern = r'<ref[^>]*title="([^"]*)"[^>]*abstract="([^"]*)"[^>]*>'
|
||||
error_match = re.search(error_title_pattern, smil_content)
|
||||
|
||||
if error_match:
|
||||
title = error_match.group(1)
|
||||
abstract = error_match.group(2)
|
||||
logger.warning(
|
||||
f"SMIL error response for channel {channel_id}: {title} - {abstract}"
|
||||
)
|
||||
|
||||
# Check for specific error patterns
|
||||
if "errorFiles/Unavailable.flv" in smil_content:
|
||||
logger.error(f"SMIL returned unavailable content for channel {channel_id}")
|
||||
return None
|
||||
if "Invalid Token" in title or "InvalidAuthToken" in smil_content:
|
||||
logger.error(f"Invalid authentication token for channel {channel_id}")
|
||||
return None
|
||||
if "403" in smil_content:
|
||||
logger.error(f"Access forbidden (403) for channel {channel_id}")
|
||||
return None
|
||||
|
||||
# Look for <video src="..."> tag first (primary source)
|
||||
video_src_pattern = r'<video\s+src="([^"]+)"'
|
||||
video_match = re.search(video_src_pattern, smil_content)
|
||||
|
||||
if video_match:
|
||||
mpd_url = video_match.group(1)
|
||||
logger.debug(f"Found MPD URL in <video> tag for channel {channel_id}")
|
||||
|
||||
# Log additional info if available in ref tag
|
||||
ref_info_pattern = (
|
||||
r'<ref[^>]*src="([^"]*)"[^>]*title="([^"]*)"[^>]*abstract="([^"]*)"'
|
||||
)
|
||||
ref_info_match = re.search(ref_info_pattern, smil_content)
|
||||
if ref_info_match and ref_info_match.group(1) == mpd_url:
|
||||
title = ref_info_match.group(2)
|
||||
abstract = ref_info_match.group(3)
|
||||
logger.debug(f"Stream info: {title} - {abstract}")
|
||||
|
||||
return mpd_url
|
||||
|
||||
# Fallback to <ref src="..."> tag if <video> not found
|
||||
ref_src_pattern = r'<ref\s+src="([^"]+)"'
|
||||
ref_match = re.search(ref_src_pattern, smil_content)
|
||||
|
||||
if ref_match:
|
||||
mpd_url = ref_match.group(1)
|
||||
logger.debug(f"Found MPD URL in <ref> tag for channel {channel_id}")
|
||||
|
||||
# Extract title and abstract from the ref tag
|
||||
ref_full_pattern = (
|
||||
r'<ref[^>]*src="%s"[^>]*title="([^"]*)"[^>]*abstract="([^"]*)"'
|
||||
% re.escape(mpd_url)
|
||||
)
|
||||
ref_full_match = re.search(ref_full_pattern, smil_content)
|
||||
if ref_full_match:
|
||||
title = ref_full_match.group(1)
|
||||
abstract = ref_full_match.group(2)
|
||||
logger.debug(f"Stream info: {title} - {abstract}")
|
||||
|
||||
return mpd_url
|
||||
|
||||
# If we get here, no MPD URL was found
|
||||
logger.warning(f"No MPD URL found in SMIL response for channel {channel_id}")
|
||||
|
||||
if len(smil_content) < 1000: # Only log if it's reasonably short
|
||||
logger.debug(f"Full SMIL content: {smil_content}")
|
||||
else:
|
||||
logger.debug(f"SMIL content preview: {smil_content[:500]}...")
|
||||
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error parsing SMIL response for channel {channel_id}: {e}")
|
||||
return None
|
||||
|
||||
def get_manifest(
|
||||
self, content_id: str, content_type: str = CONTENT_TYPE_LIVE, **kwargs
|
||||
) -> Optional[str]:
|
||||
"""Get MPD manifest URL using cached SMIL data"""
|
||||
smil_data = self._get_smil_data(content_id)
|
||||
return smil_data.get("mpd_url") if smil_data else None
|
||||
"""Get MPD manifest URL via SmilManager."""
|
||||
if not self._smil_manager:
|
||||
raise RuntimeError("SmilManager not available")
|
||||
return self._smil_manager.get_manifest(content_id, content_type, **kwargs)
|
||||
|
||||
def get_catchup_manifest(
|
||||
self, channel_id: str, start_time: int, end_time: int, **kwargs
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Get catchup manifest URL for Magenta TV.
|
||||
"""Get catchup manifest URL via SmilManager."""
|
||||
if not self._smil_manager:
|
||||
raise RuntimeError("SmilManager not available")
|
||||
return self._smil_manager.get_catchup_manifest(
|
||||
channel_id, start_time, end_time, **kwargs
|
||||
)
|
||||
|
||||
Returns the same manifest as get_manifest but extended with
|
||||
?begin=YYYYMMDDTHHMMSS&end=YYYYMMDDTHHMMSS query parameters.
|
||||
|
||||
Args:
|
||||
channel_id: Channel identifier
|
||||
start_time: Start time as Unix timestamp (epoch seconds)
|
||||
end_time: End time as Unix timestamp (epoch seconds)
|
||||
**kwargs: Additional parameters (epg_id, etc.)
|
||||
|
||||
Returns:
|
||||
Manifest URL with catchup time parameters, or None if channel not found
|
||||
"""
|
||||
from ...base.utils.timestamp_converter import TimestampConverter
|
||||
|
||||
# First get the base manifest URL
|
||||
base_manifest = self.get_manifest(channel_id, **kwargs)
|
||||
|
||||
if not base_manifest:
|
||||
logger.warning(f"Channel {channel_id} not found or has no manifest")
|
||||
return None
|
||||
|
||||
try:
|
||||
# Convert epoch seconds to ISO basic format (YYYYMMDDTHHMMSS)
|
||||
start_iso = TimestampConverter.epoch_to_iso(
|
||||
start_time, format_type="basic", as_utc=True
|
||||
)
|
||||
end_iso = TimestampConverter.epoch_to_iso(end_time, format_type="basic", as_utc=True)
|
||||
|
||||
# Build the catchup manifest URL
|
||||
# Check if the manifest already has query parameters
|
||||
separator = "&" if "?" in base_manifest else "?"
|
||||
catchup_manifest = f"{base_manifest}{separator}begin={start_iso}&end={end_iso}"
|
||||
|
||||
logger.debug(f"Catchup manifest for channel {channel_id}: {catchup_manifest}")
|
||||
return catchup_manifest
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error building catchup manifest for channel {channel_id}: {e}")
|
||||
# Fall back to live manifest if catchup formatting fails
|
||||
logger.warning(f"Falling back to live manifest for channel {channel_id}")
|
||||
return base_manifest
|
||||
|
||||
def _resolve_gn_id_to_media_id(self, gn_id: str) -> Optional[str]:
|
||||
"""
|
||||
Resolve a Gracenote content id (GN_EP*, GN_MV*, GN_SH*) to the
|
||||
alphanumeric MPX guid needed by the SMIL selector URL (.../media/{guid}).
|
||||
|
||||
Called at play time only — never during browse. Runs the full chain:
|
||||
VodDetails → productInformationLink → VodPlayer → playbackUrls
|
||||
and extracts the guid from the theplatform href in manifest_script.
|
||||
"""
|
||||
if not self._vod_manager:
|
||||
logger.warning(f"Cannot resolve GN id {gn_id}: VodManager not available")
|
||||
return None
|
||||
try:
|
||||
# Route to the correct resolver based on GN id prefix.
|
||||
# get_children with the opaque prefix triggers full resolution.
|
||||
if gn_id.startswith(VOD_PREFIX_EPISODE):
|
||||
result = self._vod_manager.get_children(f"episode:{gn_id}")
|
||||
elif gn_id.startswith(VOD_PREFIX_MOVIE_MV) or gn_id.startswith(VOD_PREFIX_MOVIE_SH):
|
||||
result = self._vod_manager.get_children(f"movie:{gn_id}")
|
||||
else:
|
||||
result = self._vod_manager.get_children(gn_id)
|
||||
|
||||
# get_children always returns {"entries": [...], ...} — unwrap it.
|
||||
# Guard against any legacy path that may still return a plain list.
|
||||
items = result.get("entries", []) if isinstance(result, dict) else result
|
||||
|
||||
if items:
|
||||
item = items[0]
|
||||
manifest_script = getattr(item, "manifest_script", None)
|
||||
|
||||
# If manifest_script is a vodproductinformation URL the content
|
||||
# is not entitled (subscriptionMissing). Stop immediately.
|
||||
if manifest_script and "vodproductinformation" in manifest_script:
|
||||
logger.warning(
|
||||
f"{gn_id}: content not entitled "
|
||||
f"(subscriptionMissing)"
|
||||
)
|
||||
return None
|
||||
|
||||
if manifest_script:
|
||||
# href: https://link.theplatform.eu/s/mdeprod/media/zRPPGLNGgPa1
|
||||
guid = manifest_script.rstrip("/").rsplit("/media/", 1)[-1].split("?")[0]
|
||||
if guid and guid != manifest_script:
|
||||
logger.debug(f"Resolved {gn_id} → guid {guid}")
|
||||
return guid
|
||||
|
||||
# Fallback: content_id already IS the guid (set by _fetch_single_item)
|
||||
resolved = getattr(item, "content_id", None)
|
||||
if resolved and resolved not in (gn_id, f"movie:{gn_id}", f"episode:{gn_id}"):
|
||||
logger.debug(f"Resolved {gn_id} → {resolved}")
|
||||
return resolved
|
||||
|
||||
logger.warning(f"GN id {gn_id} resolution returned no usable id")
|
||||
except Exception as exc:
|
||||
logger.warning(f"GN id {gn_id} resolution failed: {exc}")
|
||||
return None
|
||||
|
||||
def _get_smil_data(self, channel_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Get complete SMIL data with caching"""
|
||||
# Strip opaque prefixes added at browse time — resolution happens here.
|
||||
if channel_id.startswith("movie:") or channel_id.startswith("episode:"):
|
||||
gn_id = channel_id.split(":", 1)[1]
|
||||
resolved = self._resolve_gn_id_to_media_id(gn_id)
|
||||
if resolved:
|
||||
channel_id = resolved
|
||||
else:
|
||||
logger.error(
|
||||
f"Cannot play {channel_id}: failed to resolve to MPX guid"
|
||||
)
|
||||
return None
|
||||
|
||||
# Legacy: bare GN_EP*/GN_MV*/GN_SH* ids (pre-opaque-prefix era)
|
||||
elif (
|
||||
channel_id.startswith(VOD_PREFIX_EPISODE)
|
||||
or channel_id.startswith(VOD_PREFIX_MOVIE_MV)
|
||||
or channel_id.startswith(VOD_PREFIX_MOVIE_SH)
|
||||
):
|
||||
resolved = self._resolve_gn_id_to_media_id(channel_id)
|
||||
if resolved:
|
||||
channel_id = resolved
|
||||
else:
|
||||
logger.error(
|
||||
f"Cannot play {channel_id}: failed to resolve GN id to MPX mediaId"
|
||||
)
|
||||
return None
|
||||
|
||||
# Check cache first
|
||||
now = time.time()
|
||||
if channel_id in self._smil_cache:
|
||||
timestamp, cached_data = self._smil_cache[channel_id]
|
||||
if now - timestamp < self._smil_cache_ttl:
|
||||
logger.debug(f"Using cached SMIL data for {channel_id}")
|
||||
return cached_data
|
||||
else:
|
||||
# Cache expired
|
||||
del self._smil_cache[channel_id]
|
||||
|
||||
try:
|
||||
smil_content = self._get_smil_content(channel_id)
|
||||
if not smil_content:
|
||||
logger.error(f"No SMIL content received for channel {channel_id}")
|
||||
return None
|
||||
|
||||
if len(smil_content.strip()) == 0:
|
||||
logger.error(f"Empty SMIL content for channel {channel_id}")
|
||||
return None
|
||||
|
||||
if "<smil" not in smil_content.lower():
|
||||
logger.error(
|
||||
f"Invalid SMIL content for channel {channel_id}: {smil_content[:200]}..."
|
||||
)
|
||||
return None
|
||||
|
||||
mpd_url = self._parse_smil_for_mpd(smil_content, channel_id)
|
||||
release_pid = self._extract_release_pid_from_smil(smil_content)
|
||||
|
||||
smil_data = {
|
||||
"content": smil_content,
|
||||
"mpd_url": mpd_url,
|
||||
"release_pid": release_pid,
|
||||
"channel_id": channel_id,
|
||||
}
|
||||
|
||||
# Only cache if we have valid data
|
||||
if mpd_url or release_pid:
|
||||
self._smil_cache[channel_id] = (now, smil_data)
|
||||
logger.debug(f"Cached SMIL data for {channel_id}")
|
||||
else:
|
||||
logger.warning(f"No MPD URL or releasePid found for {channel_id}, not caching")
|
||||
|
||||
return smil_data
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting SMIL data for channel {channel_id}: {e}")
|
||||
return None
|
||||
|
||||
def _get_smil_content(self, channel_id: str) -> Optional[str]:
|
||||
"""Get SMIL content for a channel to extract releasePid and release concurrency lock"""
|
||||
logger.debug(f"🔵 ENTER _get_smil_content for channel: {channel_id}")
|
||||
|
||||
try:
|
||||
logger.debug("🔵 Step 1: Calling _ensure_authenticated()")
|
||||
persona_token = self._ensure_authenticated()
|
||||
logger.debug(f"🔵 _ensure_authenticated() SUCCESS, token length: {len(persona_token)}")
|
||||
|
||||
if not persona_token:
|
||||
logger.error(f"No persona token!:")
|
||||
return None
|
||||
|
||||
selector_service = self.endpoint_manager.get_endpoint("mpx_selector")
|
||||
if not selector_service:
|
||||
logger.error(f"No selector service!:")
|
||||
return None
|
||||
|
||||
account_pid = self.provider_config.manifest.mpx.account_pid
|
||||
if not account_pid:
|
||||
logger.error(f"No account pid!:")
|
||||
return None
|
||||
|
||||
smil_url = (
|
||||
f"{selector_service}{account_pid}/media/{channel_id}"
|
||||
f"?format=smil&formats=MPEG-DASH&tracking=true&clientId={SMIL_CLIENT_ID}"
|
||||
)
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Basic {persona_token}",
|
||||
"User-Agent": self.platform_config["user_agent"],
|
||||
"Accept": "application/smil+xml, application/xml;q=0.9, */*;q=0.8",
|
||||
}
|
||||
|
||||
logger.debug(f"SMIL Request URL: {smil_url}")
|
||||
logger.debug(f"SMIL Request Headers:")
|
||||
for key, value in headers.items():
|
||||
if key == "Authorization":
|
||||
logger.debug(f" {key}: Basic [REDACTED] (length: {len(persona_token)})")
|
||||
else:
|
||||
logger.debug(f" {key}: {value}")
|
||||
|
||||
# Also debug the persona token structure
|
||||
try:
|
||||
decoded = base64.b64decode(persona_token).decode("utf-8")
|
||||
logger.debug(f"Decoded persona token preview: {decoded[:100]}...")
|
||||
except Exception as decode_error:
|
||||
logger.debug(f"Could not decode persona token: {decode_error}")
|
||||
|
||||
response = self.http_manager.get(
|
||||
smil_url,
|
||||
operation="manifest_smil_drm",
|
||||
headers=headers,
|
||||
timeout=DEFAULT_REQUEST_TIMEOUT,
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
smil_content = response.text
|
||||
|
||||
if not smil_content:
|
||||
logger.error("Empty SMIL content received")
|
||||
return None
|
||||
|
||||
# RELEASE CONCURRENCY LOCK IMMEDIATELY AFTER GETTING SMIL
|
||||
from .concurrency import extract_and_release_lock
|
||||
|
||||
extract_and_release_lock(
|
||||
smil_content,
|
||||
self.http_manager,
|
||||
client_id=SMIL_CLIENT_ID,
|
||||
user_agent=self.platform_config["user_agent"],
|
||||
)
|
||||
|
||||
return smil_content
|
||||
else:
|
||||
logger.error(f"Failed to get SMIL content for DRM: {response.status_code}")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting SMIL content for DRM: {e}")
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _extract_release_pid_from_smil(smil_content: str) -> Optional[str]:
|
||||
"""Extract releasePid from SMIL trackingData parameter"""
|
||||
try:
|
||||
# Look for trackingData parameter in ref tag
|
||||
tracking_data_pattern = r'<param name="trackingData" value="([^"]*)"'
|
||||
match = re.search(tracking_data_pattern, smil_content)
|
||||
|
||||
if not match:
|
||||
logger.warning("No trackingData found in SMIL content")
|
||||
return None
|
||||
|
||||
tracking_data = match.group(1)
|
||||
logger.debug(f"Found trackingData: {tracking_data}")
|
||||
|
||||
# Extract pid from trackingData (pid=value)
|
||||
pid_pattern = r"pid=([^|]+)"
|
||||
pid_match = re.search(pid_pattern, tracking_data)
|
||||
|
||||
if pid_match:
|
||||
release_pid = pid_match.group(1)
|
||||
logger.debug(f"Extracted releasePid: {release_pid}")
|
||||
return release_pid
|
||||
else:
|
||||
logger.warning("No pid found in trackingData")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error extracting releasePid from SMIL: {e}")
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _extract_persona_jwt_from_token(persona_token: str) -> Optional[str]:
|
||||
@@ -1720,85 +1313,10 @@ class Magenta2Provider(StreamingProvider):
|
||||
def get_drm(
|
||||
self, content_id: str, content_type: str = CONTENT_TYPE_LIVE, **kwargs
|
||||
) -> List[DRMConfig]:
|
||||
"""Get DRM configuration using unified SMIL data"""
|
||||
try:
|
||||
smil_data = self._get_smil_data(content_id)
|
||||
if not smil_data:
|
||||
logger.error(f"No SMIL data found for channel {content_id}")
|
||||
return []
|
||||
|
||||
if not smil_data.get("release_pid"):
|
||||
logger.error(f"No releasePid found in SMIL for channel {content_id}")
|
||||
logger.debug(f"SMIL data keys: {list(smil_data.keys())}")
|
||||
if "content" in smil_data and smil_data["content"]:
|
||||
logger.debug(f"SMIL content preview: {smil_data['content'][:500]}...")
|
||||
return []
|
||||
|
||||
release_pid = smil_data["release_pid"]
|
||||
|
||||
# Get persona token
|
||||
persona_token = self._ensure_authenticated()
|
||||
|
||||
# Extract the persona JWT from the Base64-encoded persona token
|
||||
raw_persona_jwt = self._extract_persona_jwt_from_token(persona_token)
|
||||
if not raw_persona_jwt:
|
||||
logger.error("Failed to extract persona JWT from persona token")
|
||||
return []
|
||||
|
||||
# Get widevine endpoint
|
||||
widevine_endpoint = self.endpoint_manager.get_endpoint("widevine_license")
|
||||
if not widevine_endpoint:
|
||||
logger.error("No widevine license endpoint available")
|
||||
return []
|
||||
|
||||
# Get account URI
|
||||
account_uri = self._get_account_uri()
|
||||
encoded_account_uri = quote(account_uri, safe="")
|
||||
|
||||
# Build license URL with the extracted JWT
|
||||
license_url = (
|
||||
f"{widevine_endpoint}?"
|
||||
f"schema=1.0&"
|
||||
f"releasePid={release_pid}&"
|
||||
f"token={raw_persona_jwt}&"
|
||||
f"account={encoded_account_uri}"
|
||||
)
|
||||
|
||||
# Create DRM config
|
||||
drm_config = DRMConfig(
|
||||
system=DRMSystem.WIDEVINE,
|
||||
priority=1,
|
||||
license=LicenseConfig.create_with_req_data(
|
||||
req_data_template="{CHA-RAW}",
|
||||
server_url=license_url,
|
||||
server_certificate=None,
|
||||
req_headers=json.dumps(
|
||||
{
|
||||
"User-Agent": self.platform_config["user_agent"],
|
||||
"Content-Type": "application/octet-stream",
|
||||
}
|
||||
),
|
||||
use_http_get_request=False,
|
||||
),
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"✓ DRM configuration created for channel {content_id} (releasePid: {release_pid})"
|
||||
)
|
||||
return [drm_config]
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting DRM configs for channel {content_id}: {e}")
|
||||
return []
|
||||
|
||||
def _get_account_uri(self) -> str:
|
||||
"""Get account URI with fallback logic"""
|
||||
if self.provider_config and self.provider_config.manifest:
|
||||
account_uri = self.provider_config.manifest.mpx.get_account_uri()
|
||||
if account_uri:
|
||||
return account_uri
|
||||
|
||||
return MAGENTA2_FALLBACK_ACCOUNT_URI
|
||||
"""Get DRM configuration via SmilManager."""
|
||||
if not self._smil_manager:
|
||||
raise RuntimeError("SmilManager not available")
|
||||
return self._smil_manager.get_drm(content_id, content_type, **kwargs)
|
||||
|
||||
def get_epg(
|
||||
self,
|
||||
|
||||
@@ -0,0 +1,643 @@
|
||||
# streaming_providers/providers/magenta2/smil_manager.py
|
||||
"""
|
||||
Magenta2 SMIL / Playback Manager
|
||||
|
||||
Handles all SMIL fetching, parsing, caching, and DRM resolution for the
|
||||
Magenta2 provider. This is the shared playback layer used by live channels,
|
||||
VOD items, and recordings — content type does not affect the code path.
|
||||
|
||||
SMIL URL strategy
|
||||
-----------------
|
||||
VOD items and recordings already carry the full theplatform selector URL in
|
||||
their ``manifest_script`` field (e.g.
|
||||
``https://link.theplatform.eu/s/mdeprod/media/{guid}``). That URL is used
|
||||
directly; only the standard SMIL query params are appended.
|
||||
|
||||
Live channels have no pre-resolved URL. The URL is constructed at request
|
||||
time from the discovered selector endpoint + account PID + MPX GUID.
|
||||
|
||||
In both cases the query params are identical:
|
||||
?format=SMIL&formats=MPEG-DASH&tracking=true&cid={session}::{call}
|
||||
|
||||
GN id resolution (VOD only)
|
||||
-----------------------------
|
||||
When ``get_smil_data`` receives an opaque browse-time id (``movie:GN_MV…``,
|
||||
``episode:GN_EP…``) or a legacy bare GN id, it resolves it to the full
|
||||
theplatform URL via ``_resolve_gn_id_to_smil_url``. This triggers the full
|
||||
VodDetails -> productInformation -> VodPlayer chain and is the only place
|
||||
where an extra network round-trip occurs at play time.
|
||||
|
||||
Public interface
|
||||
----------------
|
||||
smil_manager.get_smil_data(content_id) -> Optional[Dict]
|
||||
smil_manager.get_manifest(content_id) -> Optional[str]
|
||||
smil_manager.get_catchup_manifest(channel_id, start_time, end_time) -> Optional[str]
|
||||
smil_manager.get_drm(content_id) -> List[DRMConfig]
|
||||
"""
|
||||
|
||||
import base64
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
from urllib.parse import quote
|
||||
|
||||
from ...base.models import DRMConfig, DRMSystem, LicenseConfig
|
||||
from ...base.utils.logger import logger
|
||||
|
||||
from .constants import (
|
||||
CONTENT_TYPE_LIVE,
|
||||
DEFAULT_REQUEST_TIMEOUT,
|
||||
MAGENTA2_FALLBACK_ACCOUNT_URI,
|
||||
SMIL_CACHE_DURATION,
|
||||
SMIL_CLIENT_ID,
|
||||
VOD_PREFIX_EPISODE,
|
||||
VOD_PREFIX_MOVIE_MV,
|
||||
VOD_PREFIX_MOVIE_SH,
|
||||
)
|
||||
|
||||
|
||||
class SmilManager:
|
||||
"""
|
||||
Shared SMIL / playback layer for Magenta2.
|
||||
|
||||
Args:
|
||||
http_manager: HTTPManager instance from the parent provider.
|
||||
provider_name: Provider identifier string (e.g. ``"magenta2"``).
|
||||
session_id: Stable session UUID used in ``cid`` correlation header.
|
||||
call_id_callback: Callable ``() -> str`` returning a fresh UUID per request.
|
||||
auth_callback: Callable ``() -> str`` returning the current
|
||||
Base64-encoded persona token.
|
||||
platform_config: Platform dict from ``MAGENTA2_PLATFORMS`` — supplies
|
||||
User-Agent and DRM request headers.
|
||||
endpoint_manager: EndpointManager for selector / widevine endpoint lookup.
|
||||
provider_config: ProviderConfig — account PID and account URI.
|
||||
vod_manager: Optional VodManager — required only for GN id resolution.
|
||||
cache_ttl: SMIL cache TTL in seconds (default: SMIL_CACHE_DURATION).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
http_manager,
|
||||
provider_name: str,
|
||||
session_id: str,
|
||||
call_id_callback,
|
||||
auth_callback,
|
||||
platform_config: Dict,
|
||||
endpoint_manager,
|
||||
provider_config,
|
||||
vod_manager=None,
|
||||
cache_ttl: int = SMIL_CACHE_DURATION,
|
||||
):
|
||||
self._http = http_manager
|
||||
self._provider = provider_name
|
||||
self._session_id = session_id
|
||||
self._call_id = call_id_callback
|
||||
self._auth = auth_callback
|
||||
self._platform_config = platform_config
|
||||
self._endpoint_manager = endpoint_manager
|
||||
self._provider_config = provider_config
|
||||
self._vod_manager = vod_manager
|
||||
self._cache: Dict[str, Tuple[float, Dict]] = {}
|
||||
self._cache_ttl = cache_ttl
|
||||
|
||||
# =========================================================================
|
||||
# Public API
|
||||
# =========================================================================
|
||||
|
||||
def get_manifest(
|
||||
self, content_id: str, content_type: str = CONTENT_TYPE_LIVE, **kwargs
|
||||
) -> Optional[str]:
|
||||
"""Return the MPD URL for *content_id*, or None on failure."""
|
||||
smil_data = self.get_smil_data(content_id)
|
||||
return smil_data.get("mpd_url") if smil_data else None
|
||||
|
||||
def get_catchup_manifest(
|
||||
self, channel_id: str, start_time: int, end_time: int, **kwargs
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Return a live manifest extended with catchup time-range parameters.
|
||||
|
||||
Args:
|
||||
channel_id: MPX GUID for the live channel.
|
||||
start_time: Catchup start as Unix epoch seconds.
|
||||
end_time: Catchup end as Unix epoch seconds.
|
||||
"""
|
||||
from ...base.utils.timestamp_converter import TimestampConverter
|
||||
|
||||
base_manifest = self.get_manifest(channel_id, **kwargs)
|
||||
if not base_manifest:
|
||||
logger.warning(
|
||||
f"{self._provider}: Channel {channel_id} has no manifest for catchup"
|
||||
)
|
||||
return None
|
||||
|
||||
try:
|
||||
start_iso = TimestampConverter.epoch_to_iso(
|
||||
start_time, format_type="basic", as_utc=True
|
||||
)
|
||||
end_iso = TimestampConverter.epoch_to_iso(end_time, format_type="basic", as_utc=True)
|
||||
separator = "&" if "?" in base_manifest else "?"
|
||||
catchup_url = f"{base_manifest}{separator}begin={start_iso}&end={end_iso}"
|
||||
logger.debug(
|
||||
f"{self._provider}: Catchup manifest for {channel_id}: {catchup_url}"
|
||||
)
|
||||
return catchup_url
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"{self._provider}: Error building catchup manifest for {channel_id}: {e}"
|
||||
)
|
||||
logger.warning(
|
||||
f"{self._provider}: Falling back to live manifest for {channel_id}"
|
||||
)
|
||||
return base_manifest
|
||||
|
||||
def get_drm(
|
||||
self, content_id: str, content_type: str = CONTENT_TYPE_LIVE, **kwargs
|
||||
) -> List[DRMConfig]:
|
||||
"""
|
||||
Return a Widevine DRMConfig for *content_id*.
|
||||
|
||||
Resolves the releasePid from cached SMIL data, extracts the persona JWT,
|
||||
and constructs the full Widevine licence URL.
|
||||
"""
|
||||
try:
|
||||
smil_data = self.get_smil_data(content_id)
|
||||
if not smil_data:
|
||||
logger.error(f"{self._provider}: No SMIL data for {content_id}")
|
||||
return []
|
||||
|
||||
release_pid = smil_data.get("release_pid")
|
||||
if not release_pid:
|
||||
logger.error(
|
||||
f"{self._provider}: No releasePid in SMIL for {content_id}"
|
||||
)
|
||||
if smil_data.get("content"):
|
||||
logger.debug(
|
||||
f"{self._provider}: SMIL preview: {smil_data['content'][:500]}..."
|
||||
)
|
||||
return []
|
||||
|
||||
persona_token = self._auth()
|
||||
raw_jwt = self._extract_persona_jwt(persona_token)
|
||||
if not raw_jwt:
|
||||
logger.error(f"{self._provider}: Failed to extract persona JWT")
|
||||
return []
|
||||
|
||||
widevine_endpoint = self._endpoint_manager.get_endpoint("widevine_license")
|
||||
if not widevine_endpoint:
|
||||
logger.error(
|
||||
f"{self._provider}: No widevine_license endpoint available"
|
||||
)
|
||||
return []
|
||||
|
||||
account_uri = self._get_account_uri()
|
||||
license_url = (
|
||||
f"{widevine_endpoint}?"
|
||||
f"schema=1.0&"
|
||||
f"releasePid={release_pid}&"
|
||||
f"token={raw_jwt}&"
|
||||
f"account={quote(account_uri, safe='')}"
|
||||
)
|
||||
|
||||
drm_config = DRMConfig(
|
||||
system=DRMSystem.WIDEVINE,
|
||||
priority=1,
|
||||
license=LicenseConfig.create_with_req_data(
|
||||
req_data_template="{CHA-RAW}",
|
||||
server_url=license_url,
|
||||
server_certificate=None,
|
||||
req_headers=json.dumps(
|
||||
{
|
||||
"User-Agent": self._platform_config["user_agent"],
|
||||
"Content-Type": "application/octet-stream",
|
||||
}
|
||||
),
|
||||
use_http_get_request=False,
|
||||
),
|
||||
)
|
||||
logger.info(
|
||||
f"{self._provider}: DRM config for {content_id} "
|
||||
f"(releasePid: {release_pid})"
|
||||
)
|
||||
return [drm_config]
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"{self._provider}: Error getting DRM for {content_id}: {e}")
|
||||
return []
|
||||
|
||||
def get_smil_data(self, content_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Resolve *content_id* to SMIL data (MPD URL + releasePid), with caching.
|
||||
|
||||
Dispatch:
|
||||
- ``movie:…`` / ``episode:…`` -> GN id resolution via VodManager
|
||||
- bare ``GN_EP*`` / ``GN_MV*`` / ``GN_SH*`` -> same (legacy compat)
|
||||
- anything else -> treated as a raw MPX GUID (live channel)
|
||||
|
||||
Cache key is always the bare MPX GUID so repeated calls for the same
|
||||
content under different prefixes share one entry.
|
||||
"""
|
||||
smil_base_url: Optional[str] = None
|
||||
|
||||
# ── Opaque VOD prefixes ──────────────────────────────────────────
|
||||
if content_id.startswith("movie:") or content_id.startswith("episode:"):
|
||||
gn_id = content_id.split(":", 1)[1]
|
||||
smil_base_url = self._resolve_gn_id_to_smil_url(gn_id)
|
||||
if not smil_base_url:
|
||||
logger.error(
|
||||
f"{self._provider}: Cannot play {content_id}: "
|
||||
"failed to resolve to SMIL URL"
|
||||
)
|
||||
return None
|
||||
|
||||
# ── Legacy bare GN ids ───────────────────────────────────────────
|
||||
elif (
|
||||
content_id.startswith(VOD_PREFIX_EPISODE)
|
||||
or content_id.startswith(VOD_PREFIX_MOVIE_MV)
|
||||
or content_id.startswith(VOD_PREFIX_MOVIE_SH)
|
||||
):
|
||||
smil_base_url = self._resolve_gn_id_to_smil_url(content_id)
|
||||
if not smil_base_url:
|
||||
logger.error(
|
||||
f"{self._provider}: Cannot play {content_id}: "
|
||||
"failed to resolve GN id to SMIL URL"
|
||||
)
|
||||
return None
|
||||
|
||||
# ── Stable cache key (bare GUID) ─────────────────────────────────
|
||||
if smil_base_url:
|
||||
tail = smil_base_url.rstrip("/").rsplit("/media/", 1)
|
||||
cache_key = tail[1].split("?")[0] if len(tail) == 2 else content_id
|
||||
else:
|
||||
cache_key = content_id
|
||||
|
||||
# ── Cache check ──────────────────────────────────────────────────
|
||||
now = time.time()
|
||||
if cache_key in self._cache:
|
||||
ts, cached = self._cache[cache_key]
|
||||
if now - ts < self._cache_ttl:
|
||||
logger.debug(f"{self._provider}: SMIL cache hit for {cache_key}")
|
||||
return cached
|
||||
del self._cache[cache_key]
|
||||
|
||||
# ── Fetch ────────────────────────────────────────────────────────
|
||||
try:
|
||||
smil_content = self._fetch_smil(cache_key, smil_base_url=smil_base_url)
|
||||
if not smil_content:
|
||||
logger.error(f"{self._provider}: No SMIL content for {cache_key}")
|
||||
return None
|
||||
if not smil_content.strip():
|
||||
logger.error(f"{self._provider}: Empty SMIL content for {cache_key}")
|
||||
return None
|
||||
if "<smil" not in smil_content.lower():
|
||||
logger.error(
|
||||
f"{self._provider}: Invalid SMIL for {cache_key}: "
|
||||
f"{smil_content[:200]}..."
|
||||
)
|
||||
return None
|
||||
|
||||
mpd_url = self._parse_mpd_url(smil_content, cache_key)
|
||||
release_pid = self._parse_release_pid(smil_content)
|
||||
|
||||
smil_data: Dict[str, Any] = {
|
||||
"content": smil_content,
|
||||
"mpd_url": mpd_url,
|
||||
"release_pid": release_pid,
|
||||
"channel_id": cache_key,
|
||||
}
|
||||
|
||||
if mpd_url or release_pid:
|
||||
self._cache[cache_key] = (now, smil_data)
|
||||
logger.debug(f"{self._provider}: Cached SMIL data for {cache_key}")
|
||||
else:
|
||||
logger.warning(
|
||||
f"{self._provider}: No MPD URL or releasePid for "
|
||||
f"{cache_key}, not caching"
|
||||
)
|
||||
|
||||
return smil_data
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"{self._provider}: Error getting SMIL data for {cache_key}: {e}"
|
||||
)
|
||||
return None
|
||||
|
||||
# =========================================================================
|
||||
# Private – GN id resolution
|
||||
# =========================================================================
|
||||
|
||||
def _resolve_gn_id_to_smil_url(self, gn_id: str) -> Optional[str]:
|
||||
"""
|
||||
Resolve a Gracenote content id to the full theplatform selector URL.
|
||||
|
||||
Returns manifest_script directly when available — it is already the
|
||||
correct URL and requires no further reconstruction. Falls back to
|
||||
constructing a URL from selector + account_pid + content_id when
|
||||
manifest_script is absent but the guid was resolved via content_id.
|
||||
"""
|
||||
if not self._vod_manager:
|
||||
logger.warning(
|
||||
f"{self._provider}: Cannot resolve GN id {gn_id}: "
|
||||
"VodManager not available"
|
||||
)
|
||||
return None
|
||||
try:
|
||||
if gn_id.startswith(VOD_PREFIX_EPISODE):
|
||||
result = self._vod_manager.get_children(f"episode:{gn_id}")
|
||||
elif (
|
||||
gn_id.startswith(VOD_PREFIX_MOVIE_MV)
|
||||
or gn_id.startswith(VOD_PREFIX_MOVIE_SH)
|
||||
):
|
||||
result = self._vod_manager.get_children(f"movie:{gn_id}")
|
||||
else:
|
||||
result = self._vod_manager.get_children(gn_id)
|
||||
|
||||
items = result.get("entries", []) if isinstance(result, dict) else result
|
||||
if not items:
|
||||
logger.warning(
|
||||
f"{self._provider}: GN id {gn_id} resolution returned no items"
|
||||
)
|
||||
return None
|
||||
|
||||
item = items[0]
|
||||
manifest_script: Optional[str] = getattr(item, "manifest_script", None)
|
||||
|
||||
if manifest_script and "vodproductinformation" in manifest_script:
|
||||
logger.warning(
|
||||
f"{self._provider}: {gn_id}: not entitled (subscriptionMissing)"
|
||||
)
|
||||
return None
|
||||
|
||||
if manifest_script and "/media/" in manifest_script:
|
||||
logger.debug(f"{self._provider}: Resolved {gn_id} -> {manifest_script}")
|
||||
return manifest_script
|
||||
|
||||
# Fallback: content_id is the guid — build URL from selector
|
||||
resolved_id: Optional[str] = getattr(item, "content_id", None)
|
||||
if resolved_id and resolved_id not in (
|
||||
gn_id, f"movie:{gn_id}", f"episode:{gn_id}"
|
||||
):
|
||||
selector = self._endpoint_manager.get_endpoint("mpx_selector") or ""
|
||||
account_pid = (
|
||||
self._provider_config.manifest.mpx.account_pid
|
||||
if self._provider_config and self._provider_config.manifest
|
||||
else ""
|
||||
)
|
||||
fallback_url = f"{selector}{account_pid}/media/{resolved_id}"
|
||||
logger.debug(
|
||||
f"{self._provider}: Resolved {gn_id} -> {fallback_url} "
|
||||
"(via content_id fallback)"
|
||||
)
|
||||
return fallback_url
|
||||
|
||||
logger.warning(
|
||||
f"{self._provider}: GN id {gn_id} resolution returned no usable URL"
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
f"{self._provider}: GN id {gn_id} resolution failed: {exc}"
|
||||
)
|
||||
return None
|
||||
|
||||
# =========================================================================
|
||||
# Private – HTTP
|
||||
# =========================================================================
|
||||
|
||||
def _fetch_smil(
|
||||
self,
|
||||
channel_id: str,
|
||||
smil_base_url: Optional[str] = None,
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Fetch raw SMIL XML for any content type.
|
||||
|
||||
Args:
|
||||
channel_id: MPX GUID — used for logging and live channel URL
|
||||
construction.
|
||||
smil_base_url: Pre-resolved theplatform URL for VOD / recordings.
|
||||
When present, only the query params are appended.
|
||||
When None the URL is built from selector + account_pid
|
||||
+ channel_id (live channel path).
|
||||
"""
|
||||
logger.debug(f"{self._provider}: Fetching SMIL for {channel_id}")
|
||||
|
||||
persona_token = self._auth()
|
||||
if not persona_token:
|
||||
logger.error(f"{self._provider}: No persona token for SMIL request")
|
||||
return None
|
||||
|
||||
cid = f"{self._session_id}::{self._call_id()}"
|
||||
smil_params = f"?format=SMIL&formats=MPEG-DASH&tracking=true&cid={cid}"
|
||||
|
||||
if smil_base_url:
|
||||
smil_url = f"{smil_base_url.split('?')[0]}{smil_params}"
|
||||
else:
|
||||
selector = self._endpoint_manager.get_endpoint("mpx_selector")
|
||||
if not selector:
|
||||
logger.error(f"{self._provider}: No mpx_selector endpoint")
|
||||
return None
|
||||
account_pid = (
|
||||
self._provider_config.manifest.mpx.account_pid
|
||||
if self._provider_config and self._provider_config.manifest
|
||||
else None
|
||||
)
|
||||
if not account_pid:
|
||||
logger.error(
|
||||
f"{self._provider}: No account_pid for SMIL URL construction"
|
||||
)
|
||||
return None
|
||||
smil_url = f"{selector}{account_pid}/media/{channel_id}{smil_params}"
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Basic {persona_token}",
|
||||
"User-Agent": self._platform_config["user_agent"],
|
||||
"Accept": "application/smil+xml, application/xml;q=0.9, */*;q=0.8",
|
||||
}
|
||||
|
||||
logger.debug(f"{self._provider}: SMIL URL: {smil_url}")
|
||||
try:
|
||||
decoded = base64.b64decode(persona_token).decode("utf-8")
|
||||
logger.debug(f"{self._provider}: Persona token preview: {decoded[:100]}...")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
response = self._http.get(
|
||||
smil_url,
|
||||
operation="manifest_smil_drm",
|
||||
headers=headers,
|
||||
timeout=DEFAULT_REQUEST_TIMEOUT,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"{self._provider}: SMIL request exception for {channel_id}: {e}"
|
||||
)
|
||||
return None
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.error(
|
||||
f"{self._provider}: SMIL request failed "
|
||||
f"[{response.status_code}] for {channel_id}"
|
||||
)
|
||||
return None
|
||||
|
||||
smil_content = response.text
|
||||
if not smil_content:
|
||||
logger.error(f"{self._provider}: Empty SMIL response for {channel_id}")
|
||||
return None
|
||||
|
||||
# Release concurrency lock immediately after receiving SMIL
|
||||
from .concurrency import extract_and_release_lock
|
||||
extract_and_release_lock(
|
||||
smil_content,
|
||||
self._http,
|
||||
client_id=SMIL_CLIENT_ID,
|
||||
user_agent=self._platform_config["user_agent"],
|
||||
)
|
||||
|
||||
return smil_content
|
||||
|
||||
# =========================================================================
|
||||
# Private – SMIL parsing
|
||||
# =========================================================================
|
||||
|
||||
@staticmethod
|
||||
def _parse_mpd_url(smil_content: str, content_id: str) -> Optional[str]:
|
||||
"""
|
||||
Extract the MPD URL from a SMIL response.
|
||||
|
||||
Checks for real error signals first (unavailable content, invalid token,
|
||||
403). A <ref> tag with title/abstract that does not match any error
|
||||
pattern is informational metadata (e.g. nPVR stream description) and
|
||||
is logged at DEBUG only.
|
||||
|
||||
Resolution order:
|
||||
1. <video src="..."> (primary)
|
||||
2. <ref src="..."> (fallback)
|
||||
"""
|
||||
try:
|
||||
logger.debug(f"Parsing SMIL for {content_id} ({len(smil_content)} chars)")
|
||||
|
||||
error_match = re.search(
|
||||
r'<ref[^>]*title="([^"]*)"[^>]*abstract="([^"]*)"[^>]*>',
|
||||
smil_content,
|
||||
)
|
||||
if error_match:
|
||||
title = error_match.group(1)
|
||||
abstract = error_match.group(2)
|
||||
if "errorFiles/Unavailable.flv" in smil_content:
|
||||
logger.error(
|
||||
f"SMIL unavailable content for {content_id}: {title}"
|
||||
)
|
||||
return None
|
||||
if "Invalid Token" in title or "InvalidAuthToken" in smil_content:
|
||||
logger.error(f"SMIL invalid token for {content_id}: {title}")
|
||||
return None
|
||||
if "403" in smil_content:
|
||||
logger.error(
|
||||
f"SMIL access forbidden (403) for {content_id}: {title}"
|
||||
)
|
||||
return None
|
||||
# Not a real error — informational stream/nPVR metadata
|
||||
logger.debug(
|
||||
f"SMIL stream info for {content_id}: {title} - {abstract}"
|
||||
)
|
||||
|
||||
video_match = re.search(r'<video\s+src="([^"]+)"', smil_content)
|
||||
if video_match:
|
||||
mpd_url = video_match.group(1)
|
||||
logger.debug(f"Found MPD URL in <video> tag for {content_id}")
|
||||
ref_info = re.search(
|
||||
r'<ref[^>]*src="([^"]*)"[^>]*title="([^"]*)"[^>]*abstract="([^"]*)"',
|
||||
smil_content,
|
||||
)
|
||||
if ref_info and ref_info.group(1) == mpd_url:
|
||||
logger.debug(
|
||||
f"Stream info: {ref_info.group(2)} - {ref_info.group(3)}"
|
||||
)
|
||||
return mpd_url
|
||||
|
||||
ref_match = re.search(r'<ref\s+src="([^"]+)"', smil_content)
|
||||
if ref_match:
|
||||
mpd_url = ref_match.group(1)
|
||||
logger.debug(f"Found MPD URL in <ref> tag for {content_id}")
|
||||
full_ref = re.search(
|
||||
r'<ref[^>]*src="%s"[^>]*title="([^"]*)"[^>]*abstract="([^"]*)"'
|
||||
% re.escape(mpd_url),
|
||||
smil_content,
|
||||
)
|
||||
if full_ref:
|
||||
logger.debug(
|
||||
f"Stream info: {full_ref.group(1)} - {full_ref.group(2)}"
|
||||
)
|
||||
return mpd_url
|
||||
|
||||
logger.warning(f"No MPD URL found in SMIL for {content_id}")
|
||||
if len(smil_content) < 1000:
|
||||
logger.debug(f"Full SMIL: {smil_content}")
|
||||
else:
|
||||
logger.debug(f"SMIL preview: {smil_content[:500]}...")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error parsing SMIL for {content_id}: {e}")
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _parse_release_pid(smil_content: str) -> Optional[str]:
|
||||
"""Extract releasePid from the SMIL trackingData parameter."""
|
||||
try:
|
||||
td_match = re.search(
|
||||
r'<param name="trackingData" value="([^"]*)"', smil_content
|
||||
)
|
||||
if not td_match:
|
||||
logger.warning("No trackingData found in SMIL")
|
||||
return None
|
||||
tracking_data = td_match.group(1)
|
||||
logger.debug(f"trackingData: {tracking_data}")
|
||||
pid_match = re.search(r"pid=([^|]+)", tracking_data)
|
||||
if pid_match:
|
||||
pid = pid_match.group(1)
|
||||
logger.debug(f"releasePid: {pid}")
|
||||
return pid
|
||||
logger.warning("No pid found in trackingData")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Error extracting releasePid: {e}")
|
||||
return None
|
||||
|
||||
# =========================================================================
|
||||
# Private – token / account helpers
|
||||
# =========================================================================
|
||||
|
||||
@staticmethod
|
||||
def _extract_persona_jwt(persona_token: str) -> Optional[str]:
|
||||
"""
|
||||
Extract the raw JWT from a Base64-encoded persona token.
|
||||
|
||||
Persona token format: Base64(account_uri + ":" + jwt)
|
||||
"""
|
||||
try:
|
||||
decoded = base64.b64decode(persona_token).decode("utf-8")
|
||||
idx = decoded.rfind(":")
|
||||
if idx == -1:
|
||||
logger.error("No colon in decoded persona token")
|
||||
return None
|
||||
jwt = decoded[idx + 1:]
|
||||
if not jwt.startswith("eyJ"):
|
||||
logger.error("Extracted token is not a JWT")
|
||||
return None
|
||||
return jwt
|
||||
except Exception as e:
|
||||
logger.error(f"Error extracting persona JWT: {e}")
|
||||
return None
|
||||
|
||||
def _get_account_uri(self) -> str:
|
||||
"""Return the MPX account URI, falling back to the hardcoded constant."""
|
||||
if self._provider_config and self._provider_config.manifest:
|
||||
uri = self._provider_config.manifest.mpx.get_account_uri()
|
||||
if uri:
|
||||
return uri
|
||||
return MAGENTA2_FALLBACK_ACCOUNT_URI
|
||||
Reference in New Issue
Block a user