mirror of
https://github.com/nirvana-7777/script.service.ultimate.git
synced 2026-10-16 04:42:25 +02:00
1854 lines
73 KiB
Python
1854 lines
73 KiB
Python
# streaming_providers/providers/magenta2/provider.py
|
|
# -*- coding: utf-8 -*-
|
|
import base64
|
|
import json
|
|
import re
|
|
import time
|
|
import uuid
|
|
from datetime import datetime, timedelta
|
|
from typing import Any, ClassVar, Dict, List, Optional, Tuple
|
|
|
|
from ...base.models import DRMConfig, DRMSystem, LicenseConfig
|
|
from ...base.models.auth import AuthState
|
|
from ...base.models.proxy_models import ProxyConfig
|
|
from ...base.models.streaming_channel import StreamingChannel
|
|
from ...base.network import HTTPManagerFactory, ProxyConfigManager
|
|
from ...base.provider import StreamingProvider
|
|
from ...base.utils.logger import logger
|
|
from .auth import Magenta2Authenticator, Magenta2Credentials, Magenta2UserCredentials
|
|
from .config_models import ProviderConfig
|
|
from .constants import (
|
|
CONTENT_TYPE_LIVE,
|
|
CONTENT_TYPE_VOD,
|
|
DEFAULT_COUNTRY,
|
|
DEFAULT_EPG_WINDOW_HOURS,
|
|
DEFAULT_MAX_RETRIES,
|
|
DEFAULT_PLATFORM,
|
|
DEFAULT_REQUEST_TIMEOUT,
|
|
DRM_REQUEST_HEADERS,
|
|
DRM_SYSTEM_WIDEVINE,
|
|
ERROR_CODES,
|
|
MAGENTA2_CLIENT_IDS,
|
|
MAGENTA2_LOGO,
|
|
MAGENTA2_PLATFORMS,
|
|
MODE_LIVE,
|
|
MODE_VOD,
|
|
SUPPORTED_COUNTRIES,
|
|
)
|
|
from .discovery import DiscoveryService
|
|
from .endpoint_manager import EndpointManager
|
|
from .models import Magenta2Channel, Magenta2PlaybackRestrictedException
|
|
from .token_flow_manager import PersonaResult
|
|
|
|
|
|
class Magenta2Provider(StreamingProvider):
|
|
PROVIDER_LABEL: ClassVar[str] = "Magenta TV 2.0"
|
|
PROVIDER_LOGO: ClassVar[str] = MAGENTA2_LOGO
|
|
"""
|
|
Magenta2 streaming provider implementation with enhanced dynamic discovery
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
country: str = DEFAULT_COUNTRY,
|
|
platform: str = DEFAULT_PLATFORM,
|
|
config_dir: Optional[str] = None,
|
|
proxy_config: Optional[ProxyConfig] = None,
|
|
proxy_url: Optional[str] = None,
|
|
username: Optional[str] = None,
|
|
password: Optional[str] = None,
|
|
):
|
|
"""
|
|
Initialize Magenta2 provider with enhanced discovery
|
|
"""
|
|
super().__init__(country=country)
|
|
|
|
if country not in SUPPORTED_COUNTRIES:
|
|
raise ValueError(
|
|
f"Unsupported country: {country}. Must be one of: {SUPPORTED_COUNTRIES}"
|
|
)
|
|
|
|
self.platform = platform
|
|
self.platform_config = MAGENTA2_PLATFORMS.get(
|
|
platform, MAGENTA2_PLATFORMS[DEFAULT_PLATFORM]
|
|
)
|
|
self.terminal_type = self.platform_config["terminal_type"]
|
|
|
|
# Generate session ID and device ID
|
|
self.session_id = self._generate_uuid()
|
|
self.device_id = self._generate_device_id()
|
|
|
|
# Setup proxy configuration
|
|
self.proxy_config = (
|
|
proxy_config
|
|
or (ProxyConfig.from_url(proxy_url) if proxy_url else None)
|
|
or self._load_proxy_from_manager(config_dir)
|
|
)
|
|
|
|
if self.proxy_config:
|
|
logger.info("Using proxy configuration for Magenta2")
|
|
else:
|
|
logger.debug("No proxy configuration found for Magenta2")
|
|
|
|
# Create HTTP manager
|
|
self.http_manager = HTTPManagerFactory.create_for_provider(
|
|
provider_name="magenta2",
|
|
proxy_config=self.proxy_config,
|
|
user_agent=self.platform_config["user_agent"],
|
|
timeout=DEFAULT_REQUEST_TIMEOUT,
|
|
max_retries=DEFAULT_MAX_RETRIES,
|
|
)
|
|
|
|
# Initialize discovery service
|
|
self.discovery_service = DiscoveryService(
|
|
platform=platform,
|
|
terminal_type=self.terminal_type,
|
|
device_id=self.device_id,
|
|
session_id=self.session_id,
|
|
http_manager=self.http_manager,
|
|
proxy_config=self.proxy_config,
|
|
)
|
|
|
|
# Initialize endpoint manager (will be populated after discovery)
|
|
self.endpoint_manager: Optional[EndpointManager] = None
|
|
self.provider_config: Optional[ProviderConfig] = None
|
|
|
|
# 🚨 INITIALIZE AUTHENTICATOR FIRST (with minimal config)
|
|
# Use fallback client IDs initially
|
|
fallback_client_id = MAGENTA2_CLIENT_IDS.get(
|
|
platform, MAGENTA2_CLIENT_IDS[DEFAULT_PLATFORM]
|
|
)
|
|
|
|
if username and password:
|
|
# Use user credentials for complete authentication flow
|
|
credentials = Magenta2UserCredentials(
|
|
client_id=fallback_client_id, # Use fallback initially
|
|
platform=platform,
|
|
country=country,
|
|
device_id=self.device_id,
|
|
username=username,
|
|
password=password,
|
|
)
|
|
logger.info("Using user credentials for authentication")
|
|
else:
|
|
# Use client credentials for TAA-only flow
|
|
credentials = Magenta2Credentials(
|
|
client_id=fallback_client_id, # Use fallback initially
|
|
platform=platform,
|
|
country=country,
|
|
device_id=self.device_id,
|
|
)
|
|
logger.info("Using client credentials for authentication")
|
|
|
|
# Create authenticator with minimal configuration
|
|
self.authenticator = Magenta2Authenticator(
|
|
country=country,
|
|
platform=platform,
|
|
config_dir=config_dir,
|
|
http_manager=self.http_manager,
|
|
credentials=credentials,
|
|
endpoints={}, # Empty initially, will be updated after discovery
|
|
client_model=f"ftv-{platform}", # Use fallback initially
|
|
device_model=f"{platform.upper()}_FTV", # Use fallback initially
|
|
sam3_client_id=fallback_client_id, # Use fallback initially
|
|
session_id=self.session_id,
|
|
device_id=self.device_id,
|
|
provider_config=None,
|
|
)
|
|
|
|
# 🚨 NOW PERFORM CONFIGURATION DISCOVERY (authenticator exists)
|
|
try:
|
|
self._perform_configuration_discovery()
|
|
except Exception as e:
|
|
logger.error(f"Configuration discovery failed: {e}")
|
|
raise
|
|
|
|
# 🚨 UPDATE AUTHENTICATOR WITH DISCOVERED CONFIG
|
|
if self.provider_config:
|
|
# Update authenticator with discovered client_id and models
|
|
self.authenticator.provider_config = self.provider_config # ✅ Store the config
|
|
logger.info("✓ ProviderConfig stored in authenticator")
|
|
|
|
# Also update TokenFlowManager if it exists
|
|
if (
|
|
hasattr(self.authenticator, "token_flow_manager")
|
|
and self.authenticator.token_flow_manager
|
|
):
|
|
self.authenticator.token_flow_manager.provider_config = self.provider_config
|
|
logger.info("✓ ProviderConfig also stored in TokenFlowManager")
|
|
|
|
if self.provider_config.bootstrap.sam3_client_id:
|
|
# Use public method if available, otherwise update directly
|
|
if hasattr(self.authenticator, "update_sam3_client_id"):
|
|
self.authenticator.update_sam3_client_id(
|
|
self.provider_config.bootstrap.sam3_client_id
|
|
)
|
|
else:
|
|
self.authenticator._sam3_client_id = (
|
|
self.provider_config.bootstrap.sam3_client_id
|
|
)
|
|
|
|
if self.authenticator.credentials:
|
|
self.authenticator.credentials.client_id = (
|
|
self.provider_config.bootstrap.sam3_client_id
|
|
)
|
|
logger.debug(
|
|
f"Updated authenticator with SAM3 client ID: {self.provider_config.bootstrap.sam3_client_id}"
|
|
)
|
|
|
|
if self.provider_config.bootstrap.client_model:
|
|
if hasattr(self.authenticator, "update_client_model"):
|
|
self.authenticator.update_client_model(
|
|
self.provider_config.bootstrap.client_model
|
|
)
|
|
else:
|
|
self.authenticator._client_model = self.provider_config.bootstrap.client_model
|
|
logger.debug(
|
|
f"Updated authenticator with client model: {self.provider_config.bootstrap.client_model}"
|
|
)
|
|
|
|
if self.provider_config.bootstrap.device_model:
|
|
if hasattr(self.authenticator, "update_device_model"):
|
|
self.authenticator.update_device_model(
|
|
self.provider_config.bootstrap.device_model
|
|
)
|
|
else:
|
|
self.authenticator._device_model = self.provider_config.bootstrap.device_model
|
|
logger.debug(
|
|
f"Updated authenticator with device model: {self.provider_config.bootstrap.device_model}"
|
|
)
|
|
|
|
# Update authenticator with device token and MPX account if available
|
|
if self.provider_config.manifest:
|
|
device_token = self.provider_config.get_device_token()
|
|
authorize_tokens_url = self.provider_config.get_authorize_tokens_url()
|
|
|
|
if device_token:
|
|
self.authenticator.set_device_token(device_token, authorize_tokens_url)
|
|
logger.debug("Device token configured in authenticator")
|
|
|
|
# CRITICAL: Pass MPX account PID for account URI construction
|
|
if self.provider_config.manifest.mpx.account_pid:
|
|
self.authenticator.set_mpx_account_pid(
|
|
self.provider_config.manifest.mpx.account_pid
|
|
)
|
|
logger.debug(
|
|
f"MPX account PID configured: {self.provider_config.manifest.mpx.account_pid}"
|
|
)
|
|
|
|
# Pass OpenID configuration if available
|
|
if self.provider_config.openid:
|
|
self.authenticator.set_openid_config(self.provider_config.openid.raw_data)
|
|
|
|
# Update authenticator with discovered endpoints using public methods
|
|
if self.endpoint_manager:
|
|
# Get all endpoints
|
|
all_endpoints = {
|
|
name: info.url
|
|
for name, info in self.endpoint_manager.get_all_endpoints().items()
|
|
}
|
|
|
|
# Update authenticator with discovered endpoints using public method
|
|
if hasattr(self.authenticator, "update_dynamic_endpoints"):
|
|
self.authenticator.update_dynamic_endpoints(all_endpoints)
|
|
logger.info(f"✓ Updated authenticator with {len(all_endpoints)} endpoints")
|
|
elif hasattr(self.authenticator, "update_endpoints"):
|
|
self.authenticator.update_endpoints(all_endpoints)
|
|
logger.info(f"✓ Updated authenticator with {len(all_endpoints)} endpoints")
|
|
else:
|
|
logger.warning("No public method available to update endpoints")
|
|
|
|
# Specifically update SAM3 client with QR code URL
|
|
qr_url = self.endpoint_manager.get_endpoint("login_qr_code")
|
|
if qr_url and hasattr(self.authenticator, "update_sam3_qr_code_url"):
|
|
success = self.authenticator.update_sam3_qr_code_url(qr_url)
|
|
if success:
|
|
logger.info("✓ Successfully updated SAM3 client with QR code URL")
|
|
else:
|
|
logger.warning("✗ Failed to update SAM3 client with QR code URL")
|
|
|
|
# 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 = 3600
|
|
|
|
logger.info("Magenta2 provider initialization completed successfully")
|
|
|
|
logger.info("Magenta2 provider initialization completed successfully")
|
|
|
|
@staticmethod
|
|
def _generate_uuid() -> str:
|
|
"""Generate UUID for session"""
|
|
return str(uuid.uuid4())
|
|
|
|
@staticmethod
|
|
def _generate_device_id() -> str:
|
|
"""Generate device ID"""
|
|
return str(uuid.uuid4())
|
|
|
|
@staticmethod
|
|
def _generate_call_id() -> str:
|
|
"""Generate call ID for requests"""
|
|
return str(uuid.uuid4())
|
|
|
|
def _load_proxy_from_manager(self, config_dir: Optional[str]) -> Optional[ProxyConfig]:
|
|
"""Load proxy configuration from ProxyConfigManager"""
|
|
try:
|
|
proxy_manager = ProxyConfigManager(config_dir)
|
|
return proxy_manager.get_proxy_config("magenta2", self.country)
|
|
except Exception as e:
|
|
logger.warning(f"Could not load proxy from ProxyConfigManager: {e}")
|
|
return None
|
|
|
|
def _perform_configuration_discovery(self) -> None:
|
|
"""
|
|
Perform complete configuration discovery using discovery service
|
|
"""
|
|
logger.info("Performing Magenta2 configuration discovery")
|
|
|
|
try:
|
|
# Perform discovery
|
|
self.provider_config = self.discovery_service.discover_provider_config()
|
|
|
|
if not self.provider_config or not self.provider_config.is_complete:
|
|
logger.warning("Configuration discovery incomplete, some features may not work")
|
|
|
|
# Initialize endpoint manager with discovered configuration
|
|
self.endpoint_manager = EndpointManager(self.provider_config)
|
|
|
|
# DEBUG: Check if QR code endpoint is discovered
|
|
if self.endpoint_manager:
|
|
qr_url = self.endpoint_manager.get_endpoint("login_qr_code")
|
|
if qr_url:
|
|
logger.info(f"✓ QR code endpoint discovered in endpoint manager: {qr_url}")
|
|
|
|
# PROPER FIX: Use public method to update SAM3 client
|
|
if hasattr(self.authenticator, "update_sam3_qr_code_url"):
|
|
success = self.authenticator.update_sam3_qr_code_url(qr_url)
|
|
if success:
|
|
logger.info("✓ Successfully updated SAM3 client with QR code URL")
|
|
else:
|
|
logger.warning("✗ Failed to update SAM3 client with QR code URL")
|
|
|
|
# Also debug the current status
|
|
if hasattr(self.authenticator, "get_sam3_client_status"):
|
|
status = self.authenticator.get_sam3_client_status()
|
|
logger.debug(f"SAM3 client status after update: {status}")
|
|
else:
|
|
logger.warning("✗ QR code endpoint NOT found in endpoint manager")
|
|
|
|
if self.provider_config and self.provider_config.manifest:
|
|
device_token = self.provider_config.get_device_token()
|
|
authorize_tokens_url = self.provider_config.get_authorize_tokens_url()
|
|
|
|
if device_token:
|
|
logger.info(f"✓ Device token discovered (length: {len(device_token)})")
|
|
else:
|
|
logger.warning("⚠️ No device token found in manifest")
|
|
|
|
if authorize_tokens_url:
|
|
logger.info(f"✓ Line auth endpoint discovered: {authorize_tokens_url}")
|
|
else:
|
|
logger.warning("⚠️ No authorize tokens URL found in manifest")
|
|
|
|
if self.provider_config.manifest.mpx.account_pid:
|
|
logger.info(
|
|
f"✓ MPX account PID discovered: {self.provider_config.manifest.mpx.account_pid}"
|
|
)
|
|
|
|
# Validate critical endpoints
|
|
missing_endpoints = self.endpoint_manager.validate_critical_endpoints()
|
|
if missing_endpoints:
|
|
logger.warning(f"Missing critical endpoints: {missing_endpoints}")
|
|
else:
|
|
logger.info("All critical endpoints available")
|
|
|
|
# Log discovery statistics
|
|
stats = self.endpoint_manager.get_stats()
|
|
logger.info(
|
|
f"Discovery complete: {stats['dynamic_endpoints']} dynamic endpoints, "
|
|
f"{stats['fallback_endpoints']} fallback endpoints, "
|
|
f"complete: {stats['is_complete']}"
|
|
)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Configuration discovery failed: {e}")
|
|
self._create_fallback_configuration()
|
|
raise
|
|
|
|
def _create_fallback_configuration(self) -> None:
|
|
"""Create fallback configuration when discovery fails"""
|
|
logger.warning("Creating fallback configuration")
|
|
|
|
from .config_models import BootstrapConfig, ProviderConfig
|
|
|
|
bootstrap_config = BootstrapConfig(
|
|
client_model=f"ftv-{self.platform}",
|
|
device_model=f"{self.platform.upper()}_FTV",
|
|
)
|
|
|
|
self.provider_config = ProviderConfig(bootstrap=bootstrap_config)
|
|
self.endpoint_manager = EndpointManager(self.provider_config)
|
|
|
|
logger.info("Fallback configuration created")
|
|
|
|
def _get_dcm_headers(self) -> Dict[str, str]:
|
|
"""Get headers for DCM requests"""
|
|
return {
|
|
"User-Agent": self.platform_config["user_agent"],
|
|
"Content-Type": "application/json",
|
|
"Accept": "application/json",
|
|
"x-dt-session-id": self.session_id,
|
|
"x-dt-call-id": self._generate_call_id(),
|
|
}
|
|
|
|
def _get_api_headers(self, require_auth: bool = False) -> Dict[str, str]:
|
|
"""Get headers for API requests"""
|
|
headers = {
|
|
"User-Agent": self.platform_config["user_agent"],
|
|
"Accept": "application/json",
|
|
"Content-Type": "application/json",
|
|
}
|
|
|
|
if require_auth:
|
|
persona_token = self._ensure_authenticated()
|
|
headers["Authorization"] = f"Basic {persona_token}"
|
|
|
|
return headers
|
|
|
|
@property
|
|
def provider_name(self) -> str:
|
|
return "magenta2"
|
|
|
|
@property
|
|
def provider_label(self) -> str:
|
|
return "Magenta TV 2.0"
|
|
|
|
@property
|
|
def provider_logo(self) -> str:
|
|
return self.PROVIDER_LOGO
|
|
|
|
@property
|
|
def uses_dynamic_manifests(self) -> bool:
|
|
return False
|
|
|
|
@property
|
|
def implements_epg(self) -> bool:
|
|
return False
|
|
|
|
@property
|
|
def catchup_window(self) -> int:
|
|
# This provider offers 4 hours of catchup
|
|
return 4
|
|
|
|
@property
|
|
def supported_auth_types(self) -> List[str]:
|
|
return ["network_based"] # ← Change 1
|
|
|
|
@property
|
|
def primary_token_scope(self) -> Optional[str]:
|
|
return "persona" # ← Keep this
|
|
|
|
@property
|
|
def token_scopes(self) -> List[str]:
|
|
return ["yo_digital", "tvhubs", "taa", "persona"]
|
|
|
|
def get_discovery_status(self) -> Dict[str, Any]:
|
|
"""Get discovery and configuration status"""
|
|
if not self.discovery_service:
|
|
return {"error": "Discovery service not initialized"}
|
|
|
|
status = self.discovery_service.get_discovery_status()
|
|
|
|
if self.endpoint_manager:
|
|
status["endpoints"] = self.endpoint_manager.get_stats()
|
|
|
|
return status
|
|
|
|
def refresh_configuration(self, force: bool = False) -> bool:
|
|
"""
|
|
Refresh provider configuration
|
|
|
|
Args:
|
|
force: Force refresh even if cache is valid
|
|
|
|
Returns:
|
|
bool: True if refresh successful
|
|
"""
|
|
try:
|
|
logger.info("Refreshing provider configuration")
|
|
|
|
new_config = self.discovery_service.discover_provider_config(force_refresh=force)
|
|
|
|
if new_config and new_config.is_complete:
|
|
self.provider_config = new_config
|
|
self.endpoint_manager = EndpointManager(new_config)
|
|
|
|
# Update authenticator with new config
|
|
if new_config.manifest:
|
|
device_token = new_config.manifest.raw_data.get("deviceToken")
|
|
authorize_tokens_url = new_config.manifest.raw_data.get("authorizeTokensUrl")
|
|
if device_token:
|
|
self.authenticator.set_device_token(device_token, authorize_tokens_url)
|
|
|
|
if new_config.manifest.mpx.account_pid:
|
|
self.authenticator.set_mpx_account_pid(new_config.manifest.mpx.account_pid)
|
|
|
|
logger.info("Configuration refresh successful")
|
|
return True
|
|
else:
|
|
logger.warning("Configuration refresh incomplete")
|
|
return False
|
|
|
|
except Exception as e:
|
|
logger.error(f"Configuration refresh failed: {e}")
|
|
return False
|
|
|
|
def register_device(self) -> bool:
|
|
"""
|
|
Perform device registration and authentication
|
|
Useful for initial setup or device token refresh
|
|
"""
|
|
try:
|
|
logger.info("Performing device registration")
|
|
|
|
if not self.authenticator:
|
|
logger.error("Authenticator not available for device registration")
|
|
return False
|
|
|
|
# PROPER: Use public method instead of checking protected attribute
|
|
if hasattr(self.authenticator, "perform_device_authentication"):
|
|
success = self.authenticator.perform_device_authentication()
|
|
if success:
|
|
logger.info("✓ Device registration successful")
|
|
return True
|
|
else:
|
|
logger.warning("Device registration failed")
|
|
return False
|
|
else:
|
|
logger.warning("Device authentication not supported in current authenticator")
|
|
return False
|
|
|
|
except Exception as e:
|
|
logger.error(f"Device registration failed: {e}")
|
|
return False
|
|
|
|
def get_persona_token(self, force_refresh: bool = False) -> str:
|
|
"""Get persona token with accurate expiry-based caching"""
|
|
# Check in-memory cache first
|
|
if not force_refresh and self._persona_cache and self._persona_cache.success:
|
|
current_time = time.time()
|
|
# Check if cached token is still valid (with 1-minute buffer)
|
|
if current_time < (self._persona_cache.expires_at - 60):
|
|
logger.debug(
|
|
f"Using in-memory cached persona token (expires at {time.ctime(self._persona_cache.expires_at)})"
|
|
)
|
|
return self._persona_cache.persona_token
|
|
else:
|
|
# Cache expired
|
|
self._persona_cache = None
|
|
logger.debug("In-memory persona cache expired")
|
|
|
|
# Get from TokenFlowManager (now returns PersonaResult with expiry)
|
|
persona_result = self.authenticator.token_flow_manager.get_persona_token(
|
|
force_refresh=force_refresh
|
|
)
|
|
|
|
if not persona_result.success:
|
|
raise Exception(f"Failed to get persona token: {persona_result.error}")
|
|
|
|
# Cache the entire PersonaResult with expiry
|
|
self._persona_cache = persona_result
|
|
logger.debug(
|
|
f"Cached persona token in memory (expires at {time.ctime(persona_result.expires_at)})"
|
|
)
|
|
|
|
return persona_result.persona_token
|
|
|
|
def _ensure_authenticated(self) -> str:
|
|
"""Ensure we have a valid persona token with accurate caching"""
|
|
return self.get_persona_token(force_refresh=False)
|
|
|
|
def clear_persona_cache(self):
|
|
"""Clear in-memory persona cache"""
|
|
self._persona_cache = None
|
|
logger.debug("Cleared in-memory persona cache")
|
|
|
|
def get_dynamic_manifest_params(self, channel: StreamingChannel, **kwargs) -> Optional[str]:
|
|
return None
|
|
|
|
@staticmethod
|
|
def _extract_channel_id_from_entry(entry: Dict) -> Optional[str]:
|
|
"""Extract the correct channel ID from era$mediaPids"""
|
|
try:
|
|
stations = entry.get("stations", {})
|
|
if not stations:
|
|
return None
|
|
|
|
# Get the first station
|
|
station_id = next(iter(stations.keys()))
|
|
station_info = stations[station_id]
|
|
|
|
# Extract from era$mediaPids (this is the correct ID for API calls)
|
|
era_media_pids = station_info.get("era$mediaPids", {})
|
|
channel_id = era_media_pids.get("urn:theplatform:tv:location:any")
|
|
|
|
if channel_id:
|
|
logger.debug(f"Extracted channel ID from era$mediaPids: {channel_id}")
|
|
return channel_id
|
|
|
|
# Fallback to guid if era$mediaPids not available
|
|
fallback_id = entry.get("guid")
|
|
if fallback_id:
|
|
logger.warning(f"Using fallback channel ID from guid: {fallback_id}")
|
|
return fallback_id
|
|
|
|
logger.warning("No channel ID found in entry")
|
|
return None
|
|
|
|
except Exception as e:
|
|
logger.warning(f"Error extracting channel ID from entry: {e}")
|
|
return None
|
|
|
|
def _build_scaled_image_url(self, original_url: str) -> Optional[str]:
|
|
"""Build scaled image URL using image scaling service"""
|
|
if not original_url:
|
|
return None
|
|
|
|
if not self.provider_config or not self.provider_config.manifest:
|
|
return original_url # Fallback to original URL
|
|
|
|
image_config = self.provider_config.manifest.image_config
|
|
|
|
# Check if we have the required scaling parameters
|
|
if not image_config.scaling_base_url or not image_config.scaling_call_parameter:
|
|
return original_url
|
|
|
|
# Parse the call parameter (e.g., "client=ftp22")
|
|
call_params = {}
|
|
for param in image_config.scaling_call_parameter.split("&"):
|
|
if "=" in param:
|
|
key, value = param.split("=", 1)
|
|
call_params[key] = value
|
|
|
|
# Build the scaling URL
|
|
base_url = image_config.scaling_base_url.rstrip("/")
|
|
|
|
# Add required parameters
|
|
params = {
|
|
**call_params, # client=ftp22
|
|
"x": "120",
|
|
"y": "42",
|
|
"ar": "keep", # aspect ratio
|
|
"src": original_url, # original image URL
|
|
}
|
|
|
|
# Build query string
|
|
query_string = "&".join([f"{k}={self._url_encode(v)}" for k, v in params.items()])
|
|
|
|
return f"{base_url}/iss?{query_string}"
|
|
|
|
def _process_channel_stations_response_optimized(
|
|
self, response_data: Dict, prefer_highest_quality: bool = True
|
|
) -> List[StreamingChannel]:
|
|
"""Ultra-optimized single-pass processing"""
|
|
|
|
if "entries" not in response_data:
|
|
return []
|
|
|
|
quality_rank = {"SD": 1, "HD": 2, "UHD": 3, "4K": 3}
|
|
best_entries = {}
|
|
channels = []
|
|
|
|
for entry in response_data["entries"]:
|
|
try:
|
|
stations = entry.get("stations", {})
|
|
if not stations:
|
|
continue
|
|
|
|
station_info = next(iter(stations.values()))
|
|
display_number = entry.get("dt$displayChannelNumber")
|
|
|
|
if display_number is None:
|
|
# Process immediately if no number (no filtering needed)
|
|
channel = self._create_channel_from_entry(entry, station_info, display_number)
|
|
if channel:
|
|
channels.append(channel)
|
|
continue
|
|
|
|
# Extract quality for comparison
|
|
quality = station_info.get("dt$quality", "SD")
|
|
current_rank = quality_rank.get(quality, 1)
|
|
|
|
# Check if we need to replace existing entry
|
|
existing = best_entries.get(display_number)
|
|
if not existing:
|
|
best_entries[display_number] = (entry, station_info, current_rank)
|
|
else:
|
|
_, _, existing_rank = existing
|
|
if (prefer_highest_quality and current_rank > existing_rank) or (
|
|
not prefer_highest_quality and current_rank < existing_rank
|
|
):
|
|
best_entries[display_number] = (
|
|
entry,
|
|
station_info,
|
|
current_rank,
|
|
)
|
|
|
|
except Exception:
|
|
continue
|
|
|
|
# Convert best entries to channels
|
|
for display_number, (entry, station_info, _) in best_entries.items():
|
|
channel = self._create_channel_from_entry(entry, station_info, display_number)
|
|
if channel:
|
|
channels.append(channel)
|
|
|
|
return channels
|
|
|
|
def _create_channel_from_entry(self, entry, station_info, display_number):
|
|
"""Helper to create StreamingChannel from entry data"""
|
|
try:
|
|
title = station_info.get("title") or entry.get("title", "Unknown Channel")
|
|
title = title.replace(" - Main", "")
|
|
|
|
channel_id = self._extract_channel_id_from_entry(entry)
|
|
if not channel_id:
|
|
return None
|
|
|
|
# Logo processing
|
|
logo_url = None
|
|
thumbnails = station_info.get("thumbnails", {})
|
|
for logo_type in ["stationLogo", "stationLogoColored"]:
|
|
if logo_type in thumbnails:
|
|
original_url = thumbnails[logo_type].get("url")
|
|
if original_url:
|
|
logo_url = self._build_scaled_image_url(original_url)
|
|
break
|
|
|
|
magenta2_channel = Magenta2Channel(
|
|
name=title,
|
|
channel_id=channel_id,
|
|
logo_url=logo_url,
|
|
mode=MODE_LIVE,
|
|
content_type=CONTENT_TYPE_LIVE,
|
|
country=self.country,
|
|
raw_data=entry,
|
|
)
|
|
|
|
streaming_channel = magenta2_channel.to_streaming_channel(
|
|
provider_name=self.provider_name
|
|
)
|
|
streaming_channel.channel_number = display_number
|
|
streaming_channel.quality = station_info.get("dt$quality", "SD")
|
|
|
|
return streaming_channel
|
|
|
|
except Exception as e:
|
|
logger.warning(f"Error creating channel from entry: {e}")
|
|
return None
|
|
|
|
def get_channels(
|
|
self,
|
|
time_window_hours: int = DEFAULT_EPG_WINDOW_HOURS,
|
|
fetch_manifests: bool = False,
|
|
populate_streaming_data: bool = True,
|
|
prefer_highest_quality: bool = True,
|
|
**kwargs,
|
|
) -> List[StreamingChannel]:
|
|
"""
|
|
Fetch available channels from Magenta2 API
|
|
"""
|
|
try:
|
|
headers = self._get_api_headers(require_auth=False)
|
|
|
|
# Use the discovered channel stations endpoint
|
|
url = None
|
|
if self.endpoint_manager:
|
|
url = self.endpoint_manager.get_endpoint("channel_stations")
|
|
if not url:
|
|
url = self.endpoint_manager.get_endpoint("channel_list")
|
|
if not url and self.endpoint_manager.has_endpoint("mpx_feed_entitledChannelsFeed"):
|
|
url = self.endpoint_manager.get_endpoint("mpx_feed_entitledChannelsFeed")
|
|
|
|
# Final fallback
|
|
if not url:
|
|
url = "https://feed.entertainment.tv.theplatform.eu/f/mdeprod/mdeprod-channel-stations-main"
|
|
|
|
url += "?lang=short-de&sort=dt%24displayChannelNumber&range=1-1000"
|
|
|
|
logger.debug(f"Fetching channels from: {url}")
|
|
response = self.http_manager.get(
|
|
url, operation="api", headers=headers, timeout=DEFAULT_REQUEST_TIMEOUT
|
|
)
|
|
response.raise_for_status()
|
|
channels_data = response.json()
|
|
|
|
# ✅ USE OPTIMIZED SINGLE-PASS PROCESSING
|
|
channels = self._process_channel_stations_response_optimized(
|
|
channels_data, prefer_highest_quality
|
|
)
|
|
|
|
logger.info(
|
|
f"Successfully fetched {len(channels)} channels for country {self.country} "
|
|
f"(quality preference: {'highest' if prefer_highest_quality else 'lowest'})"
|
|
)
|
|
return channels
|
|
|
|
except Exception as e:
|
|
raise Exception(f"Error fetching channels from Magenta2 API: {e}")
|
|
|
|
def _get_channels_from_mpx_feeds(self) -> List[StreamingChannel]:
|
|
"""Get channels from MPX feeds discovered in manifest"""
|
|
try:
|
|
if not self.endpoint_manager:
|
|
return []
|
|
|
|
entitled_channels_url = self.endpoint_manager.get_endpoint(
|
|
"mpx_feed_entitledChannelsFeed"
|
|
)
|
|
if not entitled_channels_url:
|
|
return []
|
|
|
|
self._ensure_authenticated()
|
|
|
|
headers = self._get_api_headers(require_auth=True)
|
|
response = self.http_manager.get(
|
|
entitled_channels_url,
|
|
operation="mpx_feed",
|
|
headers=headers,
|
|
timeout=DEFAULT_REQUEST_TIMEOUT,
|
|
)
|
|
response.raise_for_status()
|
|
|
|
feed_data = response.json()
|
|
return self._parse_mpx_feed_response(feed_data)
|
|
|
|
except Exception as e:
|
|
logger.warning(f"Failed to get channels from MPX feed: {e}")
|
|
return []
|
|
|
|
def _parse_mpx_feed_response(self, feed_data: Dict) -> List[StreamingChannel]:
|
|
"""Parse MPX feed response into channels"""
|
|
channels = []
|
|
entries = feed_data.get("entries", [])
|
|
|
|
for entry in entries:
|
|
try:
|
|
title = entry.get("title", "Unknown")
|
|
channel_id = entry.get("guid", "").split("/")[-1] if entry.get("guid") else ""
|
|
|
|
if not channel_id:
|
|
continue
|
|
|
|
channel = Magenta2Channel(
|
|
name=title,
|
|
channel_id=channel_id,
|
|
content_type=CONTENT_TYPE_LIVE,
|
|
country=self.country,
|
|
raw_data=entry,
|
|
)
|
|
|
|
streaming_channel = channel.to_streaming_channel(provider_name=self.provider_name)
|
|
channels.append(streaming_channel)
|
|
|
|
except Exception as e:
|
|
logger.warning(f"Error parsing MPX channel entry: {e}")
|
|
|
|
return channels
|
|
|
|
def _process_channels_response(self, response_data: Dict) -> List[StreamingChannel]:
|
|
"""Process API response and convert to StreamingChannel objects"""
|
|
channels = []
|
|
|
|
if isinstance(response_data, list):
|
|
channel_list = response_data
|
|
elif isinstance(response_data, dict):
|
|
if "channels" in response_data:
|
|
channel_list = response_data["channels"]
|
|
elif "data" in response_data:
|
|
channel_list = response_data["data"]
|
|
else:
|
|
channel_list = [response_data]
|
|
else:
|
|
logger.warning("Unexpected channel response format")
|
|
return channels
|
|
|
|
for channel_data in channel_list:
|
|
try:
|
|
channel_id = channel_data.get("id", channel_data.get("channelId", ""))
|
|
if not channel_id:
|
|
logger.warning("Channel missing ID, skipping")
|
|
continue
|
|
|
|
title = channel_data.get("title", channel_data.get("name", "Unknown Channel"))
|
|
stream_type = channel_data.get("type", "LIVE")
|
|
quality = channel_data.get("quality", "")
|
|
|
|
logo_url = None
|
|
if "logo" in channel_data:
|
|
if isinstance(channel_data["logo"], dict) and "url" in channel_data["logo"]:
|
|
logo_url = channel_data["logo"]["url"]
|
|
elif isinstance(channel_data["logo"], str):
|
|
logo_url = channel_data["logo"]
|
|
elif "image" in channel_data:
|
|
logo_url = channel_data["image"]
|
|
|
|
content_type = (
|
|
CONTENT_TYPE_LIVE if stream_type.upper() == "LIVE" else CONTENT_TYPE_VOD
|
|
)
|
|
mode = MODE_LIVE if stream_type.upper() == "LIVE" else MODE_VOD
|
|
|
|
magenta2_channel = Magenta2Channel(
|
|
name=title,
|
|
channel_id=channel_id,
|
|
logo_url=logo_url,
|
|
mode=mode,
|
|
content_type=content_type,
|
|
country=self.country,
|
|
raw_data=channel_data,
|
|
)
|
|
|
|
if quality:
|
|
magenta2_channel.name = f"{title} ({quality})"
|
|
|
|
streaming_channel = magenta2_channel.to_streaming_channel(
|
|
provider_name=self.provider_name
|
|
)
|
|
channels.append(streaming_channel)
|
|
|
|
except Exception as e:
|
|
logger.warning(f"Error processing channel data: {e}")
|
|
|
|
return channels
|
|
|
|
def get_entitlement_token(self, content_id: str, content_type: str = CONTENT_TYPE_LIVE) -> str:
|
|
"""
|
|
Get entitlement token using persona_token Basic auth
|
|
"""
|
|
# Ensure we're authenticated with persona token
|
|
self._ensure_authenticated()
|
|
|
|
# Use persona token (Basic auth) for entitlement
|
|
headers = self._get_api_headers(require_auth=True)
|
|
|
|
payload = {"content_id": content_id, "content_type": content_type}
|
|
|
|
url = (
|
|
self.endpoint_manager.get_endpoint("entitlement")
|
|
if self.endpoint_manager
|
|
else "https://entitlement.p7s1.io/api/user/entitlement-token"
|
|
)
|
|
|
|
try:
|
|
logger.debug(f"Requesting entitlement token with persona token for: {content_id}")
|
|
response = self.http_manager.post(
|
|
url,
|
|
operation="auth",
|
|
headers=headers,
|
|
json_data=payload,
|
|
timeout=DEFAULT_REQUEST_TIMEOUT,
|
|
)
|
|
|
|
if response.status_code == 400:
|
|
try:
|
|
error_data = response.json()
|
|
error_list = error_data if isinstance(error_data, list) else [error_data]
|
|
|
|
if len(error_list) > 0:
|
|
error = error_list[0]
|
|
code = error.get("code", error.get("errorCode", "UNKNOWN"))
|
|
msg = error.get("msg", error.get("message", "No error message provided"))
|
|
|
|
if code == ERROR_CODES["PLAYBACK_RESTRICTED"]:
|
|
raise Magenta2PlaybackRestrictedException(
|
|
f"Playback restricted for {content_id}: {msg}"
|
|
)
|
|
else:
|
|
raise Exception(f"Entitlement error for {content_id} ({code}): {msg}")
|
|
except (json.JSONDecodeError, KeyError, IndexError) as e:
|
|
raise Exception(
|
|
f"Bad response for {content_id} (400), failed to parse error: {e}"
|
|
)
|
|
|
|
response.raise_for_status()
|
|
data = response.json()
|
|
|
|
if "entitlement_token" in data:
|
|
return data["entitlement_token"]
|
|
elif "entitlementToken" in data:
|
|
return data["entitlementToken"]
|
|
elif "token" in data:
|
|
return data["token"]
|
|
else:
|
|
raise KeyError("No entitlement token found in response")
|
|
|
|
except Magenta2PlaybackRestrictedException:
|
|
raise
|
|
except KeyError as e:
|
|
logger.error(f"No entitlement token in response for {content_id}: {e}")
|
|
logger.debug(f"Auth state: {self.authenticator.debug_authentication_state()}")
|
|
raise Exception(f"No entitlement token in response for {content_id}: {e}")
|
|
except Exception as e:
|
|
logger.error(f"Error getting entitlement token for {content_id}: {e}")
|
|
logger.debug(f"Auth state: {self.authenticator.debug_authentication_state()}")
|
|
raise Exception(f"Error getting entitlement token for {content_id}: {e}")
|
|
|
|
def get_channel_playlist(self, channel_id: str, entitlement_token: str) -> Dict:
|
|
"""Get channel playlist data"""
|
|
if self.endpoint_manager and self.endpoint_manager.has_endpoint("channel_playlist"):
|
|
url = self.endpoint_manager.get_endpoint("channel_playlist").format(
|
|
channel_id=channel_id
|
|
)
|
|
else:
|
|
url = f"https://api.magentatv.de/v1/channel/{channel_id}/playlist"
|
|
|
|
headers = {
|
|
"Authorization": f"Bearer {entitlement_token}",
|
|
"User-Agent": self.platform_config["user_agent"],
|
|
"Accept": "application/json",
|
|
}
|
|
|
|
try:
|
|
response = self.http_manager.get(
|
|
url,
|
|
operation="manifest",
|
|
headers=headers,
|
|
timeout=DEFAULT_REQUEST_TIMEOUT,
|
|
)
|
|
response.raise_for_status()
|
|
return response.json()
|
|
|
|
except Exception as e:
|
|
raise Exception(f"Error getting playlist for {channel_id}: {e}")
|
|
|
|
def populate_streaming_data(
|
|
self, channels: List[StreamingChannel], max_retries: int = DEFAULT_MAX_RETRIES
|
|
) -> List[StreamingChannel]:
|
|
"""Populate streaming data (manifest, DRM) for all channels"""
|
|
self._ensure_authenticated()
|
|
|
|
successful_channels = []
|
|
|
|
for channel in channels:
|
|
retries = 0
|
|
success = False
|
|
is_restricted = False
|
|
|
|
while retries < max_retries and not success and not is_restricted:
|
|
try:
|
|
logger.debug(
|
|
f"Getting entitlement token for: {channel.name} (attempt {retries + 1})"
|
|
)
|
|
|
|
entitlement_token = self.get_entitlement_token(
|
|
content_id=channel.channel_id, content_type=channel.content_type
|
|
)
|
|
|
|
logger.debug(f"Getting playlist data for: {channel.name}")
|
|
|
|
playlist_data = self.get_channel_playlist(channel.channel_id, entitlement_token)
|
|
|
|
manifest_url = playlist_data.get("manifestUrl", playlist_data.get("manifest"))
|
|
license_url = playlist_data.get("licenseUrl", playlist_data.get("license"))
|
|
certificate_url = playlist_data.get(
|
|
"certificateUrl", playlist_data.get("certificate")
|
|
)
|
|
streaming_format = playlist_data.get(
|
|
"streamingFormat", playlist_data.get("format", "dash")
|
|
)
|
|
|
|
if manifest_url:
|
|
channel.manifest = manifest_url
|
|
channel.cdm_type = DRM_SYSTEM_WIDEVINE
|
|
channel.cdm = f"pid={channel.channel_id}"
|
|
channel.license_url = license_url
|
|
channel.certificate_url = certificate_url
|
|
channel.streaming_format = streaming_format
|
|
|
|
logger.info(f"Streaming data populated for: {channel.name}")
|
|
successful_channels.append(channel)
|
|
success = True
|
|
else:
|
|
raise Exception("No manifest URL in response")
|
|
|
|
except Magenta2PlaybackRestrictedException as e:
|
|
logger.warning(f"Playback restricted for {channel.name}: {e}")
|
|
is_restricted = True
|
|
|
|
except Exception as e:
|
|
retries += 1
|
|
if retries < max_retries:
|
|
logger.debug(f"Retry {retries}/{max_retries} for {channel.name}: {e}")
|
|
time.sleep(1)
|
|
else:
|
|
logger.error(f"Failed to get streaming data for {channel.name}: {e}")
|
|
|
|
logger.info(f"Streaming data population complete:")
|
|
logger.info(f" Successful: {len(successful_channels)}")
|
|
logger.info(f" Failed/Restricted: {len(channels) - len(successful_channels)}")
|
|
logger.info(f" Total: {len(channels)}")
|
|
|
|
return successful_channels
|
|
|
|
def enrich_channel_data(
|
|
self, channel: StreamingChannel, **kwargs
|
|
) -> Optional[StreamingChannel]:
|
|
"""Get manifest URL for a specific channel and configure DRM"""
|
|
self._ensure_authenticated()
|
|
|
|
try:
|
|
entitlement_token = self.get_entitlement_token(
|
|
content_id=channel.channel_id, content_type=channel.content_type
|
|
)
|
|
|
|
playlist_data = self.get_channel_playlist(channel.channel_id, entitlement_token)
|
|
|
|
manifest_url = playlist_data.get("manifestUrl", playlist_data.get("manifest"))
|
|
if not manifest_url:
|
|
return None
|
|
|
|
channel.manifest = manifest_url
|
|
channel.streaming_format = playlist_data.get(
|
|
"streamingFormat", playlist_data.get("format", "dash")
|
|
)
|
|
|
|
license_url = playlist_data.get("licenseUrl", playlist_data.get("license"))
|
|
if license_url:
|
|
widevine_url = (
|
|
self.endpoint_manager.get_endpoint("widevine_license")
|
|
if self.endpoint_manager
|
|
else license_url
|
|
)
|
|
|
|
drm_config = DRMConfig(
|
|
system=DRMSystem.WIDEVINE,
|
|
priority=1,
|
|
license=LicenseConfig(
|
|
server_url=widevine_url,
|
|
server_certificate=playlist_data.get(
|
|
"certificateUrl", playlist_data.get("certificate")
|
|
),
|
|
req_headers=json.dumps(
|
|
{
|
|
"User-Agent": self.platform_config["user_agent"],
|
|
"Content-Type": DRM_REQUEST_HEADERS["Content-Type"],
|
|
}
|
|
),
|
|
req_data="{CHA-RAW}",
|
|
use_http_get_request=False,
|
|
),
|
|
)
|
|
channel.drm_config = drm_config
|
|
channel.cdm_type = DRM_SYSTEM_WIDEVINE
|
|
channel.cdm = f"pid={channel.channel_id}"
|
|
|
|
return channel
|
|
|
|
except Exception as e:
|
|
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}")
|
|
|
|
# Log the full SMIL content for debugging in case of unexpected format
|
|
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, channel_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(channel_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]:
|
|
"""
|
|
Get catchup manifest URL for Magenta TV.
|
|
|
|
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 _get_smil_data(self, channel_id: str) -> Optional[Dict[str, Any]]:
|
|
"""Get complete SMIL data with caching"""
|
|
# 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
|
|
|
|
# 🚨 ADD VALIDATION: Check if we got actual SMIL content
|
|
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
|
|
|
|
# Use the same client_id as in the original SMIL request
|
|
client_id = "a8198f31-b406-4177-8dee-f6216c356c75"
|
|
smil_url = f"{selector_service}{account_pid}/media/{channel_id}?format=smil&formats=MPEG-DASH&tracking=true&clientId={client_id}"
|
|
|
|
headers = {
|
|
"Authorization": f"Basic {persona_token}",
|
|
"User-Agent": self.platform_config["user_agent"], # Platform 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":
|
|
# Don't log full auth token for security, but show it exists
|
|
logger.debug(f" {key}: Basic [REDACTED] (length: {len(persona_token)})")
|
|
else:
|
|
logger.debug(f" {key}: {value}")
|
|
|
|
# Also debug the persona token structure
|
|
try:
|
|
# Try to decode to see what's in there
|
|
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=client_id, # Use the same client_id as SMIL request
|
|
user_agent=self.platform_config["user_agent"], # Platform 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 _url_encode(value: str) -> str:
|
|
"""URL encode a string"""
|
|
from urllib.parse import quote
|
|
|
|
return quote(value, safe="")
|
|
|
|
@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]:
|
|
"""
|
|
Extract the raw persona JWT token from Base64-encoded persona token
|
|
|
|
The persona token format is: Base64(account_uri + ":" + persona_jwt)
|
|
This method decodes it and extracts just the persona_jwt part.
|
|
"""
|
|
try:
|
|
decoded = base64.b64decode(persona_token).decode("utf-8")
|
|
last_colon_index = decoded.rfind(":")
|
|
|
|
if last_colon_index == -1:
|
|
logger.error("No colon found in decoded persona token")
|
|
return None
|
|
|
|
persona_jwt = decoded[last_colon_index + 1 :]
|
|
|
|
if not persona_jwt.startswith("eyJ"):
|
|
logger.error(f"Extracted token doesn't look like a JWT")
|
|
return None
|
|
|
|
return persona_jwt
|
|
except Exception as e:
|
|
logger.error(f"Error extracting persona JWT token: {e}")
|
|
return None
|
|
|
|
def get_drm(
|
|
self, channel_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(channel_id)
|
|
if not smil_data:
|
|
logger.error(f"No SMIL data found for channel {channel_id}")
|
|
return []
|
|
|
|
if not smil_data.get("release_pid"):
|
|
logger.error(f"No releasePid found in SMIL for channel {channel_id}")
|
|
# Debug what we do have
|
|
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 = self._url_encode(account_uri)
|
|
|
|
# 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 {channel_id} (releasePid: {release_pid})"
|
|
)
|
|
return [drm_config]
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error getting DRM configs for channel {channel_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
|
|
|
|
# Fallback
|
|
return "http://access.auth.theplatform.com/data/Account/2709353023"
|
|
|
|
def get_epg(
|
|
self,
|
|
channel_id: str,
|
|
start_time: Optional[datetime] = None,
|
|
end_time: Optional[datetime] = None,
|
|
**kwargs,
|
|
) -> List[Dict]:
|
|
"""Get EPG data for a channel"""
|
|
try:
|
|
if start_time is None:
|
|
start_time = datetime.now()
|
|
if end_time is None:
|
|
end_time = datetime.now() + timedelta(hours=DEFAULT_EPG_WINDOW_HOURS)
|
|
|
|
headers = self._get_api_headers(require_auth=False)
|
|
|
|
url = (
|
|
self.endpoint_manager.get_endpoint("epg")
|
|
if self.endpoint_manager
|
|
else "https://api.magentatv.de/proxy/device/epg"
|
|
)
|
|
|
|
params = {
|
|
"channelId": channel_id,
|
|
"start": start_time.isoformat(),
|
|
"end": end_time.isoformat(),
|
|
}
|
|
|
|
response = self.http_manager.get(
|
|
url,
|
|
operation="api",
|
|
headers=headers,
|
|
params=params,
|
|
timeout=DEFAULT_REQUEST_TIMEOUT,
|
|
)
|
|
response.raise_for_status()
|
|
return response.json()
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error getting EPG for channel {channel_id}: {e}")
|
|
return []
|
|
|
|
def _calculate_auth_state(self, context) -> "AuthState":
|
|
"""
|
|
Custom auth state calculation for Magenta2.
|
|
Checks persona token with its special structure.
|
|
"""
|
|
import time
|
|
|
|
from ...base.models.auth import AuthState
|
|
|
|
# Get persona token data
|
|
persona_token = context.get_token(self.provider_name, "persona", self.country)
|
|
|
|
if not persona_token:
|
|
logger.debug("No persona token found")
|
|
return AuthState.NOT_AUTHENTICATED
|
|
|
|
# Validate persona token structure
|
|
if not isinstance(persona_token, dict) or "persona_token" not in persona_token:
|
|
logger.warning("Invalid persona token structure")
|
|
return AuthState.NOT_AUTHENTICATED
|
|
|
|
# Check if persona token is expired
|
|
if "expires_at" in persona_token:
|
|
current_time = time.time()
|
|
expires_at = persona_token["expires_at"]
|
|
|
|
# Add 5-minute buffer
|
|
if current_time >= (expires_at - 300):
|
|
logger.debug(
|
|
f"Persona token expired (expires_at: {expires_at}, now: {current_time})"
|
|
)
|
|
return AuthState.EXPIRED
|
|
|
|
# Token exists and is valid
|
|
logger.debug("Persona token is valid")
|
|
return AuthState.AUTHENTICATED
|
|
|
|
def _calculate_readiness(self, context) -> Tuple[bool, str]:
|
|
"""
|
|
Custom readiness calculation for Magenta2.
|
|
Ready if we have a valid persona token.
|
|
"""
|
|
import time
|
|
|
|
# Get persona token data
|
|
persona_token = context.get_token(self.provider_name, "persona", self.country)
|
|
|
|
if not persona_token:
|
|
return False, "No persona token available"
|
|
|
|
# Validate structure
|
|
if not isinstance(persona_token, dict) or "persona_token" not in persona_token:
|
|
return False, "Invalid persona token structure"
|
|
|
|
# Check expiration
|
|
if "expires_at" in persona_token:
|
|
current_time = time.time()
|
|
expires_at = persona_token["expires_at"]
|
|
|
|
# Add 5-minute buffer
|
|
if current_time >= (expires_at - 300):
|
|
# Token expired but might be refreshable
|
|
# Check if we have yo_digital token with refresh capability
|
|
yo_token = context.get_token(self.provider_name, "yo_digital", self.country)
|
|
if yo_token and "refresh_token" in yo_token:
|
|
# Check if yo_digital refresh token is still valid
|
|
if (
|
|
"refresh_token_expires_in" in yo_token
|
|
and "refresh_token_issued_at" in yo_token
|
|
):
|
|
refresh_expires_at = (
|
|
yo_token["refresh_token_issued_at"]
|
|
+ yo_token["refresh_token_expires_in"]
|
|
)
|
|
if current_time < (refresh_expires_at - 300):
|
|
return (
|
|
True,
|
|
"Persona token expired but can be refreshed via yo_digital",
|
|
)
|
|
|
|
return (
|
|
False,
|
|
f"Persona token expired (expired at {time.ctime(expires_at)})",
|
|
)
|
|
|
|
# Valid persona token
|
|
return True, "Has valid persona token"
|
|
|
|
def get_auth_details(self, context) -> Dict[str, Any]:
|
|
"""
|
|
Provide Magenta2-specific auth details.
|
|
Shows status of all token scopes.
|
|
"""
|
|
import time
|
|
|
|
details = {}
|
|
|
|
# Check all token scopes
|
|
for scope in self.token_scopes:
|
|
token = context.get_token(self.provider_name, scope, self.country)
|
|
|
|
if not token:
|
|
details[scope] = {"available": False}
|
|
continue
|
|
|
|
scope_info = {"available": True}
|
|
|
|
# Handle persona token specially
|
|
if scope == "persona":
|
|
if "expires_at" in token:
|
|
current_time = time.time()
|
|
expires_at = token["expires_at"]
|
|
scope_info["expires_at"] = expires_at
|
|
scope_info["is_expired"] = current_time >= expires_at
|
|
scope_info["time_remaining"] = int(max(0, expires_at - current_time))
|
|
|
|
if "composed_at" in token:
|
|
scope_info["composed_at"] = token["composed_at"]
|
|
|
|
# Handle yo_digital token
|
|
elif scope == "yo_digital":
|
|
if "access_token_expires_in" in token and "access_token_issued_at" in token:
|
|
current_time = time.time()
|
|
expires_at = token["access_token_issued_at"] + token["access_token_expires_in"]
|
|
scope_info["access_token_expires_at"] = expires_at
|
|
scope_info["access_token_is_expired"] = current_time >= expires_at
|
|
|
|
if "refresh_token_expires_in" in token and "refresh_token_issued_at" in token:
|
|
current_time = time.time()
|
|
refresh_expires_at = (
|
|
token["refresh_token_issued_at"] + token["refresh_token_expires_in"]
|
|
)
|
|
scope_info["refresh_token_expires_at"] = refresh_expires_at
|
|
scope_info["refresh_token_is_expired"] = current_time >= refresh_expires_at
|
|
scope_info["has_refresh_token"] = True
|
|
|
|
# Standard token handling (tvhubs, taa)
|
|
else:
|
|
if "expires_in" in token and "issued_at" in token:
|
|
current_time = time.time()
|
|
expires_at = token["issued_at"] + token["expires_in"]
|
|
scope_info["expires_at"] = expires_at
|
|
scope_info["is_expired"] = current_time >= expires_at
|
|
|
|
details[scope] = scope_info
|
|
|
|
return details
|
|
|
|
def debug_authentication(self) -> Dict[str, Any]:
|
|
"""
|
|
Debug authentication state and token flow
|
|
|
|
Returns comprehensive information about the current authentication state,
|
|
token availability, and TokenFlowManager status.
|
|
"""
|
|
result: Dict[str, Any] = {
|
|
"provider": {
|
|
"provider_name": self.provider_name,
|
|
"country": self.country,
|
|
"platform": self.platform,
|
|
}
|
|
}
|
|
|
|
# Try to get current persona token status
|
|
try:
|
|
persona_token = self.get_persona_token(force_refresh=False)
|
|
persona_info: Dict[str, Any] = {
|
|
"available": True,
|
|
"length": len(persona_token),
|
|
"preview": persona_token[:50] + "...",
|
|
}
|
|
|
|
# Try to extract and verify the JWT inside
|
|
try:
|
|
persona_jwt = self._extract_persona_jwt_from_token(persona_token)
|
|
persona_info["jwt_available"] = bool(persona_jwt)
|
|
if persona_jwt:
|
|
persona_info["jwt_length"] = len(persona_jwt)
|
|
persona_info["jwt_preview"] = persona_jwt[:50] + "..."
|
|
except Exception as e:
|
|
persona_info["jwt_extraction_error"] = str(e)
|
|
|
|
result["persona_token"] = persona_info
|
|
|
|
except Exception as e:
|
|
result["persona_token"] = {"available": False, "error": str(e)}
|
|
|
|
# TokenFlowManager status
|
|
if (
|
|
hasattr(self.authenticator, "token_flow_manager")
|
|
and self.authenticator.token_flow_manager
|
|
):
|
|
result["token_flow_manager"] = {
|
|
"available": True,
|
|
"token_status": self.authenticator.token_flow_manager.get_token_status(),
|
|
}
|
|
else:
|
|
result["token_flow_manager"] = {
|
|
"available": False,
|
|
"error": "TokenFlowManager not initialized",
|
|
}
|
|
|
|
# Authenticator capabilities
|
|
if hasattr(self.authenticator, "get_authentication_capabilities"):
|
|
result["authentication_capabilities"] = (
|
|
self.authenticator.get_authentication_capabilities()
|
|
)
|
|
|
|
# Endpoint manager info
|
|
if self.endpoint_manager:
|
|
result["endpoints"] = {
|
|
"has_taa_auth": self.endpoint_manager.has_endpoint("taa_auth"),
|
|
"has_entitlement": self.endpoint_manager.has_endpoint("entitlement"),
|
|
"has_widevine_license": self.endpoint_manager.has_endpoint("widevine_license"),
|
|
"has_mpx_selector": self.endpoint_manager.has_endpoint("mpx_selector"),
|
|
"total_endpoints": len(self.endpoint_manager.get_all_endpoints()),
|
|
}
|
|
|
|
# SAM3 client status
|
|
if hasattr(self.authenticator, "get_sam3_client_status"):
|
|
result["sam3_client"] = self.authenticator.get_sam3_client_status()
|
|
|
|
return result
|