diff --git a/lib/streaming_providers/providers/magenta2/auth.py b/lib/streaming_providers/providers/magenta2/auth.py index 0d0184c..1278714 100644 --- a/lib/streaming_providers/providers/magenta2/auth.py +++ b/lib/streaming_providers/providers/magenta2/auth.py @@ -327,7 +327,8 @@ class Magenta2Authenticator(BaseOAuth2Authenticator): device_model: Optional[str] = None, sam3_client_id: Optional[str] = None, session_id: Optional[str] = None, - device_id: Optional[str] = None): + device_id: Optional[str] = None, + provider_config: Optional[Any] = None): """ Enhanced authenticator with complete authentication flow support """ @@ -355,6 +356,7 @@ class Magenta2Authenticator(BaseOAuth2Authenticator): # Session and device management self._session_id = session_id or str(uuid.uuid4()) self._device_id = device_id or str(uuid.uuid4()) + self.provider_config = provider_config # NEW: Store MPX account info for persona token composition self._mpx_account_pid: Optional[str] = None @@ -512,7 +514,8 @@ class Magenta2Authenticator(BaseOAuth2Authenticator): sam3_client=self._sam3_client, taa_client=self._taa_client, provider_name=self.provider_name, - country=self.country + country=self.country, + provider_config=self.provider_config ) logger.debug("TokenFlowManager initialized") diff --git a/lib/streaming_providers/providers/magenta2/provider.py b/lib/streaming_providers/providers/magenta2/provider.py index 79eebc4..68bcf33 100644 --- a/lib/streaming_providers/providers/magenta2/provider.py +++ b/lib/streaming_providers/providers/magenta2/provider.py @@ -138,7 +138,8 @@ class Magenta2Provider(StreamingProvider): 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 + device_id=self.device_id, + provider_config=None ) # 🚨 NOW PERFORM CONFIGURATION DISCOVERY (authenticator exists) @@ -151,6 +152,8 @@ class Magenta2Provider(StreamingProvider): # 🚨 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") if self.provider_config.bootstrap.sam3_client_id: # Use public method if available, otherwise update directly if hasattr(self.authenticator, 'update_sam3_client_id'): @@ -355,10 +358,9 @@ class Magenta2Provider(StreamingProvider): 'x-dt-call-id': self._generate_call_id() } - def _get_api_headers(self, use_persona_token: bool = False, - require_auth: bool = False) -> Dict[str, str]: + def _get_api_headers(self, require_auth: bool = False) -> Dict[str, str]: """ - FIXED: Get headers for API requests with proper token usage + Get headers for API requests with persona_token Basic auth """ headers = { 'User-Agent': self.platform_config['user_agent'], @@ -367,19 +369,20 @@ class Magenta2Provider(StreamingProvider): } # Lazy authentication - only authenticate if required and not already done - if require_auth and not self.bearer_token: + if require_auth and not self.persona_token: try: self._ensure_authenticated() except Exception as e: logger.warning(f"Could not authenticate for API headers: {e}") - # CRITICAL FIX: Use COMPOSED persona token for API calls when requested - if use_persona_token and self.persona_token: + # Use persona token for Basic auth when available + if require_auth and self.persona_token: headers['Authorization'] = f'Basic {self.persona_token}' - logger.debug("Using composed persona token (Basic auth)") + logger.debug("Using persona token (Basic auth) for API call") elif self.bearer_token: + # Fallback to Bearer token for backward compatibility headers['Authorization'] = f'Bearer {self.bearer_token}' - logger.debug("Using TAA bearer token") + logger.debug("Using TAA bearer token (fallback)") return headers @@ -475,18 +478,15 @@ class Magenta2Provider(StreamingProvider): logger.error(f"Device registration failed: {e}") return False - def get_yo_digital_token(self, force_refresh: bool = False) -> str: - """Get yo_digital access token following complete hierarchy""" + def get_persona_token(self, force_refresh: bool = False) -> str: + """Get persona token for ALL API calls""" + persona_token = self.authenticator.get_persona_token() + if persona_token: + logger.info("✓ Got persona token") + return persona_token - # Try TokenFlowManager via public API - yo_digital_token = self.authenticator.get_yo_digital_token(force_refresh) - - if yo_digital_token: - logger.info("✓ Got yo_digital token") - return yo_digital_token - - # Fallback to existing authentication - logger.info("Using legacy authentication flow") + # Fallback to legacy authentication if persona token fails + logger.info("Using legacy authentication flow as fallback") return self.authenticate(force_refresh=force_refresh) def authenticate(self, **kwargs) -> str: @@ -545,32 +545,18 @@ class Magenta2Provider(StreamingProvider): return self.bearer_token def _ensure_authenticated(self) -> None: - """Ensure we have valid authentication token""" - - # Try TokenFlowManager first (gets yo_digital) - if hasattr(self.authenticator, 'get_yo_digital_token'): - yo_digital_token = self.authenticator.get_yo_digital_token() - if yo_digital_token: - self.bearer_token = yo_digital_token - logger.info("✓ Lazy authentication via yo_digital") + """Ensure we have valid persona token""" + if hasattr(self.authenticator, 'get_persona_token'): + persona_token = self.authenticator.get_persona_token() + if persona_token: + self.persona_token = persona_token + logger.info("✓ Lazy authentication via persona token") return - # Fallback to old flow + # Fallback to old flow (for backward compatibility) + logger.info("Using legacy authentication for lazy auth") self.bearer_token = self.authenticator.get_bearer_token() - - # CRITICAL: Get composed persona token - self.persona_token = self.authenticator.get_persona_token() - - if not self.persona_token: - # Debug information - auth_state = self.authenticator.debug_authentication_state() - logger.error(f"Authentication state: {json.dumps(auth_state, indent=2)}") - raise Exception( - "Lazy authentication succeeded but persona token composition failed. " - "Check if TAA JWT contains dc_cts_persona_token and account_uri claims." - ) - - logger.info("✓ Lazy authentication complete with persona token") + # Note: Legacy flow might not set persona_token def get_dynamic_manifest_params(self, channel: StreamingChannel, **kwargs) -> Optional[str]: return None @@ -806,13 +792,13 @@ class Magenta2Provider(StreamingProvider): def get_entitlement_token(self, content_id: str, content_type: str = CONTENT_TYPE_LIVE) -> str: """ - FIXED: Get entitlement token using COMPOSED persona token + Get entitlement token using persona_token Basic auth """ # Ensure we're authenticated with persona token self._ensure_authenticated() - # CRITICAL: Use persona token (Basic auth) for entitlement - headers = self._get_api_headers(use_persona_token=True, require_auth=True) + # Use persona token (Basic auth) for entitlement + headers = self._get_api_headers(require_auth=True) payload = { "content_id": content_id, @@ -1064,7 +1050,7 @@ class Magenta2Provider(StreamingProvider): # Get required components for SMIL request selector_service = self.endpoint_manager.get_endpoint('mpx_selector') if not selector_service: - logger.error(f"No mpx_basic_url_selector_service endpoint found for channel {channel_id}") + logger.error(f"No mpx_selector endpoint found for channel {channel_id}") return None account_pid = self.provider_config.manifest.mpx.account_pid @@ -1073,20 +1059,18 @@ class Magenta2Provider(StreamingProvider): return None # Get composed persona token for Basic auth - persona_token = self.authenticator.get_persona_token() + persona_token = self.get_persona_token() if not persona_token: - logger.error(f"No composed persona token available for channel {channel_id}") + logger.error(f"No persona token available for channel {channel_id}") return None - # Fixed client ID (can be parameterized later if needed) + # Fixed client ID client_id = "a8198f31-b406-4177-8dee-f6216c356c75" # Construct SMIL URL smil_url = f"{selector_service}{account_pid}/media/{channel_id}?format=smil&formats=MPEG-DASH&tracking=true&clientId={client_id}" logger.info(f"Requesting SMIL manifest for channel {channel_id}") - logger.debug(f"SMIL URL: {smil_url}") - logger.debug(f"Using account PID: {account_pid}, client ID: {client_id}") # Make SMIL request with Basic auth headers = { @@ -1103,14 +1087,13 @@ class Magenta2Provider(StreamingProvider): ) response.raise_for_status() - logger.info(f"✓ SMIL response received for channel {channel_id} (status: {response.status_code})") + logger.info(f"✓ SMIL response received for channel {channel_id}") # Parse SMIL to extract MPD URL mpd_url = self._parse_smil_for_mpd(response.text, channel_id) if mpd_url: logger.info(f"✓ MPD manifest URL extracted for channel {channel_id}") - logger.debug(f"MPD URL: {mpd_url}") else: logger.warning(f"✗ No MPD URL found in SMIL response for channel {channel_id}") diff --git a/lib/streaming_providers/providers/magenta2/token_flow_manager.py b/lib/streaming_providers/providers/magenta2/token_flow_manager.py index f567507..71d69ce 100644 --- a/lib/streaming_providers/providers/magenta2/token_flow_manager.py +++ b/lib/streaming_providers/providers/magenta2/token_flow_manager.py @@ -6,6 +6,8 @@ yo_digital → taa → tvhubs → line_auth/remote_login """ import time +import base64 +import json from typing import Optional, Dict, Any from dataclasses import dataclass @@ -26,6 +28,12 @@ class TokenFlowResult: error: Optional[str] = None flow_path: Optional[str] = None # For debugging which path was taken +@dataclass +class PersonaResult: + success: bool + persona_token: Optional[str] = None + error: Optional[str] = None + class TokenFlowManager: """ @@ -51,7 +59,8 @@ class TokenFlowManager: sam3_client: 'Sam3Client', taa_client: 'TaaClient', provider_name: str, - country: Optional[str] = None): + country: Optional[str] = None, + provider_config: Optional[Any] = None): """ Initialize TokenFlowManager @@ -67,10 +76,125 @@ class TokenFlowManager: self.taa_client = taa_client self.provider_name = provider_name self.country = country + self.provider_config = provider_config logger.debug(f"TokenFlowManager initialized for {provider_name}" + (f" ({country})" if country else "")) + @staticmethod + def _extract_jwt_claims(jwt_token: str) -> Dict[str, Any]: + """Extract claims from JWT token""" + try: + parts = jwt_token.split('.') + if len(parts) != 3: + logger.warning("Invalid JWT format") + return {} + + # Decode payload + payload_b64 = parts[1] + padding = len(payload_b64) % 4 + if padding: + payload_b64 += '=' * (4 - padding) + + payload_json = base64.b64decode(payload_b64).decode('utf-8') + claims = json.loads(payload_json) + + logger.debug(f"JWT claims extracted: {list(claims.keys())}") + return claims + + except Exception as e: + logger.error(f"Failed to extract JWT claims: {e}") + return {} + + def _compose_persona_token(self, access_token: str) -> Optional[str]: + """ + Compose persona token from access_token JWT claims + """ + try: + # Import here to avoid circular imports + from .config_models import ProviderConfig + + # Extract dc_cts_persona_token from JWT + claims = self._extract_jwt_claims(access_token) + dc_cts_persona_token = claims.get('dc_cts_persona_token') + + if not dc_cts_persona_token: + logger.error("No dc_cts_persona_token found in JWT claims") + return None + + # Use the discovered account URI from ProviderConfig + if not hasattr(self, 'provider_config') or not self.provider_config: + logger.error("No provider_config available - cannot compose persona token") + return None + + # Type check to ensure it's a ProviderConfig + if not isinstance(self.provider_config, ProviderConfig): + logger.error(f"provider_config is not ProviderConfig: {type(self.provider_config)}") + return None + + # CORRECTED: Use get_mpx_account_uri() method from ProviderConfig + account_uri = self.provider_config.get_mpx_account_uri() + if not account_uri: + logger.error("No account URI available from provider_config") + return None + + logger.debug(f"Using discovered account URI: {account_uri}") + + # Compose: account_uri + ":" + dc_cts_persona_token + raw_token = f"{account_uri}:{dc_cts_persona_token}" + + # Base64 encode + persona_token = base64.b64encode(raw_token.encode('utf-8')).decode('utf-8') + + logger.info("✓ Persona token composed successfully") + logger.debug(f"Account URI: {account_uri}") + logger.debug(f"Persona token preview: {persona_token[:50]}...") + + return persona_token + + except ImportError as e: + logger.error(f"Failed to import ProviderConfig: {e}") + return None + except Exception as e: + logger.error(f"Failed to compose persona token: {e}") + return None + + def get_persona_token(self, force_refresh: bool = False) -> PersonaResult: + """ + Get persona token - compose from successful yo_digital token + """ + # Get access_token using existing method + token_result = self.get_yo_digital_token(force_refresh) + + # If it failed, return the failure + if not token_result.success: + return PersonaResult( + success=False, + error=token_result.error, + ) + + # We have success - extract access_token and compose persona_token + access_token = token_result.access_token + + if not access_token: + return PersonaResult( + success=False, + error="Failed to get yo_digital access token" + ) + + # Compose persona_token + persona_token = self._compose_persona_token(access_token) + if not persona_token: + return PersonaResult( + success=False, + error="Failed to compose persona token" + ) + + return PersonaResult( + success=True, + persona_token=persona_token + ) + def get_yo_digital_token(self, force_refresh: bool = False) -> TokenFlowResult: """ Get yo_digital access token following the complete hierarchy