From e9972d650fa114432df88b1a07583b772b209101 Mon Sep 17 00:00:00 2001 From: Nirvana Date: Thu, 13 Nov 2025 16:36:39 +0100 Subject: [PATCH] Add Magenta 2.0 (DE) --- README.md | 1 + .../base/auth/credentials.py | 2 +- .../base/auth/session_manager.py | 285 +-- lib/streaming_providers/base/ui/__init__.py | 31 + .../base/ui/console_notification_adapter.py | 167 ++ .../base/ui/kodi_notification_adapter.py | 310 +++ .../base/ui/notification_factory.py | 150 ++ .../base/ui/notification_interface.py | 151 ++ .../providers/magenta2/__init__.py | 45 + .../providers/magenta2/auth.py | 1696 +++++++++++++++++ .../providers/magenta2/config_models.py | 399 ++++ .../providers/magenta2/constants.py | 263 +++ .../providers/magenta2/discovery.py | 379 ++++ .../providers/magenta2/endpoint_manager.py | 230 +++ .../providers/magenta2/models.py | 209 ++ .../providers/magenta2/provider.py | 1041 ++++++++++ .../magenta2/remote_login_handler.py | 352 ++++ .../providers/magenta2/sam3_client.py | 635 ++++++ .../providers/magenta2/sso_client.py | 219 +++ .../providers/magenta2/taa_client.py | 374 ++++ resources/settings.xml | 21 + 21 files changed, 6844 insertions(+), 116 deletions(-) create mode 100644 lib/streaming_providers/base/ui/__init__.py create mode 100644 lib/streaming_providers/base/ui/console_notification_adapter.py create mode 100644 lib/streaming_providers/base/ui/kodi_notification_adapter.py create mode 100644 lib/streaming_providers/base/ui/notification_factory.py create mode 100644 lib/streaming_providers/base/ui/notification_interface.py create mode 100644 lib/streaming_providers/providers/magenta2/__init__.py create mode 100644 lib/streaming_providers/providers/magenta2/auth.py create mode 100644 lib/streaming_providers/providers/magenta2/config_models.py create mode 100644 lib/streaming_providers/providers/magenta2/constants.py create mode 100644 lib/streaming_providers/providers/magenta2/discovery.py create mode 100644 lib/streaming_providers/providers/magenta2/endpoint_manager.py create mode 100644 lib/streaming_providers/providers/magenta2/models.py create mode 100644 lib/streaming_providers/providers/magenta2/provider.py create mode 100644 lib/streaming_providers/providers/magenta2/remote_login_handler.py create mode 100644 lib/streaming_providers/providers/magenta2/sam3_client.py create mode 100644 lib/streaming_providers/providers/magenta2/sso_client.py create mode 100644 lib/streaming_providers/providers/magenta2/taa_client.py diff --git a/README.md b/README.md index 9ae4a20..e86117b 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ Currently supported: - 🇦🇹 **Joyn (AT)** - 🇨🇭 **Joyn (CH)** - 🇩🇪 **RTL+** +- 🇩🇪 **Magenta TV 2.0** - 🇦🇹 **Magenta TV (AT)** - 🇭🇷 **Max TV (HR)** - 🇵🇱 **Magenta TV (PL)** diff --git a/lib/streaming_providers/base/auth/credentials.py b/lib/streaming_providers/base/auth/credentials.py index c84b638..6396e17 100644 --- a/lib/streaming_providers/base/auth/credentials.py +++ b/lib/streaming_providers/base/auth/credentials.py @@ -63,7 +63,7 @@ class ClientCredentials(BaseCredentials): Client credentials (client_id/client_secret) based authentication """ client_id: str - client_secret: str + client_secret: Optional[str] = "" grant_type: str = 'client_credentials' def validate(self) -> bool: diff --git a/lib/streaming_providers/base/auth/session_manager.py b/lib/streaming_providers/base/auth/session_manager.py index 569f1a0..1dbc3c8 100644 --- a/lib/streaming_providers/base/auth/session_manager.py +++ b/lib/streaming_providers/base/auth/session_manager.py @@ -13,7 +13,7 @@ class SessionManager: """ Manages persistent session data including tokens and device IDs Compatible with both Kodi and standalone environments via VFS abstraction - Now supports country-specific sessions + Now supports country-specific sessions and scope-based token storage """ def __init__(self, config_dir: Optional[str] = None): @@ -25,12 +25,9 @@ class SessionManager: """ # Initialize VFS with optional config directory override if config_dir: - # For custom config directories, we'll use a VFS instance that treats - # the config_dir as the base path directly self.vfs = VFS() self.vfs._base_path = config_dir else: - # Use default VFS (handles Kodi vs standard filesystem automatically) self.vfs = VFS() # Session file is always in the root of the VFS base path @@ -92,12 +89,7 @@ class SessionManager: if session_data: # Log what we found (without sensitive data) - safe_data = {} - for key, value in session_data.items(): - if key in ['access_token', 'refresh_token', 'client_secret', 'password']: - safe_data[key] = f"<{key}_present>" if value else f"<{key}_missing>" - else: - safe_data[key] = value + safe_data = self._get_safe_representation(session_data) logger.info(f"Loaded session data for {provider}{country_str}: {safe_data}") else: logger.info(f"No session data found for {provider}{country_str} in session file") @@ -132,11 +124,9 @@ class SessionManager: # Extract and preserve token classification data if it's a token object if hasattr(session_data, 'auth_level'): - # If session_data is a token-like object, convert to dict first if hasattr(session_data, 'to_dict'): session_data = session_data.to_dict() else: - # Extract classification fields from object token_dict = {} for key in ['access_token', 'refresh_token', 'token_type', 'expires_in', 'issued_at', 'refresh_expires_in']: @@ -145,12 +135,7 @@ class SessionManager: session_data = token_dict # Log what we're about to save (without sensitive data) - safe_data = {} - for key, value in session_data.items(): - if key in ['access_token', 'refresh_token', 'client_secret', 'password']: - safe_data[key] = f"<{key}_present>" if value else f"<{key}_missing>" - else: - safe_data[key] = value + safe_data = self._get_safe_representation(session_data) logger.info(f"Saving session data for {provider}{country_str}: {safe_data}") # Navigate and create nested structure if needed @@ -178,7 +163,6 @@ class SessionManager: # Verify by reading it back verify_data = self.vfs.read_json(self.session_file) if verify_data: - # Navigate to verify verify_current = verify_data found = True for key in keys_path: @@ -207,10 +191,115 @@ class SessionManager: logger.error(f"Full traceback: {traceback.format_exc()}") return False + def save_scoped_token(self, provider: str, scope: str, token_data: Dict[str, Any], + country: Optional[str] = None) -> bool: + """ + Save authentication token for a specific scope + + Args: + provider: Provider name + scope: Token scope (e.g., 'line_auth', 'taa', 'yo_digital') + token_data: Token data to save + country: Optional country code + + Returns: + True if successful, False otherwise + """ + country_str = f" (country: {country})" if country else "" + + try: + logger.debug(f"Saving scoped token for {provider}{country_str}, scope: {scope}") + + # Load existing session data + session_data = self.load_session(provider, country) or {} + + # Update token data for this scope + session_data[scope] = token_data + + # Log what we're saving + safe_token_data = self._get_safe_representation(token_data) + logger.info(f"Scoped token data for {provider}{country_str}/{scope}: {safe_token_data}") + + success = self.save_session(provider, session_data, country) + if success: + logger.info(f"Successfully saved scoped token for {provider}{country_str}/{scope}") + else: + logger.error(f"Failed to save scoped token for {provider}{country_str}/{scope}") + return success + + except Exception as e: + logger.error(f"Error saving scoped token for {provider}{country_str}/{scope}: {e}") + return False + + def load_scoped_token(self, provider: str, scope: str, + country: Optional[str] = None) -> Optional[Dict[str, Any]]: + """ + Load token data for a specific scope + + Args: + provider: Provider name + scope: Token scope (e.g., 'line_auth', 'taa', 'yo_digital') + country: Optional country code + + Returns: + Token data dictionary or None + """ + country_str = f" (country: {country})" if country else "" + + logger.debug(f"Loading scoped token for {provider}{country_str}, scope: {scope}") + + session_data = self.load_session(provider, country) + if not session_data: + logger.info(f"No session data available for {provider}{country_str}") + return None + + # Check if scope exists + if scope not in session_data: + logger.info(f"No token found for scope '{scope}' in {provider}{country_str}") + logger.debug(f"Available scopes: {list(session_data.keys())}") + return None + + token_data = session_data[scope] + + # Validate it's actually token data + if not isinstance(token_data, dict) or 'access_token' not in token_data: + logger.warning(f"Scope '{scope}' exists but doesn't contain valid token data") + return None + + # Check if token is expired + if self._is_token_expired(token_data): + logger.info(f"Token for scope '{scope}' is expired") + return token_data # Return anyway so refresh can be attempted + + logger.info(f"Loaded valid token for {provider}{country_str}/{scope}") + return token_data + + @staticmethod + def _is_token_expired(token_data: Dict[str, Any], buffer_seconds: int = 300) -> bool: + """ + Check if token is expired with buffer + + Args: + token_data: Token data dictionary + buffer_seconds: Seconds buffer before expiry (default 5 minutes) + + Returns: + True if expired, False otherwise + """ + if 'expires_in' not in token_data or 'issued_at' not in token_data: + return False # Can't determine, assume valid + + expires_in = token_data.get('expires_in', 0) + issued_at = token_data.get('issued_at', 0) + current_time = time.time() + expires_at = issued_at + expires_in + + return current_time >= (expires_at - buffer_seconds) + def save_token(self, provider: str, token: BaseAuthToken, country: Optional[str] = None) -> bool: """ - Save authentication token for a provider + Save authentication token for a provider (legacy compatibility) Args: provider: Provider name @@ -227,24 +316,15 @@ class SessionManager: # Load existing session data session_data = self.load_session(provider, country) or {} - logger.debug(f"Current session data keys before token save: {list(session_data.keys())}") - # Update token data - include classification fields + # Update token data token_data = token.to_dict() # Log token info (without sensitive data) - safe_token_data = {} - for key, value in token_data.items(): - if key in ['access_token', 'refresh_token']: - safe_token_data[key] = f"<{key}_present>" if value else f"<{key}_missing>" - elif key in ['auth_level', 'credential_type']: - safe_token_data[key] = value # Include classification info - else: - safe_token_data[key] = value + safe_token_data = self._get_safe_representation(token_data) logger.info(f"Token data to save for {provider}{country_str}: {safe_token_data}") session_data.update(token_data) - logger.debug(f"Updated session data keys after token merge: {list(session_data.keys())}") success = self.save_session(provider, session_data, country) if success: @@ -258,7 +338,7 @@ class SessionManager: return False def load_token_data(self, provider: str, country: Optional[str] = None) -> Optional[Dict[str, Any]]: - """Load token data for a provider""" + """Load token data for a provider (legacy compatibility)""" country_str = f" (country: {country})" if country else "" logger.debug(f"Loading token data for {provider}{country_str}") @@ -274,55 +354,27 @@ class SessionManager: logger.debug(f"Available session keys: {list(session_data.keys())}") return None - # Check if token is expired (with 5 minute buffer) - expires_in = session_data.get('expires_in', 0) - issued_at = session_data.get('issued_at', 0) - current_time = time.time() - expires_at = issued_at + expires_in - time_until_expiry = expires_at - current_time - - logger.debug(f"Token expiry check for {provider}{country_str}: issued_at={issued_at}, " - f"expires_in={expires_in}, current_time={current_time}, " - f"time_until_expiry={time_until_expiry:.0f}") - - is_expired = current_time >= (expires_at - 300) # 5 minute buffer - has_refresh_token = bool(session_data.get('refresh_token')) - - logger.debug(f"Token status - is_expired: {is_expired}, has_refresh_token: {has_refresh_token}") - - if is_expired: + # Check if token is expired + if self._is_token_expired(session_data): + has_refresh_token = bool(session_data.get('refresh_token')) if has_refresh_token: - # Token is expired BUT we have a refresh token - return the data so refresh can be attempted - logger.info( - f"Token expired for {provider}{country_str} but refresh token available - returning data for refresh") + logger.info(f"Token expired for {provider}{country_str} but refresh token available") return session_data else: logger.info(f"Token expired for {provider}{country_str} and no refresh token available") return None - logger.info(f"Loaded valid token data for {provider}{country_str} " - f"(expires in {time_until_expiry:.0f}s, " - f"auth_level={session_data.get('auth_level')})") + logger.info(f"Loaded valid token data for {provider}{country_str}") return session_data def get_device_id(self, provider: str, country: Optional[str] = None) -> str: - """ - Get or generate device ID for a provider - - Args: - provider: Provider name - country: Optional country code - - Returns: - Device ID (UUID string) - """ + """Get or generate device ID for a provider""" country_str = f" (country: {country})" if country else "" session_data = self.load_session(provider, country) or {} device_id = session_data.get('device_id') if not device_id: - # Generate new device ID device_id = str(uuid.uuid4()) session_data['device_id'] = device_id self.save_session(provider, session_data, country) @@ -333,16 +385,7 @@ class SessionManager: return device_id def clear_session(self, provider: str, country: Optional[str] = None) -> bool: - """ - Clear session data for a provider and optional country - - Args: - provider: Provider name - country: Optional country code (if None, clears entire provider) - - Returns: - True if successful, False otherwise - """ + """Clear session data for a provider and optional country""" country_str = f" (country: {country})" if country else "" try: @@ -352,12 +395,10 @@ class SessionManager: return True if country: - # Clear specific country data if provider in data and isinstance(data[provider], dict) and country in data[provider]: del data[provider][country] logger.info(f"Cleared session data for {provider}{country_str}") - # If provider dict is now empty, remove it entirely if not data[provider]: del data[provider] logger.debug(f"Provider {provider} had no more countries, removed entirely") @@ -366,7 +407,6 @@ class SessionManager: else: logger.debug(f"No session data found to clear for {provider}{country_str}") else: - # Clear entire provider data (all countries) if provider in data: del data[provider] logger.info(f"Cleared all session data for {provider}") @@ -380,12 +420,14 @@ class SessionManager: logger.error(f"Error clearing session for {provider}{country_str}: {e}") return False - def clear_token(self, provider: str, country: Optional[str] = None) -> bool: + def clear_scoped_token(self, provider: str, scope: str, + country: Optional[str] = None) -> bool: """ - Clear only token data but keep other session data (like device_id) + Clear token for a specific scope Args: provider: Provider name + scope: Token scope to clear country: Optional country code Returns: @@ -393,6 +435,28 @@ class SessionManager: """ country_str = f" (country: {country})" if country else "" + try: + session_data = self.load_session(provider, country) + if not session_data: + logger.debug(f"No session data found for {provider}{country_str}") + return True + + if scope in session_data: + del session_data[scope] + logger.info(f"Cleared scoped token for {provider}{country_str}/{scope}") + return self.save_session(provider, session_data, country) + else: + logger.debug(f"No token found for scope '{scope}' in {provider}{country_str}") + return True + + except Exception as e: + logger.error(f"Error clearing scoped token for {provider}{country_str}/{scope}: {e}") + return False + + def clear_token(self, provider: str, country: Optional[str] = None) -> bool: + """Clear only token data but keep other session data (legacy compatibility)""" + country_str = f" (country: {country})" if country else "" + try: session_data = self.load_session(provider, country) if not session_data: @@ -417,15 +481,7 @@ class SessionManager: return False def get_all_countries(self, provider: str) -> list: - """ - Get all countries that have session data for a provider - - Args: - provider: Provider name - - Returns: - List of country codes - """ + """Get all countries that have session data for a provider""" try: data = self.vfs.read_json(self.session_file) if not data or provider not in data: @@ -433,10 +489,7 @@ class SessionManager: provider_data = data[provider] - # Check if this is a nested (country-aware) structure if isinstance(provider_data, dict): - # Check if any keys look like country codes (2-3 char strings) - # and their values are dicts (session data) countries = [] for key, value in provider_data.items(): if isinstance(value, dict) and len(key) <= 3: @@ -449,14 +502,29 @@ class SessionManager: logger.error(f"Error getting countries for {provider}: {e}") return [] + @staticmethod + def _get_safe_representation(data: Any) -> Dict[str, Any]: + """Get safe representation of data hiding sensitive fields""" + if not isinstance(data, dict): + return {} + + safe_data = {} + for key, value in data.items(): + if key in ['access_token', 'refresh_token', 'client_secret', 'password']: + safe_data[key] = f"" if value else f"" + elif isinstance(value, dict): + # Recursively handle nested dicts (for scoped tokens) + safe_data[key] = SessionManager._get_safe_representation(value) + else: + safe_data[key] = value + + return safe_data + def debug_session_file(self) -> None: - """ - Debug method to log the current state of the session file - """ + """Debug method to log the current state of the session file""" try: logger.info(f"=== SESSION FILE DEBUG INFO ===") - # Get VFS debug info vfs_info = self.vfs.debug_info() for key, value in vfs_info.items(): logger.info(f"VFS {key}: {value}") @@ -476,7 +544,6 @@ class SessionManager: for provider, provider_data in data.items(): if isinstance(provider_data, dict): - # Check if nested (has countries) has_countries = any( isinstance(v, dict) and len(k) <= 3 for k, v in provider_data.items() @@ -486,12 +553,11 @@ class SessionManager: logger.info(f" {provider} (country-aware):") for country, session_data in provider_data.items(): if isinstance(session_data, dict): - safe_keys = self._get_safe_keys(session_data) - logger.info(f" {country}: {safe_keys}") + safe_repr = self._get_safe_representation(session_data) + logger.info(f" {country}: {safe_repr}") else: - # Flat structure (no country) - safe_keys = self._get_safe_keys(provider_data) - logger.info(f" {provider} (no country): {safe_keys}") + safe_repr = self._get_safe_representation(provider_data) + logger.info(f" {provider} (no country): {safe_repr}") else: logger.error("Session file contains invalid JSON or is empty") else: @@ -502,15 +568,4 @@ class SessionManager: logger.info(f"=== END SESSION FILE DEBUG ===") except Exception as e: - logger.error(f"Error during session file debug: {e}") - - @staticmethod - def _get_safe_keys(session_data: Dict[str, Any]) -> list: - """Helper to get safe representation of session keys""" - safe_keys = [] - for key in session_data.keys(): - if key in ['access_token', 'refresh_token']: - safe_keys.append(f"{key}:present" if session_data[key] else f"{key}:missing") - else: - safe_keys.append(f"{key}:{session_data[key]}") - return safe_keys \ No newline at end of file + logger.error(f"Error during session file debug: {e}") \ No newline at end of file diff --git a/lib/streaming_providers/base/ui/__init__.py b/lib/streaming_providers/base/ui/__init__.py new file mode 100644 index 0000000..7c24f89 --- /dev/null +++ b/lib/streaming_providers/base/ui/__init__.py @@ -0,0 +1,31 @@ +# ============================================================================ +# FILE 1: streaming_providers/base/ui/__init__.py +# ============================================================================ +""" +UI notification system for streaming providers +Provides adapters for different UI environments (Kodi, console, web, etc.) +""" + +from .notification_interface import NotificationInterface, NotificationResult +from .notification_factory import NotificationFactory + +# Conditional imports - only import if environment supports them +try: + from .kodi_notification_adapter import KodiNotificationAdapter + __all__ = [ + 'NotificationInterface', + 'NotificationResult', + 'NotificationFactory', + 'KodiNotificationAdapter', + 'ConsoleNotificationAdapter' + ] +except ImportError: + # Kodi not available + __all__ = [ + 'NotificationInterface', + 'NotificationResult', + 'NotificationFactory', + 'ConsoleNotificationAdapter' + ] + +from .console_notification_adapter import ConsoleNotificationAdapter \ No newline at end of file diff --git a/lib/streaming_providers/base/ui/console_notification_adapter.py b/lib/streaming_providers/base/ui/console_notification_adapter.py new file mode 100644 index 0000000..9ff0cd4 --- /dev/null +++ b/lib/streaming_providers/base/ui/console_notification_adapter.py @@ -0,0 +1,167 @@ +# ============================================================================ +# FILE 3: streaming_providers/base/ui/console_notification_adapter.py +# ============================================================================ +""" +Console notification adapter for non-Kodi environments +Displays remote login information as simple text output +""" +import time +from typing import Optional +from .notification_interface import NotificationInterface, NotificationResult +from ..utils.logger import logger + + +class ConsoleNotificationAdapter(NotificationInterface): + """ + Console-based notification adapter + + Displays remote login information as text output + Suitable for: + - Standalone script execution + - Headless environments + - Development/testing + - CI/CD pipelines + """ + + def __init__(self): + """Initialize console notification adapter""" + super().__init__() + self._start_time = None + self._expires_in = 0 + self._last_update = 0 + + @property + def supports_qr_display(self) -> bool: + """Console cannot display QR codes""" + return False + + @property + def supports_countdown(self) -> bool: + """Console supports text-based countdown""" + return True + + @property + def is_blocking(self) -> bool: + """Console output is non-blocking""" + return False + + def show_remote_login( + self, + login_code: str, + qr_url: str, + expires_in: int, + interval: int = 10 + ) -> NotificationResult: + """ + Show remote login information in console + + Args: + login_code: Short login code + qr_url: URL to QR code + expires_in: Expiration time in seconds + interval: Update interval + + Returns: + NotificationResult.CONTINUE (always, as console can't cancel) + """ + self._is_active = True + self._start_time = time.time() + self._expires_in = expires_in + self._last_update = 0 + + # Print header + print("\n" + "=" * 70) + print(" MAGENTATV REMOTE LOGIN REQUIRED") + print("=" * 70) + print() + + # Print instructions + print("Please authenticate using your mobile device:") + print() + print(f" Option 1: Scan QR Code") + print(f" Visit this URL on your mobile device:") + print(f" {qr_url}") + print() + print(f" Option 2: Manual Entry") + print(f" Login Code: {login_code}") + print() + print(f" This code expires in {expires_in} seconds") + print() + print("=" * 70) + print() + + logger.info(f"Remote login started: code={login_code}, expires_in={expires_in}s") + logger.info(f"QR code URL: {qr_url}") + + return NotificationResult.CONTINUE + + def update_countdown(self, remaining_seconds: int) -> bool: + """ + Update countdown in console + + Only prints updates at reasonable intervals to avoid spam + + Args: + remaining_seconds: Seconds remaining + + Returns: + bool: Always True (console can't be cancelled by user) + """ + if not self._is_active: + return True + + # Print milestone updates (every 30 seconds, or at key intervals) + if remaining_seconds <= 0: + print(f"⏰ Remote login expired") + return True + + # Print at: 240s, 180s, 120s, 60s, 30s, 10s + milestones = [240, 180, 120, 60, 30, 10] + + if remaining_seconds in milestones or remaining_seconds <= 10: + minutes = remaining_seconds // 60 + seconds = remaining_seconds % 60 + + if minutes > 0: + time_str = f"{minutes}m {seconds}s" + else: + time_str = f"{seconds}s" + + print(f"⏳ Waiting for authentication... {time_str} remaining") + + return True + + def close(self, success: bool = False, message: Optional[str] = None): + """ + Close console notification + + Args: + success: Whether authentication succeeded + message: Optional message + """ + if not self._is_active: + return + + self._is_active = False + + print() + print("=" * 70) + + if success: + print("✓ Remote login successful!") + elif message: + print(f"✗ Remote login failed: {message}") + else: + print("✗ Remote login failed") + + print("=" * 70) + print() + + def is_cancelled(self) -> bool: + """ + Check if cancelled (always False for console) + + Returns: + bool: Always False (console can't be cancelled interactively) + """ + return False diff --git a/lib/streaming_providers/base/ui/kodi_notification_adapter.py b/lib/streaming_providers/base/ui/kodi_notification_adapter.py new file mode 100644 index 0000000..5696f50 --- /dev/null +++ b/lib/streaming_providers/base/ui/kodi_notification_adapter.py @@ -0,0 +1,310 @@ +# ============================================================================ +# FILE 4: streaming_providers/base/ui/kodi_notification_adapter.py +# ============================================================================ +""" +Kodi notification adapter using xbmcgui dialogs +Displays remote login QR code in a non-blocking progress dialog +""" +import time +import os +import tempfile +from typing import Optional +from .notification_interface import NotificationInterface, NotificationResult +from ..utils.logger import logger + + +class KodiNotificationAdapter(NotificationInterface): + """ + Kodi-based notification adapter + + Uses xbmcgui.DialogProgress() for non-blocking display + Features: + - Downloads and displays QR code SVG + - Live countdown updates + - User can cancel + - Non-blocking operation + """ + + def __init__(self, http_manager=None): + """ + Initialize Kodi notification adapter + + Args: + http_manager: Optional HTTPManager instance for QR code download + """ + super().__init__() + + # Import Kodi modules + try: + import xbmcgui + import xbmc + import xbmcvfs + self.xbmcgui = xbmcgui + self.xbmc = xbmc + self.xbmcvfs = xbmcvfs + self._kodi_available = True + except ImportError as e: + logger.error(f"Kodi modules not available: {e}") + self._kodi_available = False + raise RuntimeError("Kodi modules not available") + + self._dialog = None + self._qr_image_path = None + self._start_time = None + self._expires_in = 0 + self._interval = 10 + self._http_manager = http_manager + + @property + def supports_qr_display(self) -> bool: + """Kodi can display QR code images""" + return True + + @property + def supports_countdown(self) -> bool: + """Kodi supports live countdown""" + return True + + @property + def is_blocking(self) -> bool: + """Kodi dialog is non-blocking""" + return False + + def show_remote_login( + self, + login_code: str, + qr_url: str, + expires_in: int, + interval: int = 10 + ) -> NotificationResult: + """ + Show remote login dialog in Kodi + + Args: + login_code: Short login code + qr_url: URL to QR code SVG + expires_in: Expiration time in seconds + interval: Update interval + + Returns: + NotificationResult indicating outcome + """ + if not self._kodi_available: + return NotificationResult.ERROR + + self._is_active = True + self._start_time = time.time() + self._expires_in = expires_in + self._interval = interval + self._is_cancelled = False + + try: + # Create progress dialog + self._dialog = self.xbmcgui.DialogProgress() + + # Download QR code SVG + qr_image_path = self._download_qr_code(qr_url) + + # Build dialog heading and message + heading = "MagentaTV Remote Login" + + # Initial message with instructions + line1 = f"Please scan the QR code with your mobile device" + line2 = f"Login Code: [B]{login_code}[/B]" + line3 = f"Expires in: {self._format_time(expires_in)}" + + # Show dialog + self._dialog.create(heading, line1, line2, line3) + + # Set QR code image if available + if qr_image_path and os.path.exists(qr_image_path): + # Note: DialogProgress doesn't support images directly + # We'll show the code prominently instead + logger.info(f"QR code downloaded to: {qr_image_path}") + # TODO: Consider using a custom window for better QR display + + logger.info(f"Kodi dialog shown: code={login_code}, expires_in={expires_in}s") + + return NotificationResult.CONTINUE + + except Exception as e: + logger.error(f"Failed to show Kodi dialog: {e}") + self._is_active = False + return NotificationResult.ERROR + + def update_countdown(self, remaining_seconds: int) -> bool: + """ + Update countdown in Kodi dialog + + Args: + remaining_seconds: Seconds remaining + + Returns: + bool: True to continue, False if user cancelled + """ + if not self._is_active or not self._dialog: + return False + + try: + # Check if user cancelled + if self._dialog.iscanceled(): + logger.info("User cancelled remote login in Kodi") + self._is_cancelled = True + return False + + # Calculate percentage for progress bar + elapsed = self._expires_in - remaining_seconds + percentage = int((elapsed / self._expires_in) * 100) + + # Update dialog + line1 = "Please scan the QR code with your mobile device" + line2 = "Or open the MagentaTV app and enter the code" + line3 = f"Time remaining: {self._format_time(remaining_seconds)}" + + self._dialog.update(percentage, line1, line2, line3) + + return True + + except Exception as e: + logger.error(f"Failed to update Kodi dialog: {e}") + return False + + def close(self, success: bool = False, message: Optional[str] = None): + """ + Close Kodi dialog + + Args: + success: Whether authentication succeeded + message: Optional message + """ + if not self._is_active: + return + + self._is_active = False + + try: + # Close progress dialog + if self._dialog: + self._dialog.close() + self._dialog = None + + # Show result notification + if success: + self.xbmcgui.Dialog().notification( + "MagentaTV", + "Remote login successful!", + self.xbmcgui.NOTIFICATION_INFO, + 3000 + ) + elif message: + self.xbmcgui.Dialog().notification( + "MagentaTV", + f"Remote login failed: {message}", + self.xbmcgui.NOTIFICATION_ERROR, + 5000 + ) + + # Cleanup QR image + self._cleanup_qr_image() + + except Exception as e: + logger.error(f"Failed to close Kodi dialog: {e}") + + def is_cancelled(self) -> bool: + """ + Check if user cancelled + + Returns: + bool: True if user clicked cancel + """ + if self._dialog and self._dialog.iscanceled(): + self._is_cancelled = True + return self._is_cancelled + + def _download_qr_code(self, qr_url: str) -> Optional[str]: + """ + Download QR code SVG from URL + + Args: + qr_url: URL to QR code SVG + + Returns: + str: Path to downloaded file, or None if failed + """ + try: + # Use http_manager if available, otherwise fall back to requests + if self._http_manager: + logger.debug(f"Downloading QR code via http_manager from: {qr_url}") + response = self._http_manager.get( + qr_url, + operation='qr_download', + timeout=10 + ) + else: + # Fallback to requests if http_manager not available + try: + import requests + logger.debug(f"Downloading QR code via requests from: {qr_url}") + response = requests.get(qr_url, timeout=10) + except ImportError: + logger.warning("Neither http_manager nor requests available, cannot download QR code") + return None + + response.raise_for_status() + + # Verify content type + content_type = response.headers.get('Content-Type', '') + if 'svg' not in content_type.lower(): + logger.warning(f"Unexpected content type: {content_type}") + + # Create temp file for QR code + temp_dir = tempfile.gettempdir() + qr_filename = f"magentatv_qr_{int(time.time())}.svg" + qr_path = os.path.join(temp_dir, qr_filename) + + # Save to file + with open(qr_path, 'wb') as f: + f.write(response.content) + + self._qr_image_path = qr_path + logger.info(f"QR code downloaded successfully: {qr_path}") + + return qr_path + + except Exception as e: + logger.error(f"Failed to download QR code: {e}") + return None + + def _cleanup_qr_image(self): + """Cleanup temporary QR image file""" + if self._qr_image_path and os.path.exists(self._qr_image_path): + try: + os.remove(self._qr_image_path) + logger.debug(f"Cleaned up QR image: {self._qr_image_path}") + except Exception as e: + logger.warning(f"Failed to cleanup QR image: {e}") + finally: + self._qr_image_path = None + + @staticmethod + def _format_time(seconds: int) -> str: + """ + Format seconds as human-readable time + + Args: + seconds: Time in seconds + + Returns: + str: Formatted time string (e.g., "3m 45s") + """ + if seconds <= 0: + return "Expired" + + minutes = seconds // 60 + secs = seconds % 60 + + if minutes > 0: + return f"{minutes}m {secs}s" + else: + return f"{secs}s" + diff --git a/lib/streaming_providers/base/ui/notification_factory.py b/lib/streaming_providers/base/ui/notification_factory.py new file mode 100644 index 0000000..c76f24c --- /dev/null +++ b/lib/streaming_providers/base/ui/notification_factory.py @@ -0,0 +1,150 @@ +# ============================================================================ +# FILE 5: streaming_providers/base/ui/notification_factory.py +# ============================================================================ +""" +Factory for creating appropriate notification adapters +Auto-detects environment (Kodi vs standalone) and creates correct adapter +""" +from typing import Optional +from .notification_interface import NotificationInterface +from ..utils.logger import logger + + +class NotificationFactory: + """ + Factory for creating notification adapters + + Automatically detects the runtime environment and creates + the appropriate adapter: + - KodiNotificationAdapter if running in Kodi + - ConsoleNotificationAdapter if running standalone + """ + + _cached_adapter: Optional[NotificationInterface] = None + _environment_detected: Optional[str] = None + + @classmethod + def create(cls, force_environment: Optional[str] = None, + http_manager=None) -> NotificationInterface: + """ + Create appropriate notification adapter + + Args: + force_environment: Force specific environment ('kodi' or 'console') + If None, auto-detects + http_manager: Optional HTTPManager instance for network requests + + Returns: + NotificationInterface: Appropriate adapter for current environment + """ + # Return cached adapter if available and no http_manager change + if cls._cached_adapter is not None and force_environment is None and http_manager is None: + logger.debug(f"Using cached notification adapter: {cls._environment_detected}") + return cls._cached_adapter + + # Detect environment + if force_environment: + environment = force_environment.lower() + logger.info(f"Forced notification environment: {environment}") + else: + environment = cls._detect_environment() + logger.info(f"Detected notification environment: {environment}") + + # Create appropriate adapter + if environment == 'kodi': + adapter = cls._create_kodi_adapter(http_manager=http_manager) + else: + adapter = cls._create_console_adapter() + + # Cache the adapter + cls._cached_adapter = adapter + cls._environment_detected = environment + + return adapter + + @classmethod + def _detect_environment(cls) -> str: + """ + Auto-detect runtime environment + + Returns: + str: 'kodi' or 'console' + """ + try: + # Try to import xbmcgui + import xbmcgui + + # If import succeeds, we're in Kodi + logger.debug("Kodi modules available - using Kodi notification adapter") + return 'kodi' + + except ImportError: + # If import fails, we're standalone + logger.debug("Kodi modules not available - using console notification adapter") + return 'console' + + @classmethod + def _create_kodi_adapter(cls, http_manager=None) -> NotificationInterface: + """ + Create Kodi notification adapter + + Args: + http_manager: Optional HTTPManager for QR code download + + Returns: + KodiNotificationAdapter + """ + try: + from .kodi_notification_adapter import KodiNotificationAdapter + adapter = KodiNotificationAdapter(http_manager=http_manager) + logger.info("✓ Kodi notification adapter created") + return adapter + + except Exception as e: + logger.error(f"Failed to create Kodi adapter: {e}") + logger.warning("Falling back to console adapter") + return cls._create_console_adapter() + + @classmethod + def _create_console_adapter(cls) -> NotificationInterface: + """ + Create console notification adapter + + Returns: + ConsoleNotificationAdapter + """ + from .console_notification_adapter import ConsoleNotificationAdapter + adapter = ConsoleNotificationAdapter() + logger.info("✓ Console notification adapter created") + return adapter + + @classmethod + def reset_cache(cls): + """Reset cached adapter (useful for testing)""" + cls._cached_adapter = None + cls._environment_detected = None + logger.debug("Notification adapter cache reset") + + @classmethod + def get_current_environment(cls) -> Optional[str]: + """ + Get currently detected environment + + Returns: + str: 'kodi', 'console', or None if not yet detected + """ + return cls._environment_detected + + @classmethod + def is_kodi_available(cls) -> bool: + """ + Check if Kodi environment is available + + Returns: + bool: True if Kodi modules can be imported + """ + try: + import xbmcgui + return True + except ImportError: + return False \ No newline at end of file diff --git a/lib/streaming_providers/base/ui/notification_interface.py b/lib/streaming_providers/base/ui/notification_interface.py new file mode 100644 index 0000000..02bdd64 --- /dev/null +++ b/lib/streaming_providers/base/ui/notification_interface.py @@ -0,0 +1,151 @@ +# ============================================================================ +# FILE 2: streaming_providers/base/ui/notification_interface.py +# ============================================================================ +""" +Abstract notification interface for displaying authentication prompts +Supports multiple UI backends (Kodi, console, web, etc.) +""" +from abc import ABC, abstractmethod +from typing import Optional +from enum import Enum + + +class NotificationResult(Enum): + """Result of notification display""" + CONTINUE = "continue" # User wants to continue + CANCELLED = "cancelled" # User cancelled + TIMEOUT = "timeout" # Notification timed out + ERROR = "error" # Error displaying notification + + +class NotificationInterface(ABC): + """ + Abstract interface for displaying remote login notifications + + Implementations must handle: + - Displaying login information (code, QR, URL) + - Countdown updates during polling + - User cancellation + - Cleanup on completion + """ + + def __init__(self): + """Initialize notification interface""" + self._is_active = False + self._is_cancelled = False + + @abstractmethod + def show_remote_login( + self, + login_code: str, + qr_url: str, + expires_in: int, + interval: int = 10 + ) -> NotificationResult: + """ + Show remote login notification to user + + This method should: + 1. Display the login code and QR code/URL + 2. Show countdown timer + 3. Allow user cancellation + 4. Return when done or cancelled + + Args: + login_code: Short code user can type (e.g., "PY48E62Q") + qr_url: Full URL to QR code SVG (e.g., "https://wcps.t-online.de/caas/default/v1/remoteLogin/PY48E62Q") + expires_in: Total seconds until expiration + interval: Polling interval in seconds (for countdown updates) + + Returns: + NotificationResult indicating outcome + """ + pass + + @abstractmethod + def update_countdown(self, remaining_seconds: int) -> bool: + """ + Update countdown display + + Args: + remaining_seconds: Seconds remaining until expiration + + Returns: + bool: True to continue, False if user cancelled + """ + pass + + @abstractmethod + def close(self, success: bool = False, message: Optional[str] = None): + """ + Close/cleanup the notification + + Args: + success: Whether authentication succeeded + message: Optional message to display + """ + pass + + @abstractmethod + def is_cancelled(self) -> bool: + """ + Check if user has cancelled + + Returns: + bool: True if user cancelled + """ + pass + + def mark_cancelled(self): + """Mark notification as cancelled""" + self._is_cancelled = True + + @property + def is_active(self) -> bool: + """Check if notification is currently active""" + return self._is_active + + @property + def supports_qr_display(self) -> bool: + """ + Check if this notifier can display QR codes + + Returns: + bool: True if QR code display is supported + """ + return False + + @property + def supports_countdown(self) -> bool: + """ + Check if this notifier supports live countdown updates + + Returns: + bool: True if countdown updates are supported + """ + return False + + @property + def is_blocking(self) -> bool: + """ + Check if this notifier blocks the calling thread + + Returns: + bool: True if blocking, False if non-blocking + """ + return True + + def get_capabilities(self) -> dict: + """ + Get notifier capabilities + + Returns: + dict: Capability information + """ + return { + 'type': self.__class__.__name__, + 'supports_qr_display': self.supports_qr_display, + 'supports_countdown': self.supports_countdown, + 'is_blocking': self.is_blocking, + 'is_active': self.is_active + } diff --git a/lib/streaming_providers/providers/magenta2/__init__.py b/lib/streaming_providers/providers/magenta2/__init__.py new file mode 100644 index 0000000..719d4e6 --- /dev/null +++ b/lib/streaming_providers/providers/magenta2/__init__.py @@ -0,0 +1,45 @@ +# streaming_providers/providers/magenta2/__init__.py +from .provider import Magenta2Provider +from .models import Magenta2Channel, Magenta2PlaybackRestrictedException, DeviceLimitExceededException +from .auth import Magenta2Authenticator, Magenta2AuthToken, Magenta2Credentials +from .discovery import DiscoveryService +from .endpoint_manager import EndpointManager, EndpointCategory +from .config_models import BootstrapConfig, ManifestConfig, OpenIDConfig, ProviderConfig, MpxConfig, DrmConfig, \ + TvHubConfig +from .constants import ( + SUPPORTED_COUNTRIES, +) + +__all__ = [ + # Core provider + 'Magenta2Provider', + + # Models + 'Magenta2Channel', + 'Magenta2PlaybackRestrictedException', + 'DeviceLimitExceededException', + + # Authentication + 'Magenta2Authenticator', + 'Magenta2AuthToken', + 'Magenta2Credentials', + + # New discovery system + 'DiscoveryService', + 'EndpointManager', + 'EndpointCategory', + + # Configuration models + 'BootstrapConfig', + 'ManifestConfig', + 'OpenIDConfig', + 'ProviderConfig', + 'MpxConfig', + 'DrmConfig', + 'TvHubConfig', + + # Constants + 'SUPPORTED_COUNTRIES', +] + +__version__ = '2.0.0' # Major version bump for architectural changes \ No newline at end of file diff --git a/lib/streaming_providers/providers/magenta2/auth.py b/lib/streaming_providers/providers/magenta2/auth.py new file mode 100644 index 0000000..46c05a8 --- /dev/null +++ b/lib/streaming_providers/providers/magenta2/auth.py @@ -0,0 +1,1696 @@ +# streaming_providers/providers/magenta2/auth.py +# -*- coding: utf-8 -*- +import uuid +import time +import base64 +import json +from typing import Dict, Optional, Any +from dataclasses import dataclass, field + +from ...base.auth.base_oauth2_auth import BaseOAuth2Authenticator +from ...base.auth.base_auth import BaseAuthToken, TokenAuthLevel +from ...base.auth.credentials import ClientCredentials +from ...base.models.proxy_models import ProxyConfig +from ...base.utils.logger import logger + +# PHASE 2 & 3: Import new components +from .sam3_client import Sam3Client +from .sso_client import SsoClient +from .taa_client import TaaClient, TaaAuthResult + +from .constants import ( + SUPPORTED_COUNTRIES, + DEFAULT_COUNTRY, + DEFAULT_PLATFORM, + DEFAULT_REQUEST_TIMEOUT, + MAGENTA2_CLIENT_IDS, + MAGENTA2_OAUTH_SCOPE, + MAGENTA2_REDIRECT_URI, + MAGENTA2_FALLBACK_ENDPOINTS, + MAGENTA2_PLATFORMS, + IDM, + APPVERSION2, + TAA_REQUEST_TEMPLATE, + GRANT_TYPES, + SSO_USER_AGENT +) + + +@dataclass +class Magenta2Credentials(ClientCredentials): + """ + Magenta2-specific credentials for client credentials flow (TAA auth) + Note: Magenta2 uses public client OAuth flow - no client_secret required + """ + platform: str = DEFAULT_PLATFORM + country: str = DEFAULT_COUNTRY + device_id: Optional[str] = field(default=None) + + def __post_init__(self): + # Magenta2 doesn't use client_secret (public client flow) + if not hasattr(self, 'client_secret') or self.client_secret is None: + self.client_secret = "" # Empty string for public client + + # Set client_id from constant if not provided + if not self.client_id: + self.client_id = MAGENTA2_CLIENT_IDS.get(self.platform, MAGENTA2_CLIENT_IDS[DEFAULT_PLATFORM]) + + # Generate device ID if not provided + if not self.device_id: + self.device_id = str(uuid.uuid4()) + + def validate(self) -> bool: + """Validate Magenta2 credentials""" + if not self.client_id or not self.platform: + return False + if self.country not in SUPPORTED_COUNTRIES: + return False + return True + + def to_taa_payload(self, access_token: str, client_model: Optional[str] = None, + device_model: Optional[str] = None) -> Dict[str, Any]: + """Convert to TAA authentication payload""" + platform_config = MAGENTA2_PLATFORMS.get(self.platform, MAGENTA2_PLATFORMS[DEFAULT_PLATFORM]) + + # Use provided models or fallback to platform defaults + resolved_device_model = device_model or platform_config['device_name'] + resolved_client_model = client_model or f"ftv-{self.platform}" + + # Build keyValue string with client model if available + key_value_parts = [ + IDM, + APPVERSION2 + ] + + # Add client model if available + if resolved_client_model: + key_value_parts.append(f"ClientModelParams(id={resolved_client_model})") + + key_value_parts.extend([ + f"TokenChannelParams(id=Tv)", + f"TokenDeviceParams(id={self.device_id}, model={resolved_device_model}, os={platform_config['firmware']})", + "DE", + "telekom" + ]) + + key_value = "/".join(key_value_parts) + + # Start with template and populate fields + payload = TAA_REQUEST_TEMPLATE.copy() + payload.update({ + "keyValue": key_value, + "accessToken": access_token, + "device": { + "id": self.device_id, + "model": resolved_device_model, + "os": platform_config['firmware'] + } + }) + + # Add client model if available + if resolved_client_model: + payload["client"] = {"model": resolved_client_model} + + return payload + + @property + def credential_type(self) -> str: + return "magenta2_client_credentials" + + +@dataclass +class Magenta2UserCredentials(Magenta2Credentials): + """ + Magenta2 user credentials for complete authentication flow + Adds username/password support for SAM3 login + """ + username: str = "" + password: str = "" + + def has_user_credentials(self) -> bool: + """Check if username/password credentials are available""" + return bool(self.username and self.password) + + def validate_user_credentials(self) -> bool: + """Validate user credentials""" + return self.has_user_credentials() and len(self.username) > 0 and len(self.password) > 0 + + @property + def credential_type(self) -> str: + return "magenta2_user_credentials" + +@dataclass +class Magenta2AuthToken(BaseAuthToken): + """ + Magenta2-specific authentication token with TAA data and persona token composition + """ + refresh_token: Optional[str] = field(default="") + dc_cts_persona_token: Optional[str] = field(default=None) + persona_id: Optional[str] = field(default=None) + account_id: Optional[str] = field(default=None) + consumer_id: Optional[str] = field(default=None) + tv_account_id: Optional[str] = field(default=None) + account_token: Optional[str] = field(default=None) + account_uri: Optional[str] = field(default=None) + composed_persona_token: Optional[str] = field(default=None) + token_exp: Optional[int] = field(default=None) + sso_user_id: Optional[str] = field(default=None) + sso_display_name: Optional[str] = field(default=None) + + def to_dict(self) -> Dict[str, Any]: + """Convert token to dictionary""" + base_dict = { + 'access_token': self.access_token, + 'refresh_token': self.refresh_token or "", + 'token_type': self.token_type, + 'expires_in': self.expires_in, + 'issued_at': self.issued_at + } + + # Add Magenta2-specific fields + if self.dc_cts_persona_token: + base_dict['dc_cts_persona_token'] = self.dc_cts_persona_token + if self.persona_id: + base_dict['persona_id'] = self.persona_id + if self.account_id: + base_dict['account_id'] = self.account_id + if self.consumer_id: + base_dict['consumer_id'] = self.consumer_id + if self.tv_account_id: + base_dict['tv_account_id'] = self.tv_account_id + if self.account_token: + base_dict['account_token'] = self.account_token + if self.account_uri: + base_dict['account_uri'] = self.account_uri + if self.composed_persona_token: + base_dict['composed_persona_token'] = self.composed_persona_token + if self.token_exp: + base_dict['token_exp'] = self.token_exp + if self.sso_user_id: + base_dict['sso_user_id'] = self.sso_user_id + if self.sso_display_name: + base_dict['sso_display_name'] = self.sso_display_name + + return base_dict + + def compose_persona_token(self) -> Optional[str]: + """ + Compose final persona token from account URI and dc_cts_persona_token + This is the CRITICAL step matching the C++ implementation: + + C++: rawToken = accountUri + ":" + dc_cts_personaToken + personaToken = base64_encode(rawToken) + + Format: Base64(accountUri + ":" + dc_cts_personaToken) + Example: Base64("urn:theplatform:auth:root:mdeprod:abcd1234-5678-90ef") + """ + if not self.account_uri or not self.dc_cts_persona_token: + logger.warning( + f"Cannot compose persona token - " + f"account_uri: {bool(self.account_uri)}, " + f"dc_cts_persona_token: {bool(self.dc_cts_persona_token)}" + ) + return None + + try: + # Compose: accountUri + ":" + dc_cts_persona_token + raw_token = f"{self.account_uri}:{self.dc_cts_persona_token}" + + # Base64 encode + self.composed_persona_token = base64.b64encode( + raw_token.encode('utf-8') + ).decode('utf-8') + + logger.info("✓ Persona token composed successfully") + logger.debug(f"Account URI: {self.account_uri}") + logger.debug(f"Composed token preview: {self.composed_persona_token[:50]}...") + + return self.composed_persona_token + + except Exception as e: + logger.error(f"Failed to compose persona token: {e}") + return None + + def get_jwt_claims(self) -> Optional[Dict[str, Any]]: + """Extract JWT claims from access token for classification""" + try: + if not self.access_token: + return None + + parts = self.access_token.split('.') + if len(parts) != 3: + return None + + payload_b64 = parts[1] + padding = len(payload_b64) % 4 + if padding: + payload_b64 += '=' * (4 - padding) + + payload_json = base64.b64decode(payload_b64).decode('utf-8') + return json.loads(payload_json) + + except Exception as e: + logger.debug(f"Failed to extract JWT claims: {e}") + return None + + +class Magenta2AuthConfig: + """Configuration object for Magenta2 authentication""" + + def __init__(self, country: str, platform: str = DEFAULT_PLATFORM, + endpoints: Optional[Dict[str, str]] = None, + client_model: Optional[str] = None, + device_model: Optional[str] = None): + self.country = country + self.platform = platform + self.platform_config = MAGENTA2_PLATFORMS.get(platform, MAGENTA2_PLATFORMS[DEFAULT_PLATFORM]) + self.user_agent = self.platform_config['user_agent'] + self.timeout = 30 + self.endpoints = endpoints or {} + self.client_model = client_model + self.device_model = device_model + + def get_base_headers(self) -> Dict[str, str]: + """Get base headers for all requests""" + return { + 'User-Agent': self.user_agent, + 'Accept': 'application/json', + 'Content-Type': 'application/json' + } + + def get_oauth_headers(self) -> Dict[str, str]: + """Get headers for OAuth2 requests (use form encoding)""" + headers = self.get_base_headers() + headers['Content-Type'] = 'application/x-www-form-urlencoded' + return headers + + @staticmethod + def get_sso_headers(session_id: str = None, device_id: str = None) -> Dict[str, str]: + """Get headers for SSO requests""" + headers = { + 'User-Agent': SSO_USER_AGENT, + 'Content-Type': 'application/json', + 'origin': 'https://web2.magentatv.de', + 'referer': 'https://web2.magentatv.de/' + } + + if session_id: + headers['session-id'] = session_id + if device_id: + headers['device-id'] = device_id + + return headers + + def get_taa_headers(self, sam3_token: str) -> Dict[str, str]: + """Get headers for TAA requests""" + headers = self.get_base_headers() + headers['Authorization'] = f'Bearer {sam3_token}' + return headers + + +class Magenta2Authenticator(BaseOAuth2Authenticator): + """ + ENHANCED: Magenta2 authenticator with complete SAM3 + SSO + TAA flow + Now supports both user credentials and client credentials flows + """ + + def __init__(self, country: str = DEFAULT_COUNTRY, + platform: str = DEFAULT_PLATFORM, + settings_manager=None, + credentials=None, + config_dir: Optional[str] = None, + http_manager=None, + proxy_config: Optional[ProxyConfig] = None, + endpoints: Optional[Dict[str, str]] = None, + client_model: Optional[str] = None, + device_model: Optional[str] = None, + sam3_client_id: Optional[str] = None, + session_id: Optional[str] = None, + device_id: Optional[str] = None): + """ + Enhanced authenticator with complete authentication flow support + """ + if country not in SUPPORTED_COUNTRIES: + raise ValueError(f"Unsupported country: {country}. Must be one of: {SUPPORTED_COUNTRIES}") + + if http_manager is None: + raise ValueError("http_manager is required for Magenta2Authenticator") + + # Set country-specific attributes FIRST + self.country = country + self.platform = platform + + # Store http_manager reference + self._http_manager = http_manager + + # Store dynamically discovered endpoints + self._dynamic_endpoints = endpoints or {} + + # Store bootstrap parameters as instance attributes + self._client_model = client_model + self._device_model = device_model + self._sam3_client_id = sam3_client_id + + # Session and device management + self._session_id = session_id or str(uuid.uuid4()) + self._device_id = device_id or str(uuid.uuid4()) + + # NEW: Store MPX account info for persona token composition + self._mpx_account_pid: Optional[str] = None + self._device_token: Optional[str] = None + self._authorize_tokens_url: Optional[str] = None + + # NEW: SAM3 and SSO clients + self._sam3_client: Optional[Sam3Client] = None + self._sso_client: Optional[SsoClient] = None + self._openid_config: Optional[Dict[str, Any]] = None + + # Setup Magenta2-specific config with endpoints and parameters + self._config = Magenta2AuthConfig( + self.country, + self.platform, + self._dynamic_endpoints, + self._client_model, + self._device_model + ) + + # Extract and cache client_id (use SAM3 client ID from bootstrap if available) + self._client_id = self._sam3_client_id or MAGENTA2_CLIENT_IDS.get( + self.platform, + MAGENTA2_CLIENT_IDS[DEFAULT_PLATFORM] + ) + + # Initialize credentials if not provided + if credentials is None: + credentials = self.get_fallback_credentials() + + # Initialize SAM3 and SSO clients if we have the required info + self._initialize_sam3_sso_clients() + + self._taa_client: Optional[TaaClient] = None + self._initialize_taa_client() + + # Initialize parent + super().__init__( + provider_name='magenta2', + settings_manager=settings_manager, + credentials=credentials, + country=country, + config_dir=config_dir, + enable_kodi_integration=True, + http_manager=self._http_manager, + proxy_config=proxy_config + ) + + def _initialize_taa_client(self) -> None: + """Initialize TAA client""" + self._taa_client = TaaClient( + http_manager=self._http_manager, + platform=self.platform + ) + logger.debug("TAA client initialized") + + def _initialize_sam3_sso_clients(self) -> None: + """Initialize SAM3 and SSO clients with all endpoints""" + try: + # Initialize SSO client (always available) + self._sso_client = SsoClient( + http_manager=self._http_manager, + session_id=self._session_id, + device_id=self._device_id + ) + + # Initialize SAM3 client if we have client ID + if self._sam3_client_id: + # GET ALL ENDPOINTS + issuer_url = None + oauth_endpoint = None + line_auth_endpoint = self._authorize_tokens_url # From manifest + backchannel_start_url = None # NEW + qr_code_url_template = None # NEW + + if self._openid_config: + issuer_url = self._openid_config.get('issuer') + oauth_endpoint = self._openid_config.get('token_endpoint') + # NEW: Try to get backchannel from OpenID config + backchannel_start_url = self._openid_config.get('backchannel_authentication_endpoint') + + # NEW: Get QR code URL from dynamic endpoints + if 'login_qr_code' in self._dynamic_endpoints: + qr_code_url_template = self._dynamic_endpoints['login_qr_code'] + + self._sam3_client = Sam3Client( + http_manager=self._http_manager, + session_id=self._session_id, + device_id=self._device_id, + sam3_client_id=self._sam3_client_id, + issuer_url=issuer_url, + oauth_token_endpoint=oauth_endpoint, + line_auth_endpoint=line_auth_endpoint, + backchannel_start_url=backchannel_start_url, # NEW + qr_code_url_template=qr_code_url_template # NEW + ) + + logger.info( + f"SAM3 client initialized - " + f"Issuer: {bool(issuer_url)}, " + f"OAuth: {bool(oauth_endpoint)}, " + f"Line: {bool(line_auth_endpoint)}, " + f"Backchannel: {bool(backchannel_start_url)}, " + f"QR URL: {bool(qr_code_url_template)}" + ) + + except Exception as e: + logger.warning(f"Failed to initialize SAM3/SSO clients: {e}") + + def can_use_line_auth(self) -> bool: + """Check if line auth components are available""" + return ( + self._device_token is not None and + self._authorize_tokens_url is not None and + self._sam3_client is not None + ) + + def can_use_remote_login(self) -> bool: + """Check if remote login components are available""" + return ( + self._sam3_client is not None and + self._sam3_client.can_use_remote_login() + ) + + def set_mpx_account_pid(self, account_pid: str): + """ + Set MPX account PID for account URI construction + This is CRITICAL for persona token composition + + Args: + account_pid: MPX account PID (e.g., 'mdeprod') + """ + self._mpx_account_pid = account_pid + logger.debug(f"MPX account PID set: {account_pid}") + + def set_remote_login_urls(self, qr_code_url_template: str, backchannel_start_url: str = None): + """ + Set remote login URLs for backchannel authentication + + Args: + qr_code_url_template: QR code URL template with {code} placeholder + backchannel_start_url: Optional backchannel start endpoint (from OpenID) + """ + if self._sam3_client: + self._sam3_client.qr_code_url_template = qr_code_url_template + if backchannel_start_url: + self._sam3_client.backchannel_start_url = backchannel_start_url + logger.info(f"✓ Remote login URLs configured for SAM3 client") + else: + logger.warning("Cannot set remote login URLs - SAM3 client not initialized") + + # PHASE 4: Enhanced device token management + def set_device_token(self, device_token: str, authorize_tokens_url: str = None): + """ + Enhanced device token setup with both endpoints + """ + self._device_token = device_token + self._authorize_tokens_url = authorize_tokens_url + + # UPDATE SAM3 CLIENT WITH LINE AUTH ENDPOINT + if self._sam3_client and authorize_tokens_url: + self._sam3_client.line_auth_endpoint = authorize_tokens_url + self._sam3_client.token_endpoint = authorize_tokens_url # Backwards compat + logger.info(f"✓ Updated SAM3 client with line auth endpoint: {authorize_tokens_url}") + + logger.debug("Device token configured with line authentication support") + + def perform_device_authentication(self) -> bool: + """ + PHASE 4: Perform device-based authentication using device token + This can be called independently for device registration flows + """ + return self._perform_line_auth() + + def validate_taa_token(self, taa_token: str) -> bool: + """ + PHASE 4: Validate TAA token using TaaClient + """ + if not self._taa_client: + return False + return self._taa_client.validate_taa_token(taa_token) + + def debug_taa_token(self, taa_token: str) -> Dict[str, Any]: + """ + PHASE 4: Debug TAA token using TaaClient + """ + if not self._taa_client: + return {'error': 'TAA client not initialized'} + return self._taa_client.debug_taa_token(taa_token) + + def get_authentication_flow_info(self) -> Dict[str, Any]: + """ + Enhanced with TAA client info + """ + base_info = { + 'user_credentials_available': isinstance(self.credentials, Magenta2UserCredentials) and self.credentials.has_user_credentials(), + 'client_credentials_available': True, + 'sam3_client_available': self._sam3_client is not None, + 'sso_client_available': self._sso_client is not None, + 'taa_client_available': self._taa_client is not None, + 'device_token_available': bool(self._device_token), + 'mpx_account_pid_available': bool(self._mpx_account_pid), + 'preferred_flow': 'USER' if (isinstance(self.credentials, Magenta2UserCredentials) and self.credentials.has_user_credentials()) else 'CLIENT' + } + + # Add TAA-specific info if available + if self._taa_client and self._current_token: + base_info['taa_token_valid'] = self.validate_taa_token(self._current_token.access_token) + + return base_info + + def set_openid_config(self, openid_config: Dict[str, Any]): + """ + Set OpenID configuration for SAM3 client + + Args: + openid_config: OpenID configuration dictionary + """ + self._openid_config = openid_config + if self._sam3_client: + self._sam3_client.update_endpoints(openid_config) + logger.debug("OpenID configuration updated") + + def get_current_token(self) -> Optional[BaseAuthToken]: + """Get the current authentication token""" + return self._current_token + + def _get_endpoint(self, endpoint_key: str, fallback_key: str = None) -> str: + """ + Get endpoint URL, preferring dynamically discovered ones + + Args: + endpoint_key: Key in dynamic endpoints dict + fallback_key: Key in MAGENTA2_FALLBACK_ENDPOINTS if dynamic lookup fails + """ + # Try dynamic endpoint first + if endpoint_key in self._dynamic_endpoints: + url = self._dynamic_endpoints[endpoint_key] + logger.debug(f"Using dynamic endpoint for {endpoint_key}: {url}") + return url + + # Fall back to hardcoded if available + if fallback_key and fallback_key in MAGENTA2_FALLBACK_ENDPOINTS: + url = MAGENTA2_FALLBACK_ENDPOINTS[fallback_key] + logger.debug(f"Using fallback endpoint for {endpoint_key}: {url}") + return url + + raise ValueError(f"No endpoint found for {endpoint_key}") + + @property + def oauth_client_id(self) -> str: + """Get OAuth2 client ID""" + return self._client_id + + @property + def oauth_scope(self) -> str: + """OAuth2 scopes""" + return MAGENTA2_OAUTH_SCOPE + + @property + def oauth_redirect_uri(self) -> str: + """OAuth2 redirect URI""" + return MAGENTA2_REDIRECT_URI + + @property + def auth_endpoint(self) -> str: + """Primary authentication endpoint - TAA flow""" + return self._get_endpoint('taa_auth', 'TAA_AUTH') + + def _get_auth_headers(self) -> Dict[str, str]: + """Get headers for authentication request""" + return self._config.get_base_headers() + + def _build_auth_payload(self) -> Dict[str, Any]: + """Build authentication payload for client credentials flow""" + if not self.credentials: + self.credentials = self.get_fallback_credentials() + + # For initial SAM3 client credentials auth + return { + 'client_id': self.credentials.client_id, + 'grant_type': GRANT_TYPES['CLIENT_CREDENTIALS'], + 'scope': MAGENTA2_OAUTH_SCOPE + } + + def _create_token_from_response(self, response_data: Dict[str, Any]) -> BaseAuthToken: + """ + ENHANCED: Create token object from API response and compose persona token + This is where the final persona token composition happens + """ + # Handle different response key formats + access_token = response_data.get('access_token', response_data.get('accessToken')) + if not access_token: + raise ValueError("No access token in response") + + # Create token with ALL fields + token = Magenta2AuthToken( + access_token=access_token, + refresh_token=response_data.get('refresh_token', response_data.get('refreshToken', '')), + token_type=response_data.get('token_type', response_data.get('tokenType', 'Bearer')), + expires_in=response_data.get('expires_in', response_data.get('expiresIn', 3600)), + issued_at=response_data.get('issued_at', response_data.get('issuedAt', time.time())), + + # Magenta2-specific fields from JWT + dc_cts_persona_token=response_data.get('dc_cts_persona_token'), + persona_id=response_data.get('persona_id'), + account_id=response_data.get('account_id'), + consumer_id=response_data.get('consumer_id'), + tv_account_id=response_data.get('tv_account_id'), + account_token=response_data.get('account_token'), + account_uri=response_data.get('account_uri'), + token_exp=response_data.get('token_exp'), + + # SSO fields if available + sso_user_id=response_data.get('sso_user_id'), + sso_display_name=response_data.get('sso_display_name') + ) + + # CRITICAL: Compose final persona token + if token.dc_cts_persona_token and token.account_uri: + composed = token.compose_persona_token() + if composed: + logger.info("✓ Persona token successfully composed") + else: + logger.error("✗ Failed to compose persona token!") + else: + logger.warning( + f"Cannot compose persona token - " + f"dc_cts_persona_token: {bool(token.dc_cts_persona_token)}, " + f"account_uri: {bool(token.account_uri)}" + ) + + # Try to construct account_uri from MPX account PID if available + if token.dc_cts_persona_token and self._mpx_account_pid: + logger.info(f"Attempting to construct account_uri from MPX account PID: {self._mpx_account_pid}") + token.account_uri = f"urn:theplatform:auth:root:{self._mpx_account_pid}" + composed = token.compose_persona_token() + if composed: + logger.info("✓ Persona token composed using constructed account_uri") + + # NEW: Only classify token if it's NOT from line_auth + if not response_data.get('auth_source') == 'line_auth': + token.auth_level = self._classify_token(token) + logger.debug(f"Token created and classified as: {token.auth_level.value}") + else: + token.auth_level = TokenAuthLevel.UNKNOWN + logger.debug("Line auth token - skipping classification") + + # NEW: Save ONLY the access token data under 'tvhubs' scope + scoped_token_data = { + 'access_token': token.access_token, + 'token_type': token.token_type, + 'expires_in': token.expires_in, + 'issued_at': token.issued_at + } + + # Save scoped token (access_token under 'tvhubs' scope) + self.settings_manager.save_scoped_token( + self.provider_name, + 'tvhubs', + scoped_token_data, + self.country + ) + + # NEW: Clear the main provider-level token data (no backward compatibility) + # Only keep refresh_token and device_id + provider_session_data = { + 'refresh_token': token.refresh_token, + 'device_id': getattr(self, '_device_id', '') + } + + # Save provider session data without access_token and without persona fields + self.settings_manager.save_session( + self.provider_name, + provider_session_data, + self.country + ) + + logger.info( + "✓ Access token saved under 'tvhubs' scope, only refresh_token and device_id saved at provider level") + + return token + + def _create_token_from_combined_data(self, sso_data: Dict[str, Any], taa_data: Dict[str, Any]) -> BaseAuthToken: + """ + NEW: Create token from combined SSO and TAA data for complete user flow + """ + # Start with TAA data as base + token_data = taa_data.copy() + + # Enhance with SSO data + token_data.update({ + 'sso_user_id': sso_data.get('userId'), + 'sso_display_name': sso_data.get('displayName'), + # Use SSO persona token if TAA doesn't provide one + 'dc_cts_persona_token': taa_data.get('dc_cts_persona_token') or sso_data.get('personaToken') + }) + + return self._create_token_from_response(token_data) + + def get_fallback_credentials(self) -> Magenta2Credentials: + """Get fallback credentials when no user credentials are available""" + return Magenta2Credentials( + client_id=self._client_id, + platform=self.platform, + country=self.country + ) + + def _perform_authentication(self) -> BaseAuthToken: + """ + ENHANCED: Perform Magenta2 authentication with line auth priority + """ + # NEW: Try line auth first if we have the required components + if self._should_try_line_auth_first(): + logger.info("Attempting line authentication first (device token available)") + try: + return self._perform_line_auth_flow() + except Exception as e: + logger.warning(f"Line auth failed, falling back to standard flow: {e}") + + # Continue with existing logic + if isinstance(self.credentials, Magenta2UserCredentials) and self.credentials.has_user_credentials(): + logger.info(f"Using complete user authentication flow for {self.provider_name}") + return self._perform_user_authentication_flow() + else: + logger.info(f"Using client credentials TAA flow for {self.provider_name}") + return self._perform_taa_flow() + + def _should_try_line_auth_first(self) -> bool: + """Check if we should attempt line auth first""" + return ( + self._device_token is not None and + self._authorize_tokens_url is not None and + self._sam3_client is not None + ) + + def _perform_line_auth_flow(self) -> BaseAuthToken: + """ + Complete authentication flow starting with line auth with automatic remote login fallback + """ + logger.debug("Starting line authentication flow with remote login fallback") + + # Step 1: Try line authentication first + try: + line_response_data = self._perform_line_auth_with_response() + if line_response_data: + logger.info("✓ Line auth succeeded") + return self._process_line_auth_success(line_response_data) + except Exception as e: + logger.warning(f"Line auth failed: {e}") + + # Step 2: Line auth failed, try remote login fallback + logger.info("Line auth failed, attempting remote login fallback") + + if not self._sam3_client or not self._sam3_client.can_use_remote_login(): + logger.error("Remote login not available as fallback") + raise Exception("Line auth failed and remote login not available") + + try: + # Perform remote login (notifier handled internally) + remote_token_data = self._sam3_client.remote_login( + scope="tvhubs offline_access" + ) + + if not remote_token_data: + raise Exception("Remote login failed or timed out") + + logger.info("✓ Remote login fallback successful") + + # Process remote login token same as line auth + return self._process_line_auth_success(remote_token_data) + + except Exception as e: + logger.error(f"Remote login fallback failed: {e}") + raise Exception(f"Both line auth and remote login failed: {e}") + + def _process_line_auth_success(self, line_response_data: Dict[str, Any]) -> BaseAuthToken: + """ + Process successful line auth or remote login response + (Extracted from original _perform_line_auth_flow) + """ + # Check if we have SettingsManager with scoped token support + if not hasattr(self.settings_manager, 'save_scoped_token'): + logger.warning("SettingsManager doesn't support scoped tokens, falling back to TAA") + raise Exception("Scoped token support not available") + + # Save the TVHUBS token + tvhubs_token_data = { + 'access_token': line_response_data.get('access_token'), + 'token_type': line_response_data.get('token_type', 'Bearer'), + 'expires_in': line_response_data.get('expires_in', 7200), + 'issued_at': time.time() + } + + success = self.settings_manager.save_scoped_token( + self.provider_name, + 'tvhubs', + tvhubs_token_data, + self.country + ) + + if not success: + logger.error("Failed to save TVHUBS scoped token") + raise Exception("Failed to save TVHUBS token") + + logger.info("✓ TVHUBS access token saved from authentication") + + # Extract refresh token for token exchange + line_refresh_token = line_response_data.get('refresh_token') + if not line_refresh_token: + logger.warning("No refresh token in response, using access token directly") + return self._create_token_from_line_auth_response(line_response_data) + + # Try token exchange for TAA scope + try: + taa_token_data = self._exchange_refresh_token_for_taa_scope(line_refresh_token) + if taa_token_data: + # Save TAA token under taa scope + self.settings_manager.save_scoped_token( + self.provider_name, + 'taa', + taa_token_data, + self.country + ) + logger.info("✓ TAA access token saved from token exchange") + + # Save the NEW refresh token at provider level + provider_session_data = { + 'refresh_token': taa_token_data['refresh_token'], + 'device_id': getattr(self, '_device_id', '') + } + + self.settings_manager.save_session( + self.provider_name, + provider_session_data, + self.country + ) + logger.info("✓ Updated refresh token saved at provider level") + + # Create token object with TAA token + token = Magenta2AuthToken( + access_token=taa_token_data['access_token'], + refresh_token=taa_token_data['refresh_token'], + token_type=taa_token_data['token_type'], + expires_in=taa_token_data['expires_in'], + issued_at=taa_token_data['issued_at'], + auth_level=TokenAuthLevel.UNKNOWN + ) + else: + logger.warning("Token exchange failed, using line auth token directly") + token = self._create_token_from_line_auth_response(line_response_data) + + except Exception as e: + logger.warning(f"Token exchange failed: {e}, using line auth token directly") + token = self._create_token_from_line_auth_response(line_response_data) + + logger.info("✓ Authentication flow completed successfully") + return token + + def _exchange_refresh_token_for_taa_scope(self, refresh_token: str) -> Optional[Dict[str, Any]]: + """ + Exchange line auth refresh token for TAA-scoped tokens + """ + try: + logger.debug("Exchanging refresh token for TAA scope") + + # Get token endpoint and client ID + token_endpoint = self._get_endpoint('oauth_token', 'OPENID_CONFIG') + if token_endpoint.endswith('/.well-known/openid-configuration'): + token_endpoint = token_endpoint.replace('/.well-known/openid-configuration', '/oauth2/tokens') + + client_id = self._sam3_client_id or self.oauth_client_id + if not client_id: + raise Exception("No client ID available for token exchange") + + # Build form-encoded payload + payload = { + 'grant_type': GRANT_TYPES['REFRESH_TOKEN'], + 'refresh_token': refresh_token, + 'client_id': client_id, + 'scope': 'taa offline_access' + } + + headers = { + 'Content-Type': 'application/x-www-form-urlencoded', + 'User-Agent': self._config.user_agent + } + + logger.debug(f"Token exchange request to: {token_endpoint}") + logger.debug(f"Client ID: {client_id}") + + # Perform token exchange + response = self.http_manager.post( + token_endpoint, + operation='token_exchange', + headers=headers, + data=payload, + timeout=DEFAULT_REQUEST_TIMEOUT + ) + response.raise_for_status() + + exchange_data = response.json() + + # Extract token data from response + taa_token_data = { + 'access_token': exchange_data.get('access_token'), + 'refresh_token': exchange_data.get('refresh_token', ''), + 'token_type': exchange_data.get('token_type', 'Bearer'), + 'expires_in': exchange_data.get('expires_in', 3600), + 'issued_at': time.time() + } + + # Validate required fields + if not taa_token_data['access_token']: + raise Exception("No access token in token exchange response") + + logger.info( + f"✓ Token exchange successful: " + f"type={taa_token_data['token_type']}, " + f"expires_in={taa_token_data['expires_in']}" + ) + + return taa_token_data + + except Exception as e: + logger.error(f"Token exchange for TAA scope failed: {e}") + return None + + def _perform_line_auth_with_response(self) -> Optional[Dict[str, Any]]: + """ + Perform line authentication and store refresh token for SAM3 requests + """ + try: + if not self._sam3_client: + raise Exception("SAM3 client not initialized") + + # Perform line auth + line_success = self._sam3_client.line_auth(self._device_token) + if not line_success: + return None + + # Get the response data + response_data = self._sam3_client.get_last_line_auth_response() + + # CRITICAL: Store refresh token for SAM3 token requests + if response_data and 'refresh_token' in response_data: + self._line_auth_refresh_token = response_data['refresh_token'] + logger.info(f"✓ Stored refresh token from line auth for SAM3 requests") + logger.debug(f"Refresh token preview: {self._line_auth_refresh_token[:20]}...") + + return response_data + + except Exception as e: + logger.error(f"Line authentication failed: {e}") + return None + + @staticmethod + def _are_line_auth_tokens_sufficient(line_response_data: Dict[str, Any]) -> bool: + """ + Check if line auth tokens provide enough access for our needs + """ + if not line_response_data: + return False + + # Check if we have a valid access token + access_token = line_response_data.get('access_token') + if not access_token: + return False + + # Check if token has reasonable expiration + expires_in = line_response_data.get('expires_in', 0) + if expires_in < 300: # Less than 5 minutes + logger.warning("Line auth token expires too soon, continuing to TAA") + return False + + # Optional: Check token type + token_type = line_response_data.get('token_type', '').lower() + if token_type != 'bearer': + logger.warning(f"Line auth token type '{token_type}' not supported, continuing to TAA") + return False + + return True + + def _create_token_from_line_auth_response(self, line_response_data: Dict[str, Any]) -> BaseAuthToken: + """ + Create authentication token from actual line auth response data + """ + # Use ACTUAL values from the response, not hardcoded ones + token_data = { + 'access_token': line_response_data.get('access_token'), + 'refresh_token': line_response_data.get('refresh_token', ''), + 'token_type': line_response_data.get('token_type', 'Bearer'), # FROM RESPONSE + 'expires_in': line_response_data.get('expires_in', 7200), # FROM RESPONSE + 'issued_at': time.time(), + 'auth_source': 'line_auth' # NEW: Mark as line_auth to skip classification + } + + # Validate required fields + if not token_data['access_token']: + raise Exception("No access token in line auth response") + + logger.info( + f"✓ Created token from line auth: type={token_data['token_type']}, expires_in={token_data['expires_in']}") + + # NEW: This will automatically save the access token under 'tvhubs' scope + # and skip classification due to auth_source='line_auth' + return self._create_token_from_response(token_data) + + def _continue_to_taa_after_line_auth(self) -> BaseAuthToken: + """ + Continue with TAA flow when line auth tokens are insufficient + """ + logger.debug("Continuing to TAA authentication after line auth") + + # Step 1: Get SAM3 token for TAA using established line auth session + if not self._sam3_client: + raise Exception("SAM3 client not initialized") + + sam3_token = self._sam3_client.get_access_token("taa") + if not sam3_token: + raise Exception("Could not obtain SAM3 token after line auth") + + # Step 2: Perform TAA authentication + if not self._taa_client: + raise Exception("TAA client not initialized") + + taa_result = self._taa_client.authenticate( + sam3_token=sam3_token, + device_id=self._device_id, + client_model=self._client_model, + device_model=self._device_model, + taa_endpoint=self._get_endpoint('taa_auth', 'TAA_AUTH') + ) + + if taa_result.device_limit_exceeded: + from .models import DeviceLimitExceededException + raise DeviceLimitExceededException("Device limit exceeded for Magenta2") + + # Step 3: Create final token from TAA + token = self._create_token_from_taa_result(taa_result) + logger.info("✓ TAA authentication completed successfully after line auth") + return token + + def _get_user_credentials(self) -> tuple[str, str]: + """ + Safely get username and password from credentials + Raises exception if user credentials are not available + """ + if not isinstance(self.credentials, Magenta2UserCredentials): + raise Exception("User credentials not available") + + if not self.credentials.has_user_credentials(): + raise Exception("Username/password not provided") + + return self.credentials.username, self.credentials.password + + def _perform_user_authentication_flow(self) -> BaseAuthToken: + """ + COMPLETE USER FLOW: SAM3 → SSO → TAA + """ + try: + logger.debug("Starting complete Magenta2 user authentication flow") + + # Step 1: SAM3 login with username/password + if not self._sam3_client: + raise Exception("SAM3 client not initialized") + + # FIXED: Use safe credential access + username, password = self._get_user_credentials() + + sam3_result = self._sam3_client.sam3_login(username, password) + + # Step 2: SSO authentication with code/state + if not self._sso_client: + raise Exception("SSO client not initialized") + + sso_result = self._sso_client.sso_authenticate( + code=sam3_result['code'], + state=sam3_result['state'] + ) + + # Step 3: Get SAM3 access token for TAA scope + sam3_taa_token = self._get_sam3_token_for_taa() + + # Step 4: Perform TAA authentication with SAM3 token + taa_result = self._perform_taa_authentication(sam3_taa_token) + + # Step 5: Create final token with combined data + token = self._create_token_from_combined_data(sso_result, taa_result) + + logger.info("✓ Complete user authentication flow successful") + return token + + except Exception as e: + logger.error(f"User authentication flow failed: {e}") + # Fall back to TAA-only flow if user auth fails + logger.warning("Falling back to TAA-only authentication") + return self._perform_taa_flow() + + def _perform_taa_flow(self) -> BaseAuthToken: + """ + Perform TAA authentication flow using dedicated TaaClient + """ + try: + logger.debug("Starting Magenta2 TAA authentication flow") + + # Step 1: Get SAM3 access token for TAA + sam3_token = self._get_sam3_token() + if not sam3_token: + raise Exception("Could not obtain SAM3 token for TAA") + + # Step 2: Perform TAA authentication using TaaClient + if not self._taa_client: + raise Exception("TAA client not initialized") + + taa_result = self._taa_client.authenticate( + sam3_token=sam3_token, + device_id=self._device_id, + client_model=self._client_model, + device_model=self._device_model, + taa_endpoint=self._get_endpoint('taa_auth', 'TAA_AUTH') + ) + + # Check for device limit + if taa_result.device_limit_exceeded: + from .models import DeviceLimitExceededException + raise DeviceLimitExceededException("Device limit exceeded for Magenta2") + + # Step 3: Convert TaaAuthResult to BaseAuthToken + token = self._create_token_from_taa_result(taa_result) + return token + + except Exception as e: + logger.error(f"TAA authentication flow failed: {e}") + raise Exception(f"TAA authentication failed: {e}") + + def _create_token_from_taa_result(self, taa_result: TaaAuthResult) -> BaseAuthToken: + """ + Create authentication token from TaaAuthResult + """ + token_data = { + 'access_token': taa_result.access_token, + 'refresh_token': taa_result.refresh_token or "", + 'token_type': 'Bearer', + 'expires_in': 3600, # Default, will be overridden by JWT exp if available + 'issued_at': time.time(), + + # TAA-specific fields + 'dc_cts_persona_token': taa_result.dc_cts_persona_token, + 'persona_id': taa_result.persona_id, + 'account_id': taa_result.account_id, + 'consumer_id': taa_result.consumer_id, + 'tv_account_id': taa_result.tv_account_id, + 'account_token': taa_result.account_token, + 'account_uri': taa_result.account_uri, + 'token_exp': taa_result.token_exp + } + + # Add raw response if available + if taa_result.raw_response: + token_data['raw_response'] = taa_result.raw_response + + return self._create_token_from_response(token_data) + + def _perform_line_auth(self) -> bool: + """ + PHASE 4: Device token line authentication + Matching C++ Sam3Client::LineAuth() + """ + try: + if not self._device_token or not self._authorize_tokens_url: + logger.warning("Line auth skipped - missing device token or authorize URL") + return False + + if not self._sam3_client: + logger.warning("Line auth skipped - SAM3 client not available") + return False + + logger.debug("Performing line authentication with device token") + + # Use SAM3 client for line authentication + success = self._sam3_client.line_auth(self._device_token) + + if success: + logger.info("✓ Line authentication successful") + return True + else: + logger.warning("Line authentication failed") + return False + + except Exception as e: + logger.error(f"Line authentication failed: {e}") + return False + + def _get_sam3_token_for_taa(self) -> str: + """ + ENHANCED: Get SAM3 token specifically for TAA scope in user flow + Uses device token line authentication if available + """ + try: + # PHASE 4: Try line authentication first if device token is available + if self._device_token and self._perform_line_auth(): + logger.debug("Line authentication successful, getting TAA token") + # Now get token for TAA scope using the established session + if self._sam3_client: + taa_token = self._sam3_client.get_access_token("taa") + if taa_token: + return taa_token + + # Fall back to standard client credentials + return self._get_sam3_token() + + except Exception as e: + logger.warning(f"Enhanced SAM3 token acquisition failed: {e}") + return self._get_sam3_token() + + def _get_sam3_token(self) -> str: + """ + Get SAM3 access token using discovered endpoints + """ + try: + headers = { + 'Content-Type': 'application/x-www-form-urlencoded', + 'User-Agent': self._config.user_agent + } + + # Determine payload based on available tokens + if hasattr(self, '_line_auth_refresh_token') and self._line_auth_refresh_token: + payload = { + 'grant_type': 'refresh_token', + 'client_id': self._sam3_client_id, + 'refresh_token': self._line_auth_refresh_token, + 'scope': 'taa offline_access' + } + else: + payload = { + 'grant_type': 'client_credentials', + 'client_id': self._sam3_client_id, + 'scope': 'taa offline_access' + } + + # Use the existing endpoint discovery system + token_endpoint = self._get_endpoint('oauth_token', 'OPENID_CONFIG') + + # If we got the OpenID config URL, convert it to token endpoint + if token_endpoint.endswith('/.well-known/openid-configuration'): + token_endpoint = token_endpoint.replace('/.well-known/openid-configuration', '/oauth2/tokens') + logger.debug(f"Converted OpenID config URL to token endpoint: {token_endpoint}") + + logger.debug(f"SAM3 token request to: {token_endpoint}") + + # Form-encode the data + form_data = '&'.join([f"{k}={self._url_encode(str(v))}" for k, v in payload.items()]) + + response = self.http_manager.post( + token_endpoint, + operation='auth', + headers=headers, + data=form_data + ) + + if response.status_code >= 400: + logger.error(f"SAM3 token request failed: {response.status_code}") + logger.error(f"Response: {response.text}") + response.raise_for_status() + + token_data = response.json() + access_token = token_data.get('access_token') + + if not access_token: + raise ValueError("No access token in SAM3 response") + + logger.debug("SAM3 token obtained successfully") + return access_token + + except Exception as e: + logger.error(f"Failed to get SAM3 token: {e}") + raise Exception(f"SAM3 token request failed: {e}") + + @staticmethod + def _url_encode(value: str) -> str: + """URL encode a string""" + from urllib.parse import quote + return quote(value) + + def _perform_taa_authentication(self, sam3_token: str) -> Dict[str, Any]: + """ + LEGACY: Kept for backward compatibility, now uses TaaClient internally + """ + try: + if not self._taa_client: + raise Exception("TAA client not initialized") + + taa_result = self._taa_client.authenticate( + sam3_token=sam3_token, + device_id=self._device_id, + client_model=self._client_model, + device_model=self._device_model, + taa_endpoint=self.auth_endpoint + ) + + if taa_result.device_limit_exceeded: + from .models import DeviceLimitExceededException + raise DeviceLimitExceededException("Device limit exceeded for Magenta2") + + # Convert to legacy format + result = { + 'access_token': taa_result.access_token, + 'refresh_token': taa_result.refresh_token, + 'dc_cts_persona_token': taa_result.dc_cts_persona_token, + 'persona_id': taa_result.persona_id, + 'account_id': taa_result.account_id, + 'consumer_id': taa_result.consumer_id, + 'tv_account_id': taa_result.tv_account_id, + 'account_token': taa_result.account_token, + 'account_uri': taa_result.account_uri, + 'token_exp': taa_result.token_exp + } + + if taa_result.raw_response: + result.update(taa_result.raw_response) + + return result + + except Exception as e: + logger.error(f"TAA authentication failed: {e}") + raise + + def _parse_taa_jwt_complete(self, jwt_token: str) -> Dict[str, Any]: + """ + ENHANCED: Complete JWT parsing extracting ALL required fields + This is critical for persona token composition + """ + 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 found: {list(claims.keys())}") + + result = {} + + # Enhanced claim mappings - ALL fields from C++ implementation + claim_mappings = { + # Core persona token (most important!) + 'dc_cts_persona_token': [ + 'dc_cts_persona_token', + 'personaToken', + 'urn:telekom:ott:dc_cts_persona_token' + ], + + # Account URI (needed for composition!) + 'account_uri': [ + 'dc_cts_account_uri', + 'accountUri', + 'urn:telekom:ott:dc_cts_account_uri', + 'mpxAccountUri' + ], + + # IDs + 'persona_id': [ + 'dc_cts_personaId', + 'personaId', + 'urn:telekom:ott:dc_cts_personaId' + ], + 'account_id': [ + 'dc_cts_accountId', + 'accountId', + 'urn:telekom:ott:dc_cts_accountId' + ], + 'consumer_id': [ + 'dc_cts_consumerId', + 'consumerId', + 'urn:telekom:ott:dc_cts_consumerId' + ], + 'tv_account_id': [ + 'dc_tvAccountId', + 'tvAccountId', + 'urn:telekom:ott:dc_tvAccountId' + ], + + # Account token + 'account_token': [ + 'dc_cts_account_token', + 'accountToken', + 'urn:telekom:ott:dc_cts_account_token' + ], + } + + # Extract all claims + for target_key, source_keys in claim_mappings.items(): + for source_key in source_keys: + if source_key in claims: + result[target_key] = claims[source_key] + logger.debug(f"Extracted {target_key} from {source_key}") + break + + # Extract token expiration + if 'exp' in claims: + result['token_exp'] = claims['exp'] + logger.debug(f"Token expires at: {claims['exp']}") + + # CRITICAL CHECK: Verify we have the essential fields + if 'dc_cts_persona_token' not in result: + logger.error("CRITICAL: dc_cts_persona_token not found in JWT!") + logger.debug(f"Available claims: {list(claims.keys())}") + + if 'account_uri' not in result: + logger.warning("account_uri not found in JWT - will try to construct from MPX account PID") + + # Try to construct from MPX account PID if available + if self._mpx_account_pid: + result['account_uri'] = f"urn:theplatform:auth:root:{self._mpx_account_pid}" + logger.info(f"✓ Constructed account_uri from MPX PID: {result['account_uri']}") + elif 'mpxAccountPid' in claims: + result['account_uri'] = f"urn:theplatform:auth:root:{claims['mpxAccountPid']}" + logger.info(f"✓ Constructed account_uri from JWT mpxAccountPid: {result['account_uri']}") + + return result + + except Exception as e: + logger.error(f"Failed to parse TAA JWT completely: {e}") + return {} + + def _classify_token(self, token: BaseAuthToken) -> TokenAuthLevel: + """ + Classify Magenta2 token based on JWT claims and structure + """ + try: + if not token or not token.access_token: + return TokenAuthLevel.UNKNOWN + + claims = token.get_jwt_claims() if hasattr(token, 'get_jwt_claims') else None + if not claims: + # If we can't parse claims, check token attributes + if hasattr(token, 'dc_cts_persona_token') and token.dc_cts_persona_token: + logger.debug("Token classified as USER_AUTHENTICATED (persona token present)") + return TokenAuthLevel.USER_AUTHENTICATED + logger.debug("Token classified as CLIENT_CREDENTIALS (no claims, no persona token)") + return TokenAuthLevel.CLIENT_CREDENTIALS + + logger.debug(f"JWT claims for classification: {list(claims.keys())}") + + # Check for persona token presence - indicates user authentication + if hasattr(token, 'dc_cts_persona_token') and token.dc_cts_persona_token: + logger.debug("Token classified as USER_AUTHENTICATED (dc_cts_persona_token present)") + return TokenAuthLevel.USER_AUTHENTICATED + + # Check JWT claims for user identifiers + user_claim_keys = ['dc_cts_personaId', 'personaId', 'dc_cts_accountId', 'accountId', + 'dc_cts_consumerId', 'consumerId', 'dc_tvAccountId', 'tvAccountId'] + + for key in user_claim_keys: + if key in claims: + logger.debug(f"Token classified as USER_AUTHENTICATED (found {key} in JWT)") + return TokenAuthLevel.USER_AUTHENTICATED + + # Check for client credentials patterns + client_id = claims.get('client_id', claims.get('clientId', '')) + if client_id in MAGENTA2_CLIENT_IDS.values(): + logger.debug("Token classified as CLIENT_CREDENTIALS (known client ID)") + return TokenAuthLevel.CLIENT_CREDENTIALS + + # Default to client credentials for TAA flow + logger.debug("Token classified as CLIENT_CREDENTIALS (default for TAA)") + return TokenAuthLevel.CLIENT_CREDENTIALS + + except Exception as e: + logger.error(f"Error classifying token: {e}") + return TokenAuthLevel.UNKNOWN + + def _refresh_oauth_token(self) -> Optional[BaseAuthToken]: + """Magenta2 token refresh implementation using HTTP manager""" + if not self._current_token or not self._current_token.refresh_token: + logger.debug(f"No refresh token available for {self.provider_name}") + return None + + try: + logger.debug(f"Refreshing OAuth2 token for {self.provider_name}") + + headers = self._config.get_oauth_headers() + + payload = { + 'grant_type': GRANT_TYPES['REFRESH_TOKEN'], + 'refresh_token': self._current_token.refresh_token, + 'client_id': self.oauth_client_id + } + + # Use discovered token endpoint for refresh + token_endpoint = self._get_endpoint('oauth_token', 'OPENID_CONFIG') + if token_endpoint.endswith('/.well-known/openid-configuration'): + token_endpoint = token_endpoint.replace('/.well-known/openid-configuration', '/oauth2/tokens') + + # USE HTTP MANAGER for token refresh + response = self.http_manager.post( + token_endpoint, + operation='auth', + headers=headers, + data=payload + ) + + response.raise_for_status() + new_token_data = response.json() + refreshed_token = self._create_token_from_response(new_token_data) + logger.info(f"OAuth2 token refresh successful for {self.provider_name}") + return refreshed_token + + except Exception as e: + logger.warning(f"OAuth2 token refresh failed for {self.provider_name}: {e}") + return None + + def get_persona_token(self) -> Optional[str]: + """ + Get the composed persona token for API calls + Returns the Base64-encoded persona token or None + """ + if not self._current_token: + return None + + if isinstance(self._current_token, Magenta2AuthToken): + # Return the composed token if available + if self._current_token.composed_persona_token: + return self._current_token.composed_persona_token + + # Try to compose it now if we have the components + if self._current_token.dc_cts_persona_token and self._current_token.account_uri: + return self._current_token.compose_persona_token() + + return None + + # Backward compatibility + def is_authenticated(self) -> bool: + return self._current_token is not None and not self._current_token.is_expired + + def invalidate_token(self) -> None: + self._current_token = None + try: + self.settings_manager.clear_token(self.provider_name) + except Exception: + pass + + def debug_token_classification(self) -> Dict[str, Any]: + """Debug method to analyze current token classification""" + if not self._current_token: + return {'error': 'No current token'} + + claims = self._current_token.get_jwt_claims() if hasattr(self._current_token, 'get_jwt_claims') else {} + + return { + 'token_type': type(self._current_token).__name__, + 'auth_level': self._current_token.auth_level.value, + 'is_expired': self._current_token.is_expired, + 'has_refresh': bool(self._current_token.refresh_token), + 'has_persona_token': bool(getattr(self._current_token, 'dc_cts_persona_token', None)), + 'jwt_claims_available': bool(claims), + 'key_claims': { + 'client_id': claims.get('client_id', claims.get('clientId', 'MISSING')), + 'persona_id': claims.get('dc_cts_personaId', claims.get('personaId', 'MISSING')), + 'account_id': claims.get('dc_cts_accountId', claims.get('accountId', 'MISSING')), + } if claims else {}, + 'discovered_endpoints': list(self._dynamic_endpoints.keys()), + 'bootstrap_parameters': { + 'client_model': self._client_model, + 'device_model': self._device_model, + 'sam3_client_id': self._sam3_client_id + }, + 'clients_initialized': { + 'sam3': self._sam3_client is not None, + 'sso': self._sso_client is not None + } + } + + # Required abstract method from BaseOAuth2Authenticator + def _perform_oauth_authorization_code_flow(self, username: str, password: str) -> Dict[str, Any]: + """ + OAuth2 authorization code flow - now implemented via SAM3 + SSO + """ + try: + # Use our complete user authentication flow + token = self._perform_user_authentication_flow() + return token.to_dict() + except Exception as e: + logger.error(f"OAuth2 authorization code flow failed: {e}") + raise + + def get_authentication_capabilities(self) -> Dict[str, Any]: + """ + Public method to check authentication capabilities + """ + line_auth_available = self.can_use_line_auth() + remote_login_available = self.can_use_remote_login() # NEW + + return { + 'line_auth_available': line_auth_available, + 'remote_login_available': remote_login_available, # NEW + 'user_credentials_available': isinstance(self.credentials, + Magenta2UserCredentials) and self.credentials.has_user_credentials(), + 'client_credentials_available': True, + 'preferred_flow': 'LINE_AUTH' if line_auth_available else + 'REMOTE_LOGIN' if remote_login_available else # NEW + 'USER' if (isinstance(self.credentials, + Magenta2UserCredentials) and self.credentials.has_user_credentials()) else + 'CLIENT' + } + + def debug_authentication_state(self) -> Dict[str, Any]: + """ + Enhanced debug method to verify complete authentication state + """ + if not self._current_token: + return {'error': 'No current token'} + + token = self._current_token + + return { + 'has_access_token': bool(token.access_token), + 'has_dc_cts_persona_token': bool(getattr(token, 'dc_cts_persona_token', None)), + 'has_account_uri': bool(getattr(token, 'account_uri', None)), + 'has_composed_persona_token': bool(getattr(token, 'composed_persona_token', None)), + 'persona_token_preview': getattr(token, 'composed_persona_token', '')[:50] + '...' if getattr(token, 'composed_persona_token', None) else None, + 'account_uri': getattr(token, 'account_uri', None), + 'persona_id': getattr(token, 'persona_id', None), + 'account_id': getattr(token, 'account_id', None), + 'sso_user_id': getattr(token, 'sso_user_id', None), + 'sso_display_name': getattr(token, 'sso_display_name', None), + 'token_expires_at': getattr(token, 'token_exp', None), + 'is_expired': token.is_expired, + 'auth_level': token.auth_level.value, + 'flow_used': 'USER' if getattr(token, 'sso_user_id', None) else 'CLIENT' + } diff --git a/lib/streaming_providers/providers/magenta2/config_models.py b/lib/streaming_providers/providers/magenta2/config_models.py new file mode 100644 index 0000000..cae9b28 --- /dev/null +++ b/lib/streaming_providers/providers/magenta2/config_models.py @@ -0,0 +1,399 @@ +# streaming_providers/providers/magenta2/config_models.py +from dataclasses import dataclass, field +from typing import Dict, Optional, Any +from datetime import datetime + + +@dataclass +class BootstrapConfig: + """Configuration extracted from bootstrap response""" + client_model: str + device_model: str + sam3_client_id: Optional[str] = None + taa_url: Optional[str] = None + device_tokens_url: Optional[str] = None + line_auth_url: Optional[str] = None + remote_login_url: Optional[str] = None + openid_config_url: Optional[str] = None + account_base_url: Optional[str] = None + consumer_accounts_url: Optional[str] = None + raw_data: Dict[str, Any] = field(default_factory=dict) + + @classmethod + def from_api_response(cls, bootstrap_data: Dict[str, Any], platform: str) -> 'BootstrapConfig': + """Create BootstrapConfig from API response""" + base_settings = bootstrap_data.get('baseSettings', {}) + + return cls( + client_model=base_settings.get('clientModel', f"ftv-{platform}"), + device_model=base_settings.get('deviceModel', f"{platform.upper()}_FTV"), + sam3_client_id=base_settings.get('sam3ClientId'), + taa_url=base_settings.get('taaUrl'), + device_tokens_url=base_settings.get('deviceTokensUrl'), + line_auth_url=base_settings.get('lineAuthUrl'), + remote_login_url=base_settings.get('remoteLoginUrl'), + openid_config_url=base_settings.get('sam3Url'), + account_base_url=base_settings.get('accountBaseUrl'), + consumer_accounts_url=base_settings.get('consumerAccountsBaseUrl'), + raw_data=bootstrap_data + ) + + def update_from_manifest(self, manifest_config: 'ManifestConfig') -> None: + """Update bootstrap config with data from manifest""" + from ...base.utils.logger import logger + + # Get SAM3 client ID from manifest if not in bootstrap + if not self.sam3_client_id: + sam3_client_id = manifest_config.get_parameter_value('SAM3ClientId') + if sam3_client_id: + self.sam3_client_id = sam3_client_id + logger.info(f"✓ SAM3 Client ID from manifest: {sam3_client_id}") + + # Get TAA URL from manifest if not in bootstrap + if not self.taa_url: + taa_url = manifest_config.get_parameter_value('TAA-URL') + if taa_url: + self.taa_url = taa_url + logger.debug(f"TAA URL from manifest: {taa_url}") + + # Get Line Auth URL from manifest if not in bootstrap + if not self.line_auth_url: + line_auth_url = manifest_config.get_parameter_value('LineAuthURL') + if line_auth_url: + self.line_auth_url = line_auth_url + logger.debug(f"Line Auth URL from manifest: {line_auth_url}") + + # Get Remote Login URL from manifest if not in bootstrap + if not self.remote_login_url: + remote_login_url = manifest_config.get_parameter_value('RemoteLoginURL') + if remote_login_url: + self.remote_login_url = remote_login_url + logger.debug(f"Remote Login URL from manifest: {remote_login_url}") + +@dataclass +class MpxConfig: + """MPX (ThePlatform) configuration from manifest""" + account_pid: str + license_service_url: str + selector_service_url: str + user_profile_url: Optional[str] = None + bookmark_base_url: Optional[str] = None + pvr_base_url: Optional[str] = None + feeds: Dict[str, str] = field(default_factory=dict) + # ADD THIS: Channel stations feed + channel_stations_feed: Optional[str] = None + + @classmethod + def from_manifest_data(cls, manifest_data: Dict[str, Any]) -> 'MpxConfig': + """Create MpxConfig from manifest data""" + def get_param(key: str) -> Optional[str]: + """Helper to get value from parameters array""" + if 'settings' not in manifest_data: + return None + settings = manifest_data['settings'] + if 'parameters' not in settings: + return None + for param in settings['parameters']: + if param.get('key') == key: + value = param.get('value') + return value if value and value != 'unused' else None + return None + + mpx_data = manifest_data.get('mpx', {}) + + # Build feeds dict from parameters + feeds = {} + feed_keys = [ + 'mpxBasicUrlAllChannelSchedulesFeed', + 'mpxBasicUrlEntitledChannelsFeed', + 'mpxAllListingsFeedUrl', + 'mpxAllProgramsFeedUrl' + ] + + for feed_key in feed_keys: + feed_url = get_param(feed_key) + if feed_url: + simple_key = feed_key.replace('mpx', '').replace('Url', '').replace('BasicUrl', '') + feeds[simple_key] = feed_url + + # ADD THIS: Extract channel stations feed + channel_stations_feed = get_param('mpxDefaultUrlAllChannelStationsFeed') + + return cls( + account_pid=get_param('mpxAccountPid') or mpx_data.get('accountPid', 'mdeprod'), + license_service_url=get_param('mpxBasicUrlGetApplicableDistributionRights') or mpx_data.get( + 'licenseServiceUrl', ''), + selector_service_url=get_param('mpxBasicUrlSelectorService') or mpx_data.get('selectorServiceUrl', ''), + user_profile_url=get_param('mpxUserProfileUrl') or mpx_data.get('userProfileUrl'), + bookmark_base_url=get_param('mpxBookmarkBaseUrl') or mpx_data.get('bookmarkBaseUrl'), + pvr_base_url=get_param('mpxPvrBaseUrl') or mpx_data.get('pvrBaseUrl'), + feeds=feeds, + # ADD THIS: + channel_stations_feed=channel_stations_feed + ) + + def get_account_uri(self) -> str: + """ + NEW: Construct MPX account URI for persona token composition + Format: urn:theplatform:auth:root:{accountPid} + """ + return f"urn:theplatform:auth:root:{self.account_pid}" + + +@dataclass +class DrmConfig: + """DRM configuration from manifest""" + widevine_license_url: str + vod_widevine_license_url: Optional[str] = None + fairplay_license_url: Optional[str] = None + + @classmethod + def from_manifest_data(cls, manifest_data: Dict[str, Any]) -> 'DrmConfig': + """Create DrmConfig from manifest data""" + from ...base.utils.logger import logger + + # Create a temporary helper function for this method + def get_param(key: str) -> Optional[str]: + """Helper to get value from parameters array""" + if 'settings' not in manifest_data: + return None + settings = manifest_data['settings'] + if 'parameters' not in settings: + return None + for param in settings['parameters']: + if param.get('key') == key: + value = param.get('value') + return value if value and value != 'unused' else None + return None + + # Try parameters array first (current structure) + widevine_url = get_param('widevineLicenseAcquisitionURL') + fairplay_url = get_param('fairplayLicenseAcquisitionURL') + + # Fallback to legacy structure + if not widevine_url or not fairplay_url: + livetv_drm = manifest_data.get('livetv', {}).get('drm', {}) + vod_drm = manifest_data.get('vod', {}).get('drm', {}) + + if not widevine_url: + widevine_url = livetv_drm.get('widevineLicenseAcquisitionUrl', '') + if not fairplay_url: + fairplay_url = livetv_drm.get('fairplayLicenseAcquisitionUrl', '') + + vod_widevine = vod_drm.get('widevineLicenseAcquisitionUrl') + else: + vod_widevine = None # Not in parameters array + + logger.debug(f"DRM config: widevine={bool(widevine_url)}, fairplay={bool(fairplay_url)}") + + return cls( + widevine_license_url=widevine_url or '', + vod_widevine_license_url=vod_widevine, + fairplay_license_url=fairplay_url or '' + ) + +@dataclass +class TvHubConfig: + """TV Hub URLs configuration from manifest""" + base_urls: Dict[str, str] = field(default_factory=dict) + + @classmethod + def from_manifest_data(cls, manifest_data: Dict[str, Any]) -> 'TvHubConfig': + """Create TvHubConfig from manifest data""" + + # Create a temporary helper function for this method + def get_param(param_key: str) -> Optional[str]: + """Helper to get value from parameters array""" + if 'settings' not in manifest_data: + return None + settings_data = manifest_data['settings'] + if 'parameters' not in settings_data: + return None + for param in settings_data['parameters']: + if param.get('key') == param_key: + value = param.get('value') + return value if value and value != 'unused' else None + return None + + base_urls = {} + + # TV Hub URLs from parameters + tvhub_keys = [ + 'homeUrl', + 'settingsMenu', + 'broadcastDetailsURL', + 'vodDetailsURL', + 'searchUrl', + 'kidsSearchURL', + 'myWatchlistUrl', + 'myMoviesURL' + ] + + # Check settings object first + if 'settings' in manifest_data: + settings_obj = manifest_data['settings'] + for tvhub_key in tvhub_keys: + if tvhub_key in settings_obj: + url = settings_obj.get(tvhub_key) + if url and url != 'unused': + base_urls[tvhub_key] = url + + # Also check parameters array + for tvhub_key in tvhub_keys: + if tvhub_key not in base_urls: + url = get_param(tvhub_key) + if url: + base_urls[tvhub_key] = url + + # Legacy structure fallback + tv_hubs = manifest_data.get('tvHubUrls', {}) + for hub_name, hub_url in tv_hubs.items(): + if isinstance(hub_url, str) and hub_url.startswith('http') and hub_name not in base_urls: + base_urls[hub_name] = hub_url + + return cls(base_urls=base_urls) + +@dataclass +class ManifestConfig: + """Complete configuration from manifest discovery""" + mpx: MpxConfig + drm: DrmConfig + tv_hubs: TvHubConfig + youbora_config: Dict[str, Any] = field(default_factory=dict) + npvr_config: Dict[str, Any] = field(default_factory=dict) + raw_data: Dict[str, Any] = field(default_factory=dict) + + @classmethod + def from_api_response(cls, manifest_data: Dict[str, Any]) -> 'ManifestConfig': + """Create ManifestConfig from API response""" + return cls( + mpx=MpxConfig.from_manifest_data(manifest_data), + drm=DrmConfig.from_manifest_data(manifest_data), + tv_hubs=TvHubConfig.from_manifest_data(manifest_data), + youbora_config=manifest_data.get('youbora', {}), + npvr_config=manifest_data.get('npvr', {}), + raw_data=manifest_data + ) + + def get_parameter_value(self, key: str) -> Optional[str]: + """Get value from settings.parameters array by key""" + if 'settings' not in self.raw_data: + return None + + settings = self.raw_data['settings'] + if 'parameters' not in settings or not isinstance(settings['parameters'], list): + return None + + for param in settings['parameters']: + if param.get('key') == key: + value = param.get('value') + # Empty string or "unused" means not available + return value if value and value != 'unused' else None + + return None + + def get_device_token(self) -> Optional[str]: + """Extract device token from raw data""" + # Try direct path first (legacy) + if 'deviceToken' in self.raw_data: + return self.raw_data['deviceToken'] + + # Try nested in sts object (current structure) + if 'sts' in self.raw_data and isinstance(self.raw_data['sts'], dict): + if 'deviceToken' in self.raw_data['sts']: + return self.raw_data['sts']['deviceToken'] + + return None + + def get_authorize_tokens_url(self) -> Optional[str]: + """Extract authorize tokens URL from raw data""" + # Check in sts object first (current structure) + if 'sts' in self.raw_data and isinstance(self.raw_data['sts'], dict): + if 'authorizeTokensUrl' in self.raw_data['sts']: + return self.raw_data['sts']['authorizeTokensUrl'] + + # Check direct path (legacy) + if 'authorizeTokensUrl' in self.raw_data: + return self.raw_data['authorizeTokensUrl'] + + # Check in settings.parameters array as fallback + line_auth_url = self.get_parameter_value('LineAuthURL') + if line_auth_url: + return line_auth_url + + return None + +@dataclass +class OpenIDConfig: + """OpenID Connect configuration""" + token_endpoint: str + authorization_endpoint: str + userinfo_endpoint: Optional[str] = None + revocation_endpoint: Optional[str] = None + raw_data: Dict[str, Any] = field(default_factory=dict) + + @classmethod + def from_api_response(cls, openid_data: Dict[str, Any]) -> 'OpenIDConfig': + """Create OpenIDConfig from API response""" + return cls( + token_endpoint=openid_data.get('token_endpoint', ''), + authorization_endpoint=openid_data.get('authorization_endpoint', ''), + userinfo_endpoint=openid_data.get('userinfo_endpoint'), + revocation_endpoint=openid_data.get('revocation_endpoint'), + raw_data=openid_data + ) + + +@dataclass +class ProviderConfig: + """Complete provider configuration assembled from all discovery sources""" + bootstrap: BootstrapConfig + manifest: Optional[ManifestConfig] = None + openid: Optional[OpenIDConfig] = None + discovered_at: datetime = field(default_factory=datetime.now) + + @property + def is_complete(self) -> bool: + """Check if configuration is complete enough for operation""" + return self.bootstrap is not None and self.manifest is not None + + def get_resolved_feed_url(self, feed_name: str) -> Optional[str]: + """Get resolved MPX feed URL with account PID substitution""" + if not self.manifest or not self.manifest.mpx: + return None + + feed_template = self.manifest.mpx.feeds.get(feed_name) + if not feed_template: + return None + + return feed_template.replace('{MpxAccountPid}', self.manifest.mpx.account_pid) + + def get_resolved_tvhub_url(self, hub_name: str, client_model: Optional[str] = None) -> Optional[str]: + """Get resolved TV Hub URL with client model substitution""" + if not self.manifest: + return None + + hub_template = self.manifest.tv_hubs.base_urls.get(hub_name) + if not hub_template: + return None + + resolved_client_model = client_model or self.bootstrap.client_model + return hub_template.replace('{clientModel}', resolved_client_model) + + def get_mpx_account_uri(self) -> Optional[str]: + """NEW: Get MPX account URI for persona token composition""" + if not self.manifest or not self.manifest.mpx: + return None + return self.manifest.mpx.get_account_uri() + + def get_device_token(self) -> Optional[str]: + """NEW: Get device token from manifest""" + if not self.manifest: + return None + return self.manifest.get_device_token() + + def get_authorize_tokens_url(self) -> Optional[str]: + """NEW: Get authorize tokens URL from manifest""" + if not self.manifest: + return None + return self.manifest.get_authorize_tokens_url() \ No newline at end of file diff --git a/lib/streaming_providers/providers/magenta2/constants.py b/lib/streaming_providers/providers/magenta2/constants.py new file mode 100644 index 0000000..0d364c7 --- /dev/null +++ b/lib/streaming_providers/providers/magenta2/constants.py @@ -0,0 +1,263 @@ +# streaming_providers/providers/magenta2/constants.py +# ============================================================================ +# Magenta2 Configuration +# ============================================================================ + +# Supported countries (Magenta2 is Germany-specific) +SUPPORTED_COUNTRIES = ['de'] + +# Default country +DEFAULT_COUNTRY = 'de' + +# Platform configuration +DEFAULT_PLATFORM = 'android-tv' +MAGENTA2_PLATFORMS = { + 'web': { + 'device_name': 'Web Browser', + 'firmware': 'Chrome 120', + 'user_agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + 'terminal_type': 'WEB' + }, + 'android-tv': { + 'device_name': 'Android TV', + 'firmware': 'Android 11', + 'user_agent': 'Mozilla/5.0 (Linux; Android 11; Android TV) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36', + 'terminal_type': 'ATV_ANDROIDTV' + }, + 'atv-launcher': { + 'device_name': 'MagentaTV Stick', + 'firmware': 'Android 11', + 'user_agent': 'Mozilla/5.0 (Linux; Android 11; AFTS Build/PPR1.180610.011) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36', + 'terminal_type': 'ATV_LAUNCHER' + }, + 'android-mobile': { + 'device_name': 'Android Mobile', + 'firmware': 'Android 13', + 'user_agent': 'Mozilla/5.0 (Linux; Android 13; SM-G991B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Mobile Safari/537.36', + 'terminal_type': 'ANDROID_MOBILE' + }, + 'ios': { + 'device_name': 'iPhone', + 'firmware': 'iOS 15', + 'user_agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.0 Mobile/15E148 Safari/604.1', + 'terminal_type': 'IOS' + } +} + +# ============================================================================ +# Manifest Request Configuration +# ============================================================================ + +# App identification for manifest requests +MAGENTA2_APP_NAME = 'MagentaTV' +MAGENTA2_APP_VERSION = '104180' +MAGENTA2_RUNTIME_VERSION = '1' + +# Model names for manifest requests (different from device models!) +MANIFEST_MODEL_MAPPINGS = { + 'web': 'DT:WEB', + 'android-tv': 'DT:ATV-AndroidTV', + 'atv-launcher': 'DT:ATV-Launcher', + 'android-mobile': 'DT:Android-Mobile', + 'ios': 'DT:IOS' +} + +# Firmware strings for manifest requests +MANIFEST_FIRMWARE_MAPPINGS = { + 'web': 'Chrome 120', + 'android-tv': 'API level 30', + 'atv-launcher': 'API level 30', + 'android-mobile': 'API level 33', + 'ios': 'iOS 15.0' +} + +# Also update the fallback mappings: +CLIENT_MODEL_MAPPINGS = { + 'web': 'ftv-web', + 'android-tv': 'ftv-androidtv', + 'atv-launcher': 'ftv-androidtv', + 'android-mobile': 'ftv-android', + 'ios': 'ftv-ios' +} + +DEVICE_MODEL_MAPPINGS = { + 'web': 'WebBrowser_FTV', + 'android-tv': 'AndroidTV_FTV', + 'atv-launcher': 'AndroidTV_FTV', + 'android-mobile': 'AndroidMobile_FTV', + 'ios': 'iOS_FTV' +} + +# And update subscriber types if needed: +SUBSCRIBER_TYPES = { + 'web': 'WEB_OTT_DT', + 'android-tv': 'FTV_OTT_DT', + 'atv-launcher': 'FTV_OTT_DT', # Same as android-tv + 'android-mobile': 'MOB_OTT_DT', # Different for mobile + 'ios': 'IOS_OTT_DT' +} + +# ============================================================================ +# API Configuration - MINIMAL HARDCODING +# ============================================================================ + +# Only bootstrap endpoint is hardcoded - everything else discovered dynamically +MAGENTA2_BASE_URL = 'https://prod.dcm.telekom-dienste.de/v1' +MAGENTA2_BOOTSTRAP_URL = MAGENTA2_BASE_URL + '/settings/{terminal_type}/bootstrap' +MAGENTA2_MANIFEST_URL = MAGENTA2_BASE_URL + '/settings/{terminal_type}/manifest' + +# Fallback endpoints if discovery fails +MAGENTA2_FALLBACK_ENDPOINTS = { + 'OPENID_CONFIG': 'https://accounts.login.idm.telekom.com/.well-known/openid-configuration', + 'TAA_AUTH': 'https://taa.p7s1.io/api/v1/taa', + 'ENTITLEMENT': 'https://entitlement.p7s1.io/api/user/entitlement-token', +} + +# ============================================================================ +# Application Configuration +# ============================================================================ + +# Application identifiers +IDM = "TDGIDM" +APPVERSION2 = "3.134.4462" + +SSO_URL = "https://ssom.magentatv.de/login" +# SSO User Agent +SSO_USER_AGENT = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" + +# ============================================================================ +# OAuth2 Configuration +# ============================================================================ + +# Client IDs for different platforms +MAGENTA2_CLIENT_IDS = { + 'web': '709115c2-f87e-4bad-9b94-28ac08d72cd9', + 'android-tv': '05f5f3df-1130-4707-a761-c04d0c50b7f2', + 'ios': '21218403-52ec-4a65-abf4-f36a0eadd631' +} + +# OAuth2 scopes +MAGENTA2_OAUTH_SCOPE = "openid profile offline_access tvhubs" + +# OAuth2 redirect URI +MAGENTA2_REDIRECT_URI = "https://web2.magentatv.de/authn/idm" + +# ============================================================================ +# Request Headers Configuration +# ============================================================================ + +# Headers for different API endpoints +MAGENTA2_HEADERS = { + 'DEFAULT': { + 'Content-Type': 'application/json', + 'Accept': 'application/json' + }, + 'SSO': { + 'User-Agent': SSO_USER_AGENT, + 'Content-Type': 'application/json', + 'origin': 'https://web2.magentatv.de', + 'referer': 'https://web2.magentatv.de/' + }, + 'DCM': { + 'Content-Type': 'application/json', + 'Accept': 'application/json' + }, + 'OAUTH2': { + 'Content-Type': 'application/x-www-form-urlencoded' + } +} + +# ============================================================================ +# Authentication Configuration +# ============================================================================ + +# Grant types +GRANT_TYPES = { + 'LINE_AUTH': 'urn:com:telekom:ott-app-services:access-auth', + 'AUTH_CODE': 'authorization_code', + 'PASSWORD': 'password', + 'REFRESH_TOKEN': 'refresh_token', + 'REMOTE_LOGIN': 'urn:telekom:com:grant-type:remote-login', + 'CLIENT_CREDENTIALS': 'client_credentials' +} + +# ============================================================================ +# Content Configuration +# ============================================================================ + +# Content types +CONTENT_TYPE_LIVE = 'LIVE' +CONTENT_TYPE_VOD = 'VOD' + +# Stream modes +MODE_LIVE = 'live' +MODE_VOD = 'vod' + +# ============================================================================ +# DRM Configuration +# ============================================================================ + +# DRM system +DRM_SYSTEM_WIDEVINE = 'widevine' + +# DRM request headers +DRM_REQUEST_HEADERS = { + 'Content-Type': 'application/octet-stream' +} + +# ============================================================================ +# Request Configuration +# ============================================================================ + +# Default timeout for HTTP requests (seconds) +DEFAULT_REQUEST_TIMEOUT = 30 + +# Default maximum retries for failed requests +DEFAULT_MAX_RETRIES = 3 + +# Default time window for EPG queries (hours) +DEFAULT_EPG_WINDOW_HOURS = 3 + +# Cache durations (seconds) +BOOTSTRAP_CACHE_DURATION = 3600 # 1 hour +OPENID_CONFIG_CACHE_DURATION = 86400 # 24 hours +MANIFEST_CACHE_DURATION = 7200 # 2 hours + +# ============================================================================ +# Error Codes +# ============================================================================ + +# Known error codes from Magenta2 API +ERROR_CODES = { + 'DEVICE_LIMIT_EXCEEDED': 'deviceLimitExceeded', + 'PLAYBACK_RESTRICTED': 'ENT_RVOD_Playback_Restricted', + 'UNAUTHORIZED': 'ENT_Unauthorized', + 'INVALID_TOKEN': 'INVALID_TOKEN', + 'SESSION_EXPIRED': 'SESSION_EXPIRED' +} + +# ============================================================================ +# TAA Configuration +# ============================================================================ + +# TAA request template +TAA_REQUEST_TEMPLATE = { + "accessTokenSource": IDM, + "appVersion": APPVERSION2, + "channel": { + "id": "Tv" + }, + "natco": "DE", + "type": "telekom" +} + +# ============================================================================ +# Bootstrap Configuration +# ============================================================================ + +# Bootstrap parameters +BOOTSTRAP_PARAMS = { + '$redirect': 'false' +} + +# REMOVED: Old BOOTSTRAP_KEYS - now handled in config_models.py \ No newline at end of file diff --git a/lib/streaming_providers/providers/magenta2/discovery.py b/lib/streaming_providers/providers/magenta2/discovery.py new file mode 100644 index 0000000..cfbefe3 --- /dev/null +++ b/lib/streaming_providers/providers/magenta2/discovery.py @@ -0,0 +1,379 @@ +# streaming_providers/providers/magenta2/discovery.py +import time +from typing import Dict, Optional, Any + +from ...base.network import HTTPManager +from ...base.models.proxy_models import ProxyConfig +from ...base.utils.logger import logger +from .config_models import BootstrapConfig, ManifestConfig, OpenIDConfig, ProviderConfig +from .constants import ( + MAGENTA2_BOOTSTRAP_URL, + MAGENTA2_MANIFEST_URL, + SUBSCRIBER_TYPES, + DEFAULT_REQUEST_TIMEOUT, + BOOTSTRAP_CACHE_DURATION, + OPENID_CONFIG_CACHE_DURATION +) + + +class DiscoveryService: + """ + Service for dynamic discovery of Magenta2 endpoints and configuration + """ + + def __init__(self, platform: str, terminal_type: str, device_id: str, session_id: str, + http_manager: HTTPManager, proxy_config: Optional[ProxyConfig] = None): + self.platform = platform + self.terminal_type = terminal_type + self.device_id = device_id + self.session_id = session_id + self.http_manager = http_manager + self.proxy_config = proxy_config + self.subscriber_type = SUBSCRIBER_TYPES.get(platform, 'FTV_OTT_DT') + + # Cache storage + self._bootstrap_config: Optional[BootstrapConfig] = None + self._manifest_config: Optional[ManifestConfig] = None + self._openid_config: Optional[OpenIDConfig] = None + self._last_bootstrap: Optional[float] = None + self._last_manifest: Optional[float] = None + self._last_openid: Optional[float] = None + + def discover_provider_config(self, force_refresh: bool = False) -> ProviderConfig: + """ + Perform complete provider configuration discovery + + Returns: + ProviderConfig: Complete provider configuration + """ + logger.info("Starting Magenta2 provider configuration discovery") + + try: + # Step 1: Bootstrap discovery + bootstrap_config = self.discover_bootstrap(force_refresh) + if not bootstrap_config: + raise Exception("Bootstrap discovery failed - cannot proceed") + + # Step 2: Manifest discovery (includes device token) + manifest_config = self.discover_manifest(force_refresh) + + # IMPORTANT: Update bootstrap with manifest data + if manifest_config: + bootstrap_config.update_from_manifest(manifest_config) + + # Log device token status + device_token = manifest_config.get_device_token() + if device_token: + logger.info("✓ Device token obtained from manifest") + else: + logger.warning("⚠️ No device token found in manifest") + + # Step 3: OpenID discovery (if bootstrap provided OpenID config URL) + openid_config = None + if bootstrap_config.openid_config_url: + try: + openid_config = self.discover_openid_config(force_refresh) + except Exception as e: + logger.warning(f"OpenID discovery failed: {e}") + + provider_config = ProviderConfig( + bootstrap=bootstrap_config, + manifest=manifest_config, + openid=openid_config + ) + + # Log MPX account info for persona token composition + if manifest_config and manifest_config.mpx: + account_uri = manifest_config.mpx.get_account_uri() + logger.info(f"✓ MPX account URI for persona token: {account_uri}") + + return provider_config + + except Exception as e: + logger.error(f"Configuration discovery failed: {e}") + self._create_fallback_configuration() + raise + + def discover_bootstrap(self, force_refresh: bool = False) -> Optional[BootstrapConfig]: + """ + Discover bootstrap configuration + + Returns: + BootstrapConfig: Bootstrap configuration, None if failed + """ + # Check cache + if not force_refresh and self._bootstrap_config and self._last_bootstrap: + cache_age = time.time() - self._last_bootstrap + if cache_age < BOOTSTRAP_CACHE_DURATION: + logger.debug("Using cached bootstrap configuration") + return self._bootstrap_config + + try: + logger.info("Discovering bootstrap configuration") + + terminal_type = self.terminal_type.lower().replace('_', '-') + url = MAGENTA2_BOOTSTRAP_URL.format(terminal_type=terminal_type) + + params = { + 'deviceid': self.device_id, + 'sid': self.session_id, + '$redirect': 'false' + } + + headers = self._get_dcm_headers() + + response = self.http_manager.get( + url, + operation='bootstrap', + headers=headers, + params=params, + timeout=DEFAULT_REQUEST_TIMEOUT + ) + response.raise_for_status() + + bootstrap_data = response.json() + self._bootstrap_config = BootstrapConfig.from_api_response(bootstrap_data, self.platform) + self._last_bootstrap = time.time() + + logger.info( + f"Bootstrap discovery successful: " + f"clientModel={self._bootstrap_config.client_model}, " + f"deviceModel={self._bootstrap_config.device_model}" + ) + + # Log critical endpoints + if self._bootstrap_config.taa_url: + logger.debug(f"TAA URL: {self._bootstrap_config.taa_url}") + if self._bootstrap_config.device_tokens_url: + logger.debug(f"Device tokens URL: {self._bootstrap_config.device_tokens_url}") + + return self._bootstrap_config + + except Exception as e: + logger.error(f"Bootstrap discovery failed: {e}") + # Don't cache failed attempts + self._bootstrap_config = None + self._last_bootstrap = None + return None + + def discover_manifest(self, force_refresh: bool = False) -> Optional[ManifestConfig]: + """ + ENHANCED: Discover manifest configuration including device token + Uses correct manifest endpoint parameters + + Returns: + ManifestConfig: Manifest configuration, None if failed + """ + # Check cache + if not force_refresh and self._manifest_config and self._last_manifest: + cache_age = time.time() - self._last_manifest + if cache_age < BOOTSTRAP_CACHE_DURATION: + logger.debug("Using cached manifest configuration") + return self._manifest_config + + try: + logger.info("Discovering manifest configuration") + + # Determine manifest URL - prefer device_tokens_url from bootstrap + if self._bootstrap_config and self._bootstrap_config.device_tokens_url: + manifest_url = self._bootstrap_config.device_tokens_url + logger.debug(f"Using bootstrap device_tokens_url for manifest: {manifest_url}") + else: + terminal_type = self.terminal_type.lower().replace('_', '-') + manifest_url = MAGENTA2_MANIFEST_URL.format(terminal_type=terminal_type) + logger.debug(f"Using fallback manifest URL: {manifest_url}") + + # Build correct manifest parameters + from .constants import ( + MAGENTA2_APP_NAME, + MAGENTA2_APP_VERSION, + MAGENTA2_RUNTIME_VERSION, + MANIFEST_MODEL_MAPPINGS, + MANIFEST_FIRMWARE_MAPPINGS + ) + + params = { + 'model': MANIFEST_MODEL_MAPPINGS.get(self.platform, 'DT:ATV-AndroidTV'), + 'deviceId': self.device_id, + 'appname': MAGENTA2_APP_NAME, + 'appVersion': MAGENTA2_APP_VERSION, + 'firmware': MANIFEST_FIRMWARE_MAPPINGS.get(self.platform, 'API level 30'), + 'runtimeVersion': MAGENTA2_RUNTIME_VERSION, + 'duid': self.device_id # Same as deviceId + } + + logger.debug(f"Manifest request params: {params}") + + headers = self._get_dcm_headers() + + response = self.http_manager.get( + manifest_url, + operation='manifest_discovery', + headers=headers, + params=params, + timeout=DEFAULT_REQUEST_TIMEOUT + ) + response.raise_for_status() + + manifest_data = response.json() + self._manifest_config = ManifestConfig.from_api_response(manifest_data) + self._last_manifest = time.time() + + # ENHANCED: Log device token and critical auth info + device_token = self._manifest_config.get_device_token() + authorize_tokens_url = self._manifest_config.get_authorize_tokens_url() + + if device_token: + logger.info("✓ Device token found in manifest") + logger.debug(f"Device token preview: {device_token[:20]}...{device_token[-10:]}") + else: + logger.warning("⚠️ Device token NOT found in manifest response") + + if authorize_tokens_url: + logger.debug(f"Authorize tokens URL: {authorize_tokens_url}") + + # Log MPX account info (critical for persona token) + mpx_account_pid = self._manifest_config.mpx.account_pid + account_uri = self._manifest_config.mpx.get_account_uri() + logger.info(f"MPX account PID: {mpx_account_pid}") + logger.info(f"MPX account URI: {account_uri}") + + logger.info( + f"Manifest discovery successful: " + f"MPX account={mpx_account_pid}, " + f"DRM endpoints={len([k for k in self._manifest_config.drm.__dict__.keys() if self._manifest_config.drm.__dict__[k]])}, " + f"TV hubs={len(self._manifest_config.tv_hubs.base_urls)}" + ) + + return self._manifest_config + + except Exception as e: + logger.error(f"Manifest discovery failed: {e}") + # Don't cache failed attempts + self._manifest_config = None + self._last_manifest = None + return None + + def discover_openid_config(self, force_refresh: bool = False) -> Optional[OpenIDConfig]: + """ + Discover OpenID Connect configuration + + Returns: + OpenIDConfig: OpenID configuration, None if failed + """ + # Check cache + if not force_refresh and self._openid_config and self._last_openid: + cache_age = time.time() - self._last_openid + if cache_age < OPENID_CONFIG_CACHE_DURATION: + logger.debug("Using cached OpenID configuration") + return self._openid_config + + try: + if not self._bootstrap_config or not self._bootstrap_config.openid_config_url: + logger.warning("No OpenID config URL available from bootstrap") + return None + + logger.info("Discovering OpenID configuration") + + openid_url = self._bootstrap_config.openid_config_url + + response = self.http_manager.get( + openid_url, + operation='openid_discovery', + headers={'Accept': 'application/json'}, + timeout=DEFAULT_REQUEST_TIMEOUT + ) + response.raise_for_status() + + openid_data = response.json() + self._openid_config = OpenIDConfig.from_api_response(openid_data) + self._last_openid = time.time() + + logger.info(f"OpenID discovery successful: token_endpoint={self._openid_config.token_endpoint}") + return self._openid_config + + except Exception as e: + logger.error(f"OpenID discovery failed: {e}") + # Don't cache failed attempts + self._openid_config = None + self._last_openid = None + return None + + def _get_dcm_headers(self) -> Dict[str, str]: + """Get headers for DCM requests""" + from .constants import MAGENTA2_PLATFORMS, DEFAULT_PLATFORM + + platform_config = MAGENTA2_PLATFORMS.get(self.platform, MAGENTA2_PLATFORMS[DEFAULT_PLATFORM]) + + return { + 'User-Agent': 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() + } + + @staticmethod + def _generate_call_id() -> str: + """Generate call ID for requests""" + import uuid + return str(uuid.uuid4()) + + def get_discovery_status(self) -> Dict[str, Any]: + """Get discovery service status""" + status = { + 'bootstrap_available': self._bootstrap_config is not None, + 'manifest_available': self._manifest_config is not None, + 'openid_available': self._openid_config is not None, + } + + if self._bootstrap_config: + status['bootstrap'] = { + 'client_model': self._bootstrap_config.client_model, + 'device_model': self._bootstrap_config.device_model, + 'has_device_tokens_url': bool(self._bootstrap_config.device_tokens_url), + 'has_openid_config_url': bool(self._bootstrap_config.openid_config_url), + 'has_taa_url': bool(self._bootstrap_config.taa_url), + } + + if self._manifest_config: + device_token = self._manifest_config.get_device_token() + status['manifest'] = { + 'mpx_account_pid': self._manifest_config.mpx.account_pid, + 'mpx_account_uri': self._manifest_config.mpx.get_account_uri(), + 'feed_count': len(self._manifest_config.mpx.feeds), + 'has_device_token': bool(device_token), + 'device_token_preview': device_token[:20] + '...' if device_token else None, + 'drm_endpoints': { + 'widevine': bool(self._manifest_config.drm.widevine_license_url), + 'vod_widevine': bool(self._manifest_config.drm.vod_widevine_license_url), + 'fairplay': bool(self._manifest_config.drm.fairplay_license_url), + }, + 'tvhub_count': len(self._manifest_config.tv_hubs.base_urls), + } + + if self._openid_config: + status['openid'] = { + 'has_token_endpoint': bool(self._openid_config.token_endpoint), + 'has_authorization_endpoint': bool(self._openid_config.authorization_endpoint), + } + + # Cache status + now = time.time() + status['cache'] = { + 'bootstrap_age': now - self._last_bootstrap if self._last_bootstrap else None, + 'manifest_age': now - self._last_manifest if self._last_manifest else None, + 'openid_age': now - self._last_openid if self._last_openid else None, + } + + return status + + def clear_cache(self) -> None: + """Clear all discovery cache""" + self._bootstrap_config = None + self._manifest_config = None + self._openid_config = None + self._last_bootstrap = None + self._last_manifest = None + self._last_openid = None + logger.info("Discovery cache cleared") \ No newline at end of file diff --git a/lib/streaming_providers/providers/magenta2/endpoint_manager.py b/lib/streaming_providers/providers/magenta2/endpoint_manager.py new file mode 100644 index 0000000..d9ae0f1 --- /dev/null +++ b/lib/streaming_providers/providers/magenta2/endpoint_manager.py @@ -0,0 +1,230 @@ +# streaming_providers/providers/magenta2/endpoint_manager.py +from typing import Dict, Optional, Any, List +from dataclasses import dataclass +from enum import Enum +import logging + +from .config_models import ProviderConfig +from .constants import MAGENTA2_FALLBACK_ENDPOINTS + +logger = logging.getLogger(__name__) + + +class EndpointCategory(Enum): + """Categories for different types of endpoints""" + AUTHENTICATION = "auth" + CONTENT = "content" + DRM = "drm" + EPG = "epg" + MPX = "mpx" + TVHUBS = "tvhubs" + USER = "user" + + +@dataclass +class EndpointInfo: + """Information about a specific endpoint""" + category: EndpointCategory + url: str + requires_auth: bool = False + cache_duration: int = 3600 # seconds + last_verified: Optional[float] = None + is_fallback: bool = False + + +class EndpointManager: + """ + Manages and resolves all dynamic endpoints for Magenta2 provider + """ + + def __init__(self, provider_config: ProviderConfig): + self.config = provider_config + self._endpoints: Dict[str, EndpointInfo] = {} + self._initialize_endpoints() + + def _initialize_endpoints(self) -> None: + """Initialize endpoints from provider configuration""" + self._add_bootstrap_endpoints() + self._add_manifest_endpoints() + self._add_openid_endpoints() + self._add_fallback_endpoints() + + def _add_bootstrap_endpoints(self) -> None: + """Add endpoints from bootstrap configuration""" + bootstrap = self.config.bootstrap + + # Authentication endpoints + if bootstrap.taa_url: + self._add_endpoint('taa_auth', EndpointCategory.AUTHENTICATION, bootstrap.taa_url) + + if bootstrap.openid_config_url: + self._add_endpoint('openid_config', EndpointCategory.AUTHENTICATION, bootstrap.openid_config_url) + + if bootstrap.line_auth_url: + self._add_endpoint('line_auth', EndpointCategory.AUTHENTICATION, bootstrap.line_auth_url) + + if bootstrap.remote_login_url: + self._add_endpoint('remote_login', EndpointCategory.AUTHENTICATION, bootstrap.remote_login_url) + + # Content endpoints + if bootstrap.device_tokens_url: + self._add_endpoint('device_tokens', EndpointCategory.CONTENT, bootstrap.device_tokens_url) + + if bootstrap.account_base_url: + self._add_endpoint('account_base', EndpointCategory.USER, bootstrap.account_base_url) + + if bootstrap.consumer_accounts_url: + self._add_endpoint('consumer_accounts', EndpointCategory.USER, bootstrap.consumer_accounts_url) + + def _add_manifest_endpoints(self) -> None: + """Add endpoints from manifest configuration""" + if not self.config.manifest: + return + + manifest = self.config.manifest + + # MPX endpoints + if manifest.mpx.license_service_url: + self._add_endpoint('mpx_license', EndpointCategory.MPX, manifest.mpx.license_service_url) + + if manifest.mpx.selector_service_url: + self._add_endpoint('mpx_selector', EndpointCategory.MPX, manifest.mpx.selector_service_url) + + if manifest.mpx.channel_stations_feed: + self._add_endpoint('channel_stations', EndpointCategory.CONTENT, manifest.mpx.channel_stations_feed) + logger.info(f"Channel stations feed found: {manifest.mpx.channel_stations_feed}") + + # DRM endpoints + if manifest.drm.widevine_license_url: + self._add_endpoint('widevine_license', EndpointCategory.DRM, manifest.drm.widevine_license_url) + + if manifest.drm.vod_widevine_license_url: + self._add_endpoint('vod_widevine_license', EndpointCategory.DRM, manifest.drm.vod_widevine_license_url) + + if manifest.drm.fairplay_license_url: + self._add_endpoint('fairplay_license', EndpointCategory.DRM, manifest.drm.fairplay_license_url) + + # MPX feeds (resolved with account PID) + for feed_name, feed_template in manifest.mpx.feeds.items(): + resolved_url = self.config.get_resolved_feed_url(feed_name) + if resolved_url: + self._add_endpoint(f'mpx_feed_{feed_name}', EndpointCategory.MPX, resolved_url) + + # TV Hub URLs (resolved with client model) + for hub_name in manifest.tv_hubs.base_urls.keys(): + resolved_url = self.config.get_resolved_tvhub_url(hub_name) + if resolved_url: + self._add_endpoint(f'tvhub_{hub_name}', EndpointCategory.TVHUBS, resolved_url) + + def _add_openid_endpoints(self) -> None: + """Add endpoints from OpenID configuration""" + if not self.config.openid: + return + + openid = self.config.openid + + if openid.token_endpoint: + self._add_endpoint('oauth_token', EndpointCategory.AUTHENTICATION, openid.token_endpoint, + requires_auth=False) + + if openid.authorization_endpoint: + self._add_endpoint('oauth_authorize', EndpointCategory.AUTHENTICATION, openid.authorization_endpoint) + + if openid.userinfo_endpoint: + self._add_endpoint('userinfo', EndpointCategory.USER, openid.userinfo_endpoint, requires_auth=True) + + if openid.revocation_endpoint: + self._add_endpoint('oauth_revoke', EndpointCategory.AUTHENTICATION, openid.revocation_endpoint) + + def _add_fallback_endpoints(self) -> None: + """Add fallback endpoints for critical functionality""" + fallbacks = { + 'openid_config': MAGENTA2_FALLBACK_ENDPOINTS['OPENID_CONFIG'], + 'taa_auth': MAGENTA2_FALLBACK_ENDPOINTS['TAA_AUTH'], + 'entitlement': MAGENTA2_FALLBACK_ENDPOINTS['ENTITLEMENT'], + 'mpx_license': 'https://license.entitlement.theplatform.eu/license/web/ContentAccessRules/getApplicableDistributionRights', + 'mpx_selector': 'https://link.api.eu.theplatform.com/s/', + 'widevine_license': 'https://widevine.entitlement.theplatform.eu/wv/web/ModularDrm/getRawWidevineLicense', + } + + for endpoint_name, url in fallbacks.items(): + # Only add fallback if we don't already have this endpoint + if endpoint_name not in self._endpoints: + self._add_endpoint(endpoint_name, EndpointCategory.AUTHENTICATION, url, is_fallback=True) + + def _add_endpoint(self, name: str, category: EndpointCategory, url: str, + requires_auth: bool = False, is_fallback: bool = False) -> None: + """Add an endpoint to the manager""" + self._endpoints[name] = EndpointInfo( + category=category, + url=url, + requires_auth=requires_auth, + is_fallback=is_fallback + ) + logger.debug(f"Added endpoint: {name} -> {url} (category: {category.value}, fallback: {is_fallback})") + + def get_endpoint(self, name: str) -> Optional[str]: + """Get endpoint URL by name""" + endpoint_info = self._endpoints.get(name) + return endpoint_info.url if endpoint_info else None + + def get_endpoint_info(self, name: str) -> Optional[EndpointInfo]: + """Get complete endpoint information by name""" + return self._endpoints.get(name) + + def get_endpoints_by_category(self, category: EndpointCategory) -> Dict[str, str]: + """Get all endpoints for a specific category""" + return { + name: info.url + for name, info in self._endpoints.items() + if info.category == category + } + + def has_endpoint(self, name: str) -> bool: + """Check if endpoint exists""" + return name in self._endpoints + + def is_fallback_endpoint(self, name: str) -> bool: + """Check if endpoint is a fallback""" + endpoint_info = self._endpoints.get(name) + return endpoint_info.is_fallback if endpoint_info else False + + def get_all_endpoints(self) -> Dict[str, EndpointInfo]: + """Get all endpoints""" + return self._endpoints.copy() + + def get_stats(self) -> Dict[str, Any]: + """Get endpoint manager statistics""" + total = len(self._endpoints) + by_category = {} + fallback_count = 0 + + for info in self._endpoints.values(): + category = info.category.value + by_category[category] = by_category.get(category, 0) + 1 + if info.is_fallback: + fallback_count += 1 + + return { + 'total_endpoints': total, + 'endpoints_by_category': by_category, + 'fallback_endpoints': fallback_count, + 'dynamic_endpoints': total - fallback_count, + 'is_complete': self.config.is_complete + } + + def validate_critical_endpoints(self) -> List[str]: + """Validate that all critical endpoints are available""" + critical_endpoints = [ + 'taa_auth', + 'openid_config', + 'mpx_license', + 'widevine_license' + ] + + missing = [] + for endpoint in critical_endpoints: + if not self.has_endpoint(endpoint): + missing.append(endpoint) + + return missing \ No newline at end of file diff --git a/lib/streaming_providers/providers/magenta2/models.py b/lib/streaming_providers/providers/magenta2/models.py new file mode 100644 index 0000000..7f14705 --- /dev/null +++ b/lib/streaming_providers/providers/magenta2/models.py @@ -0,0 +1,209 @@ +# streaming_providers/providers/magenta2/models.py +from dataclasses import dataclass, field +from typing import Dict, Optional +import json + +from ...base.models import StreamingChannel + + +# streaming_providers/providers/magenta2/models.py +# Add this exception if not already present + +class DeviceLimitExceededException(Exception): + """Exception raised when device limit is exceeded""" + pass + + +class Magenta2PlaybackRestrictedException(Exception): + """Exception raised when playback is restricted for content""" + pass + + +@dataclass +class Magenta2Channel: + """ + Represents a Magenta2 channel with all necessary streaming data + """ + # Core identification + name: str + channel_id: str + + # Visual elements + logo_url: Optional[str] = None + + # Streaming configuration + mode: str = "live" # "live" or "vod" + session_manifest: bool = False + manifest: Optional[str] = None + manifest_script: Optional[str] = None + + # CDM (Content Decryption Module) settings + cdm_type: Optional[str] = None + use_cdm: bool = True + cdm: Optional[str] = None # Usually "pid={pid}" + cdm_mode: str = 'external' + + # Video settings + video: str = 'best' + on_demand: bool = True + speed_up: bool = True + + # Additional metadata + content_type: str = 'LIVE' # 'LIVE' or 'VOD' + description: Optional[str] = None + genre: Optional[str] = None + language: str = 'de' + country: str = 'DE' + + # Streaming data + license_url: Optional[str] = None + certificate_url: Optional[str] = None + streaming_format: Optional[str] = None + + # Internal tracking + raw_data: Dict = field(default_factory=dict) + + @classmethod + def from_api_data(cls, api_data: Dict, **kwargs) -> 'Magenta2Channel': + """ + Create Magenta2Channel from API response data + + Args: + api_data: Raw API response data + **kwargs: Additional parameters to override defaults + + Returns: + Magenta2Channel instance + """ + channel = cls( + name=api_data.get('title', api_data.get('name', 'Unknown Channel')), + channel_id=api_data.get('id', ''), + content_type=api_data.get('type', 'LIVE'), + raw_data=api_data.copy() + ) + + # Apply any additional parameters + for key, value in kwargs.items(): + if hasattr(channel, key): + setattr(channel, key, value) + + return channel + + def set_streaming_data(self, manifest: str, cdm_type: str = None, + pid: str = None, license_url: str = None, + certificate_url: str = None, streaming_format: str = None) -> None: + """ + Configure streaming-specific data + + Args: + manifest: Manifest URL for streaming + cdm_type: Content decryption module type + pid: Program ID for CDM + license_url: License URL for DRM + certificate_url: Certificate URL for DRM + streaming_format: Streaming format (e.g., 'dash') + """ + self.manifest = manifest + + if cdm_type: + self.cdm_type = cdm_type + + if pid: + self.cdm = f"pid={pid}" + + if license_url: + self.license_url = license_url + + if certificate_url: + self.certificate_url = certificate_url + + if streaming_format: + self.streaming_format = streaming_format + + def set_logo(self, logo_url: str) -> None: + """Set channel logo URL""" + self.logo_url = logo_url + + def set_metadata(self, description: str = None, genre: str = None) -> None: + """Set additional metadata""" + if description: + self.description = description + if genre: + self.genre = genre + + def is_live(self) -> bool: + """Check if channel is live TV""" + return self.content_type == 'LIVE' and self.mode == 'live' + + def is_vod(self) -> bool: + """Check if channel is video on demand""" + return self.content_type == 'VOD' or self.mode == 'vod' + + def to_streaming_channel(self, provider_name: str = 'magenta2') -> StreamingChannel: + """ + Convert to generic StreamingChannel object + + Args: + provider_name: Provider name to set + + Returns: + StreamingChannel instance + """ + return StreamingChannel( + name=self.name, + channel_id=self.channel_id, + provider=provider_name, + logo_url=self.logo_url, + mode=self.mode, + session_manifest=self.session_manifest, + manifest=self.manifest, + manifest_script=self.manifest_script, + cdm_type=self.cdm_type, + use_cdm=self.use_cdm, + cdm=self.cdm, + cdm_mode=self.cdm_mode, + video=self.video, + on_demand=self.on_demand, + speed_up=self.speed_up, + content_type=self.content_type, + description=self.description, + genre=self.genre, + language=self.language, + country=self.country, + license_url=self.license_url, + certificate_url=self.certificate_url, + streaming_format=self.streaming_format + ) + + def to_dict(self) -> Dict: + """ + Convert to dictionary format matching your output specification + + Returns: + Dictionary in the format expected by your application + """ + return { + 'Name': self.name, + 'LogoUrl': self.logo_url, + 'Mode': self.mode, + 'SessionManifest': self.session_manifest, + 'Manifest': self.manifest, + 'ManifestScript': self.manifest_script, + 'CdmType': self.cdm_type, + 'UseCdm': self.use_cdm, + 'Cdm': self.cdm, + 'CdmMode': self.cdm_mode, + 'Video': self.video, + 'OnDemand': self.on_demand, + 'SpeedUp': self.speed_up + } + + def to_json(self, indent: int = 2) -> str: + """Convert to JSON string""" + return json.dumps(self.to_dict(), indent=indent, ensure_ascii=False) + + def __str__(self) -> str: + return f"Magenta2Channel(name='{self.name}', id='{self.channel_id}', type='{self.content_type}')" + + def __repr__(self) -> str: + return self.__str__() \ No newline at end of file diff --git a/lib/streaming_providers/providers/magenta2/provider.py b/lib/streaming_providers/providers/magenta2/provider.py new file mode 100644 index 0000000..7d9dc75 --- /dev/null +++ b/lib/streaming_providers/providers/magenta2/provider.py @@ -0,0 +1,1041 @@ +# streaming_providers/providers/magenta2/provider.py +# -*- coding: utf-8 -*- +from typing import Dict, Optional, List, Any +import json +import time +import uuid +from datetime import datetime, timedelta + +from ...base.provider import StreamingProvider +from ...base.models import DRMConfig, LicenseConfig, DRMSystem +from ...base.models.streaming_channel import StreamingChannel +from ...base.network import HTTPManagerFactory, ProxyConfigManager +from ...base.models.proxy_models import ProxyConfig +from ...base.utils.logger import logger +from .models import Magenta2Channel, Magenta2PlaybackRestrictedException +from .auth import Magenta2Authenticator, Magenta2Credentials, Magenta2UserCredentials +from .discovery import DiscoveryService +from .endpoint_manager import EndpointManager +from .config_models import ProviderConfig +from .constants import ( + SUPPORTED_COUNTRIES, + DEFAULT_COUNTRY, + DEFAULT_PLATFORM, + MAGENTA2_PLATFORMS, + CONTENT_TYPE_LIVE, + CONTENT_TYPE_VOD, + MODE_LIVE, + MODE_VOD, + DRM_SYSTEM_WIDEVINE, + DRM_REQUEST_HEADERS, + DEFAULT_REQUEST_TIMEOUT, + DEFAULT_MAX_RETRIES, + DEFAULT_EPG_WINDOW_HOURS, + ERROR_CODES +) + + +class Magenta2Provider(StreamingProvider): + """ + 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 + + # 🚨 PERFORM CONFIGURATION DISCOVERY FIRST (before authenticator) + try: + self._perform_configuration_discovery() + except Exception as e: + logger.error(f"Configuration discovery failed: {e}") + raise + + # 🚨 NOW INITIALIZE AUTHENTICATOR WITH DISCOVERED CONFIG + if username and password: + # Use user credentials for complete authentication flow + credentials = Magenta2UserCredentials( + client_id=self.provider_config.bootstrap.sam3_client_id if self.provider_config else None, + 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=self.provider_config.bootstrap.sam3_client_id if self.provider_config else None, + platform=platform, + country=country, + device_id=self.device_id + ) + logger.info("Using client credentials for authentication") + + # Create authenticator with the appropriate credentials + self.authenticator = Magenta2Authenticator( + country=country, + platform=platform, + config_dir=config_dir, + http_manager=self.http_manager, + proxy_config=self.proxy_config, + credentials=credentials, + endpoints={}, + client_model=self.provider_config.bootstrap.client_model if self.provider_config else None, + device_model=self.provider_config.bootstrap.device_model if self.provider_config else None, + sam3_client_id=self.provider_config.bootstrap.sam3_client_id if self.provider_config else None, + session_id=self.session_id, + device_id=self.device_id + ) + + # 🚨 NOW CONFIGURE AUTHENTICATOR WITH DISCOVERED DATA (after authenticator exists) + 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: + 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) + + # Initialize auth tokens (lazy - populated on first use) + self.bearer_token = None # TAA access token (JWT) + self.persona_token = None # COMPOSED persona token (Base64) for API calls + self.device_token = None + + @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) + + # 🚨 REMOVED: Don't configure authenticator here - it doesn't exist yet + # Just log what we discovered for now + 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, use_persona_token: bool = False, + require_auth: bool = False) -> Dict[str, str]: + """ + FIXED: Get headers for API requests with proper token usage + """ + headers = { + 'User-Agent': self.platform_config['user_agent'], + 'Accept': 'application/json', + 'Content-Type': 'application/json' + } + + # Lazy authentication - only authenticate if required and not already done + if require_auth and not self.bearer_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: + headers['Authorization'] = f'Basic {self.persona_token}' + logger.debug("Using composed persona token (Basic auth)") + elif self.bearer_token: + headers['Authorization'] = f'Bearer {self.bearer_token}' + logger.debug("Using TAA bearer token") + + return headers + + @property + def provider_name(self) -> str: + return 'magenta2' + + @property + def provider_label(self) -> str: + return f'Magenta2 ({self.country})' + + @property + def uses_dynamic_manifests(self) -> bool: + return False + + 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 authenticate(self, **kwargs) -> str: + """ + ENHANCED: Authenticate with line auth priority + """ + # PROPER: Use public method to check line auth availability + line_auth_available = ( + hasattr(self.authenticator, 'can_use_line_auth') and + self.authenticator.can_use_line_auth() + ) + + if line_auth_available and not kwargs.get('force_client_credentials', False): + logger.info("Line auth components available, attempting line auth flow") + + # Continue with existing logic (which should now try line auth first in auth.py) + self.bearer_token = self.authenticator.get_bearer_token( + force_refresh=kwargs.get('force_refresh', False) + ) + + # CRITICAL: Extract the COMPOSED persona token + self.persona_token = self.authenticator.get_persona_token() + + if not self.persona_token: + logger.error("Authentication succeeded but no composed persona token available!") + auth_state = self.authenticator.debug_authentication_state() + logger.error(f"Auth state: {json.dumps(auth_state, indent=2)}") + raise Exception( + "Failed to compose persona token. " + "TAA JWT may be missing dc_cts_persona_token or account_uri claims." + ) + + logger.info("✓ Authentication complete with composed persona token") + return self.bearer_token + + def refresh_authentication(self) -> str: + """FIXED: Force refresh authentication and get new persona token""" + self.bearer_token = self.authenticator.get_bearer_token(force_refresh=True) + self.persona_token = self.authenticator.get_persona_token() + + if not self.persona_token: + raise Exception("Failed to compose persona token after refresh") + + logger.info("✓ Authentication refreshed with new persona token") + return self.bearer_token + + def _ensure_authenticated(self) -> None: + """ + FIXED: Ensure we have valid authentication token AND composed persona token + """ + # Check if we have both bearer token AND composed persona token + if self.bearer_token and self.persona_token and self.authenticator.is_authenticated(): + return + + logger.debug("Performing lazy authentication") + + # Authenticate to get bearer token + 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") + + def get_dynamic_manifest_params(self, channel: StreamingChannel, **kwargs) -> Optional[str]: + return None + + def _process_channel_stations_response(self, response_data: Dict) -> List[StreamingChannel]: + """Process channel stations feed response""" + channels = [] + + if 'entries' not in response_data: + logger.warning("No entries found in channel stations response") + return channels + + for entry in response_data['entries']: + try: + # Extract channel information from the entry + title = entry.get('title', 'Unknown Channel') + channel_id = entry.get('guid', '') + + if not channel_id: + continue + + # Extract station information + stations = entry.get('stations', {}) + station_info = None + if stations: + # Get the first station (there's usually only one) + station_id = next(iter(stations.keys())) + station_info = stations[station_id] + + # Extract logo URLs from station info + logo_url = None + if station_info: + thumbnails = station_info.get('thumbnails', {}) + if 'stationLogo' in thumbnails: + logo_url = thumbnails['stationLogo'].get('url') + elif 'stationLogoColored' in thumbnails: + logo_url = thumbnails['stationLogoColored'].get('url') + + # Extract channel number + channel_number = entry.get('channelNumber') + if channel_number: + title = f"{channel_number}. {title}" + + # Create channel object + 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 + ) + channels.append(streaming_channel) + + except Exception as e: + logger.warning(f"Error processing channel station entry: {e}") + + return channels + + def fetch_channels(self, + time_window_hours: int = DEFAULT_EPG_WINDOW_HOURS, + fetch_manifests: bool = False, + populate_streaming_data: bool = True, + **kwargs) -> List[StreamingChannel]: + """Fetch available channels from Magenta2 API""" + try: + # Get headers WITHOUT requiring auth (lazy auth will happen later if needed) + headers = self._get_api_headers(require_auth=False) + + # FIXED: Use the discovered channel stations endpoint + url = None + if self.endpoint_manager: + # Try channel_stations endpoint first (this is the main channel list) + url = self.endpoint_manager.get_endpoint('channel_stations') + + # If not found, try other channel endpoints + if not url: + url = self.endpoint_manager.get_endpoint('channel_list') + + # If still not found, try MPX feeds + 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" + + 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() + + # Process the channel stations feed response + channels = self._process_channel_stations_response(channels_data) + + logger.info(f"Successfully fetched {len(channels)} channels for country {self.country}") + 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: + """ + FIXED: Get entitlement token using COMPOSED persona token + """ + # 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) + + 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 + + def get_manifest(self, channel_id: str, content_type: str = CONTENT_TYPE_LIVE, **kwargs) -> Optional[str]: + """Get manifest URL for a specific channel by ID""" + self._ensure_authenticated() + + try: + entitlement_token = self.get_entitlement_token( + content_id=channel_id, + content_type=content_type + ) + + playlist_data = self.get_channel_playlist( + channel_id, + entitlement_token + ) + + return playlist_data.get('manifestUrl', playlist_data.get('manifest')) + + except Exception as e: + logger.error(f"Error getting manifest for channel {channel_id}: {e}") + return None + + def get_drm_configs_by_id(self, channel_id: str, content_type: str = CONTENT_TYPE_LIVE, + **kwargs) -> List[DRMConfig]: + """Get all DRM configurations for a channel by ID""" + self._ensure_authenticated() + + try: + entitlement_token = self.get_entitlement_token( + content_id=channel_id, + content_type=content_type + ) + + playlist_data = self.get_channel_playlist( + channel_id, + entitlement_token + ) + + license_url = playlist_data.get('licenseUrl', playlist_data.get('license')) + if not license_url: + return [] + + widevine_license_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_license_url, + server_certificate=playlist_data.get('certificateUrl', playlist_data.get('certificate')), + req_headers=json.dumps({ + 'Authorization': f'Bearer {self.bearer_token}', + 'Content-Type': DRM_REQUEST_HEADERS['Content-Type'], + 'User-Agent': self.platform_config['user_agent'] + }), + req_data="{CHA-RAW}", + use_http_get_request=False + ) + ) + + return [drm_config] + + except Exception as e: + logger.error(f"Error getting DRM configs for channel {channel_id}: {e}") + return [] + + 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 debug_authentication(self) -> Dict[str, Any]: + """ + Enhanced debug method with authentication capabilities + """ + result = { + 'provider': { + 'has_bearer_token': bool(self.bearer_token), + 'has_persona_token': bool(self.persona_token), + 'persona_token_preview': self.persona_token[:50] + '...' if self.persona_token else None, + } + } + + if hasattr(self.authenticator, 'get_authentication_capabilities'): + result['authentication_capabilities'] = self.authenticator.get_authentication_capabilities() + + if hasattr(self.authenticator, 'debug_authentication_state'): + result['authenticator'] = self.authenticator.debug_authentication_state() + + if self.endpoint_manager: + result['endpoints'] = { + 'has_taa_auth': self.endpoint_manager.has_endpoint('taa_auth'), + 'has_entitlement': self.endpoint_manager.has_endpoint('entitlement'), + 'total_endpoints': len(self.endpoint_manager.get_all_endpoints()), + } + + # PHASE 4: Add TAA token analysis if bearer token is available + if self.bearer_token and hasattr(self.authenticator, 'debug_taa_token'): + result['taa_token_analysis'] = self.authenticator.debug_taa_token(self.bearer_token) + + # PHASE 4: Add authentication flow info + if hasattr(self.authenticator, 'get_authentication_flow_info'): + result['authentication_flow'] = self.authenticator.get_authentication_flow_info() + + return result \ No newline at end of file diff --git a/lib/streaming_providers/providers/magenta2/remote_login_handler.py b/lib/streaming_providers/providers/magenta2/remote_login_handler.py new file mode 100644 index 0000000..e599def --- /dev/null +++ b/lib/streaming_providers/providers/magenta2/remote_login_handler.py @@ -0,0 +1,352 @@ +# streaming_providers/providers/magenta2/remote_login_handler.py +""" +Remote Login Handler for Magenta2 Backchannel Authentication +Implements QR code-based authentication flow as fallback when line auth fails +""" +import time +from typing import Dict, Optional, Any +from dataclasses import dataclass + +from ...base.network import HTTPManager +from ...base.utils.logger import logger +from ...base.ui import NotificationFactory, NotificationInterface, NotificationResult +from .constants import ( + SSO_USER_AGENT, + DEFAULT_REQUEST_TIMEOUT, + GRANT_TYPES, +) + + +@dataclass +class RemoteLoginSession: + """Remote login session data""" + initial_login_code: str + auth_req_id: str + auth_req_sec: str + interval: int + expires_in: int + qr_code_url: str + started_at: float + + +class RemoteLoginHandler: + """ + Handles the complete backchannel authentication / remote login flow + + Flow: + 1. Start backchannel auth -> get login code and QR URL + 2. Display QR code to user (via notification adapter) + 3. Poll token endpoint until user completes mobile authentication + 4. Handle countdown internally with notification updates + """ + + def __init__(self, http_manager: HTTPManager, sam3_client_id: str, + backchannel_start_url: str, token_endpoint: str, + qr_code_url_template: str, + notifier: Optional[NotificationInterface] = None): + """ + Initialize remote login handler + + Args: + http_manager: HTTP manager for requests + sam3_client_id: SAM3 client ID + backchannel_start_url: Backchannel auth start endpoint + token_endpoint: OAuth token endpoint for polling + qr_code_url_template: QR code URL template with {code} placeholder + notifier: Optional notification interface (auto-created if None) + """ + self.http_manager = http_manager + self.sam3_client_id = sam3_client_id + self.backchannel_start_url = backchannel_start_url + self.token_endpoint = token_endpoint + self.qr_code_url_template = qr_code_url_template + + # Get or create notifier with http_manager + if notifier: + self._notifier = notifier + else: + self._notifier = NotificationFactory.create(http_manager=http_manager) + + self._current_session: Optional[RemoteLoginSession] = None + + logger.debug(f"RemoteLoginHandler initialized with {self._notifier.__class__.__name__}") + + def set_notifier(self, notifier: NotificationInterface) -> None: + """ + Set custom notification interface + + Args: + notifier: Notification interface to use + """ + self._notifier = notifier + logger.debug(f"Notifier set to: {notifier.__class__.__name__}") + + def start_remote_login(self, scope: str = "tvhubs offline_access") -> RemoteLoginSession: + """ + Start backchannel authentication flow + + Args: + scope: OAuth scopes to request + + Returns: + RemoteLoginSession with login code and polling parameters + + Raises: + Exception: If backchannel auth start fails + """ + try: + logger.info("Starting remote login (backchannel auth)") + + # Build request payload + payload = { + 'client_id': self.sam3_client_id, + 'scope': scope + } + + headers = { + 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', + 'User-Agent': SSO_USER_AGENT + } + + # Start backchannel auth + response = self.http_manager.post( + self.backchannel_start_url, + operation='backchannel_start', + headers=headers, + data=payload, + timeout=DEFAULT_REQUEST_TIMEOUT + ) + response.raise_for_status() + + data = response.json() + + # Extract session data + initial_login_code = data.get('initial_login_code') + auth_req_id = data.get('auth_req_id') + auth_req_sec = data.get('auth_req_sec') + interval = int(data.get('interval', 10)) + expires_in = int(data.get('expires_in', 300)) + + if not all([initial_login_code, auth_req_id, auth_req_sec]): + raise Exception("Incomplete backchannel auth response") + + # Build QR code URL + qr_code_url = self.qr_code_url_template.format(code=initial_login_code) + + # Create session + session = RemoteLoginSession( + initial_login_code=initial_login_code, + auth_req_id=auth_req_id, + auth_req_sec=auth_req_sec, + interval=interval, + expires_in=expires_in, + qr_code_url=qr_code_url, + started_at=time.time() + ) + + self._current_session = session + + logger.info( + f"Remote login started: code={initial_login_code}, " + f"interval={interval}s, expires_in={expires_in}s" + ) + + return session + + except Exception as e: + logger.error(f"Failed to start remote login: {e}") + raise Exception(f"Remote login start failed: {e}") + + def poll_for_token(self, session: RemoteLoginSession) -> Optional[Dict[str, Any]]: + """ + Poll token endpoint until user completes authentication or timeout + NOW HANDLES COUNTDOWN UPDATES INTERNALLY + + Args: + session: Active remote login session + + Returns: + Token data dict if successful, None if timed out/cancelled + + Raises: + Exception: If polling fails with unexpected error + """ + try: + logger.info("Starting token polling for remote login") + + start_time = session.started_at + next_poll_time = start_time + last_countdown_update = start_time + + headers = { + 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', + 'User-Agent': SSO_USER_AGENT + } + + payload = { + 'client_id': self.sam3_client_id, + 'grant_type': GRANT_TYPES['REMOTE_LOGIN'], + 'auth_req_id': session.auth_req_id, + 'auth_req_sec': session.auth_req_sec + } + + poll_count = 0 + max_polls = session.expires_in // session.interval + 1 + + while True: + current_time = time.time() + elapsed = current_time - start_time + remaining = max(0, session.expires_in - elapsed) + + # Check if session expired + if elapsed >= session.expires_in: + logger.warning( + f"Remote login session expired after {elapsed:.1f}s " + f"(limit: {session.expires_in}s)" + ) + self._notifier.close(success=False, message="Session expired") + return None + + # Update countdown display (every second or as needed) + if current_time - last_countdown_update >= 1.0: + # Check if user cancelled + if self._notifier.is_cancelled(): + logger.info("User cancelled remote login") + self._notifier.close(success=False, message="Cancelled by user") + return None + + # Update countdown + if not self._notifier.update_countdown(int(remaining)): + logger.info("Countdown update returned False - user cancelled") + self._notifier.close(success=False, message="Cancelled by user") + return None + + last_countdown_update = current_time + + # Wait until next poll time + if current_time < next_poll_time: + sleep_time = min(1.0, next_poll_time - current_time) # Sleep max 1 second for countdown updates + time.sleep(sleep_time) + continue + + # Perform poll + poll_count += 1 + logger.debug( + f"Polling attempt {poll_count}/{max_polls} " + f"(remaining: {remaining:.0f}s)" + ) + + try: + response = self.http_manager.post( + self.token_endpoint, + operation='remote_login_poll', + headers=headers, + data=payload, + timeout=DEFAULT_REQUEST_TIMEOUT + ) + + # 202 = User hasn't completed authentication yet + if response.status_code == 202: + logger.debug("Authentication not yet completed (202)") + next_poll_time = time.time() + session.interval + continue + + # Success - user completed authentication + if response.status_code == 200: + token_data = response.json() + logger.info( + f"✓ Remote login successful after {elapsed:.1f}s " + f"({poll_count} polls)" + ) + self._notifier.close(success=True) + return token_data + + # Other status codes = error + response.raise_for_status() + + except Exception as e: + # If we get an error during polling, check if we should retry + if elapsed < session.expires_in: + logger.debug(f"Poll error (will retry): {e}") + next_poll_time = time.time() + session.interval + continue + else: + # Session expired, give up + raise + + except Exception as e: + logger.error(f"Remote login polling failed: {e}") + self._notifier.close(success=False, message=str(e)) + raise Exception(f"Remote login polling failed: {e}") + + def perform_complete_flow(self, scope: str = "tvhubs offline_access") -> Optional[Dict[str, Any]]: + """ + Perform complete remote login flow: + 1. Start session + 2. Display QR code via notifier + 3. Poll for completion with automatic countdown updates + + Args: + scope: OAuth scopes to request + + Returns: + Token data dict if successful, None if failed/timeout/cancelled + """ + try: + # Step 1: Start session + session = self.start_remote_login(scope) + + # Step 2: Display QR code to user via notifier + result = self._notifier.show_remote_login( + login_code=session.initial_login_code, + qr_url=session.qr_code_url, + expires_in=session.expires_in, + interval=session.interval + ) + + if result != NotificationResult.CONTINUE: + logger.warning(f"Failed to show notification: {result}") + return None + + # Step 3: Poll for completion (handles countdown internally) + token_data = self.poll_for_token(session) + + if token_data: + logger.info("✓ Remote login flow completed successfully") + else: + logger.warning("Remote login flow timed out or was cancelled") + + return token_data + + except Exception as e: + logger.error(f"Remote login flow failed: {e}") + self._notifier.close(success=False, message=str(e)) + return None + finally: + self._current_session = None + + def cancel_current_session(self) -> None: + """Cancel current remote login session""" + if self._current_session: + logger.info("Cancelling remote login session") + self._current_session = None + self._notifier.close(success=False, message="Cancelled") + + def get_session_status(self) -> Optional[Dict[str, Any]]: + """Get current session status""" + if not self._current_session: + return None + + session = self._current_session + elapsed = time.time() - session.started_at + remaining = max(0, session.expires_in - elapsed) + + return { + 'login_code': session.initial_login_code, + 'qr_code_url': session.qr_code_url, + 'elapsed_seconds': elapsed, + 'remaining_seconds': remaining, + 'is_expired': remaining <= 0, + 'interval': session.interval, + 'is_cancelled': self._notifier.is_cancelled() + } \ No newline at end of file diff --git a/lib/streaming_providers/providers/magenta2/sam3_client.py b/lib/streaming_providers/providers/magenta2/sam3_client.py new file mode 100644 index 0000000..2d09b2f --- /dev/null +++ b/lib/streaming_providers/providers/magenta2/sam3_client.py @@ -0,0 +1,635 @@ +# streaming_providers/providers/magenta2/sam3_client.py +import re +from typing import Dict, Optional, Any, List +from dataclasses import dataclass +from urllib.parse import urlparse, parse_qs + +from ...base.network import HTTPManager +from ...base.utils.logger import logger +from .constants import ( + SSO_USER_AGENT, + DEFAULT_REQUEST_TIMEOUT, + GRANT_TYPES, +) + + +@dataclass +class Sam3AuthMethod: + """SAM3 authentication method""" + name: str + enabled: bool = False + + +@dataclass +class Sam3AuthMethods: + """Available SAM3 authentication methods""" + password: bool = False + code: bool = False + line: bool = False + + +@dataclass +class Sam3FormField: + """HTML form field extracted from SAM3 login pages""" + name: str + value: str + type: str = "hidden" + + +class Sam3Client: + """ + SAM3 authentication client implementing the complete login flow from C++ code + """ + + def __init__(self, http_manager: HTTPManager, session_id: str, device_id: str, + sam3_client_id: str, issuer_url: str = None, + oauth_token_endpoint: str = None, line_auth_endpoint: str = None, + backchannel_start_url: str = None, qr_code_url_template: str = None): + """ + Initialize SAM3 client with all required endpoints + """ + self.http_manager = http_manager + self.session_id = session_id + self.device_id = device_id + self.sam3_client_id = sam3_client_id + + # STORE ALL ENDPOINTS FOR DIFFERENT AUTH FLOWS + self.issuer_url = issuer_url # For user auth flow (username/password) + self.oauth_token_endpoint = oauth_token_endpoint # For OAuth flows + self.line_auth_endpoint = line_auth_endpoint # For device line auth + self.token_endpoint = line_auth_endpoint # Backwards compatibility + + # Remote login endpoints + self.backchannel_start_url = backchannel_start_url + self.qr_code_url_template = qr_code_url_template + + # Other SAM3 endpoints (will be updated from OpenID config) + self.authorization_endpoint: Optional[str] = None + self.userinfo_endpoint: Optional[str] = None + + # Authentication state + self.auth_methods = Sam3AuthMethods() + self.form_fields: List[Sam3FormField] = [] + self.refresh_token: Optional[str] = None + self.access_tokens: Dict[str, str] = {} # scope -> token + + self.__last_line_auth_response: Optional[Dict[str, Any]] = None + + # Remote login handler (lazy initialized) + self._remote_login_handler: Optional['RemoteLoginHandler'] = None + + logger.debug( + f"SAM3 client initialized - " + f"Issuer: {issuer_url}, " + f"OAuth: {oauth_token_endpoint}, " + f"Line: {line_auth_endpoint}, " + f"Backchannel: {backchannel_start_url}" + ) + + # ======================================================================== + # Endpoint Resolution Helpers + # ======================================================================== + + def _get_token_endpoint(self) -> str: + """Get the appropriate token endpoint with fallback logic""" + return self.oauth_token_endpoint or self.token_endpoint or self.line_auth_endpoint + + def _get_line_auth_endpoint(self) -> str: + """Get line auth endpoint with fallback""" + return self.line_auth_endpoint or self.token_endpoint + + # ======================================================================== + # Common Token Request Method + # ======================================================================== + + def _make_token_request(self, operation: str, payload: Dict[str, Any], + endpoint_override: str = None) -> Dict[str, Any]: + """ + Common method for all token requests + + Args: + operation: Operation name for logging + payload: Request payload (will be form-encoded) + endpoint_override: Optional endpoint override + + Returns: + Token response data + """ + endpoint = endpoint_override or self._get_token_endpoint() + if not endpoint: + raise Exception("No token endpoint available") + + headers = { + 'Content-Type': 'application/x-www-form-urlencoded', + 'User-Agent': SSO_USER_AGENT + } + + response = self.http_manager.post( + endpoint, + operation=operation, + headers=headers, + data=payload, + timeout=DEFAULT_REQUEST_TIMEOUT + ) + response.raise_for_status() + data = response.json() + + # Store refresh token if provided + if 'refresh_token' in data: + self.refresh_token = data['refresh_token'] + logger.debug(f"Refresh token stored/updated from {operation}") + + return data + + # ======================================================================== + # Authentication Methods Discovery + # ======================================================================== + + def discover_auth_methods(self, line_auth_url: str) -> bool: + """ + Discover available authentication methods + Matching C++ GetAuthMethods() + """ + try: + logger.debug("Discovering SAM3 authentication methods") + + headers = { + 'User-Agent': SSO_USER_AGENT, + 'Accept': 'application/json' + } + + response = self.http_manager.get( + line_auth_url, + operation='auth_methods', + headers=headers, + timeout=DEFAULT_REQUEST_TIMEOUT + ) + response.raise_for_status() + + data = response.json() + content = data.get('content', {}) + + if 'supportedAuthenticationKinds' in content: + auth_kinds = content['supportedAuthenticationKinds'] + for method in auth_kinds: + if method == GRANT_TYPES['PASSWORD']: + self.auth_methods.password = True + elif method == GRANT_TYPES['AUTH_CODE']: + self.auth_methods.code = True + elif method == GRANT_TYPES['LINE_AUTH']: + self.auth_methods.line = True + + logger.info( + f"SAM3 auth methods: password={self.auth_methods.password}, " + f"code={self.auth_methods.code}, line={self.auth_methods.line}" + ) + return True + + except Exception as e: + logger.error(f"Failed to discover SAM3 auth methods: {e}") + return False + + # ======================================================================== + # User Authentication Flow (Username/Password) + # ======================================================================== + + def sam3_login(self, username: str, password: str) -> Dict[str, str]: + """ + Complete SAM3 login flow matching C++ Sam3Client::Sam3Login() + """ + try: + logger.info("Starting SAM3 login flow") + + # Step 1: Get initial authorization page + auth_url = self._build_authorization_url() + logger.debug(f"Step 1: Getting authorization page: {auth_url}") + + initial_response = self.http_manager.get( + auth_url, + operation='sam3_auth', + headers=self._get_sso_headers(), + timeout=DEFAULT_REQUEST_TIMEOUT, + allow_redirects=False + ) + + # Step 2: Parse HTML and extract form fields + self._parse_html_form_fields(initial_response.text) + logger.debug(f"Step 2: Extracted {len(self.form_fields)} form fields") + + # Step 3: Submit username to factorx endpoint + factorx_url = f"{self.issuer_url}/factorx" if self.issuer_url else "https://login.telekom-dienste.de/factorx" + username_data = self._build_username_payload(username) + + logger.debug(f"Step 3: Submitting username to {factorx_url}") + username_response = self.http_manager.post( + factorx_url, + operation='sam3_username', + headers=self._get_sso_headers(), + data=username_data, + timeout=DEFAULT_REQUEST_TIMEOUT, + allow_redirects=False + ) + + # Step 4: Parse response and extract updated form fields + self._parse_html_form_fields(username_response.text) + logger.debug(f"Step 4: Updated form fields: {len(self.form_fields)}") + + # Step 5: Submit password to factorx endpoint + password_data = self._build_password_payload(username, password) + + logger.debug(f"Step 5: Submitting password to {factorx_url}") + password_response = self.http_manager.post( + factorx_url, + operation='sam3_password', + headers=self._get_sso_headers(), + data=password_data, + timeout=DEFAULT_REQUEST_TIMEOUT, + allow_redirects=False + ) + + # Step 6: Extract authorization code from redirect + redirect_url = password_response.headers.get('Location', '') + logger.debug(f"Step 6: Redirect URL: {redirect_url}") + + if not redirect_url: + # Follow redirects manually if needed + final_response = self.http_manager.get( + factorx_url, + operation='sam3_final', + headers=self._get_sso_headers(), + timeout=DEFAULT_REQUEST_TIMEOUT, + allow_redirects=True + ) + redirect_url = final_response.url + + # Step 7: Extract code and state from redirect URL + code, state = self._extract_code_and_state(redirect_url) + + if not code or not state: + raise Exception("Could not extract authorization code and state from redirect") + + logger.info("SAM3 login completed successfully") + return { + 'code': code, + 'state': state, + 'redirect_url': redirect_url + } + + except Exception as e: + logger.error(f"SAM3 login failed: {e}") + raise Exception(f"SAM3 login failed: {e}") + + # ======================================================================== + # Line Authentication (Device Token) + # ======================================================================== + + def get_last_line_auth_response(self) -> Optional[Dict[str, Any]]: + """Get the last line auth response data""" + return self.__last_line_auth_response + + def line_auth(self, device_token: str) -> bool: + """ + Line authentication using device token + """ + try: + endpoint = self._get_line_auth_endpoint() + if not endpoint: + raise Exception("No line auth endpoint available") + + logger.debug("Performing line authentication with device token") + + payload = { + 'grant_type': GRANT_TYPES['LINE_AUTH'], + 'client_id': self.sam3_client_id, + 'token': device_token, + 'scope': 'tvhubs offline_access' + } + + # Use common token request method + data = self._make_token_request('line_auth', payload, endpoint) + + # Store the actual response data + self.__last_line_auth_response = data + + if 'refresh_token' in data: + logger.info("Line authentication successful, refresh token obtained") + return True + + logger.warning("Line authentication succeeded but no refresh token received") + return False + + except Exception as e: + logger.error(f"Line authentication failed: {e}") + return False + + # ======================================================================== + # Remote Login (Backchannel Authentication) + # ======================================================================== + + def _get_remote_login_handler(self) -> Optional['RemoteLoginHandler']: + """ + Get or create remote login handler + + Returns: + RemoteLoginHandler if endpoints available, None otherwise + """ + # Return existing handler if available + if self._remote_login_handler: + return self._remote_login_handler + + # Check if we have required endpoints + if not all([ + self.backchannel_start_url, + self._get_token_endpoint(), + self.qr_code_url_template + ]): + logger.debug("Remote login not available - missing endpoints") + return None + + # Import here to avoid circular dependency + from .remote_login_handler import RemoteLoginHandler + + # Create handler + self._remote_login_handler = RemoteLoginHandler( + http_manager=self.http_manager, + sam3_client_id=self.sam3_client_id, + backchannel_start_url=self.backchannel_start_url, + token_endpoint=self._get_token_endpoint(), + qr_code_url_template=self.qr_code_url_template + ) + + logger.info("✓ Remote login handler initialized") + return self._remote_login_handler + + def can_use_remote_login(self) -> bool: + """Check if remote login is available""" + return self._get_remote_login_handler() is not None + + def remote_login(self, scope: str = "tvhubs offline_access") -> Optional[Dict[str, Any]]: + """ + Perform complete remote login (backchannel auth) flow + + Args: + scope: OAuth scopes to request + + Returns: + Token data dict if successful, None if failed/timeout/cancelled + """ + handler = self._get_remote_login_handler() + if not handler: + logger.error("Remote login not available") + return None + + # Perform complete flow (notifier is already set in handler) + token_data = handler.perform_complete_flow(scope) + + # Store refresh token if we got one + if token_data and 'refresh_token' in token_data: + self.refresh_token = token_data['refresh_token'] + logger.info("✓ Remote login successful, refresh token obtained") + + return token_data + + def get_remote_login_status(self) -> Optional[Dict[str, Any]]: + """Get current remote login session status""" + handler = self._get_remote_login_handler() + if not handler: + return None + return handler.get_session_status() + + # ======================================================================== + # Generic Token Operations + # ======================================================================== + + def get_token(self, grant_type: str, scope: str, credential1: str = "", + credential2: str = "") -> str: + """ + Generic token acquisition + """ + try: + logger.debug(f"Getting token with grant_type: {grant_type}, scope: {scope}") + + payload = { + 'grant_type': grant_type, + 'client_id': self.sam3_client_id + } + + # Add credentials based on grant type + if grant_type == GRANT_TYPES['REFRESH_TOKEN']: + payload['refresh_token'] = credential1 + payload['scope'] = f"{scope} offline_access" + elif grant_type == GRANT_TYPES['REMOTE_LOGIN']: + payload['auth_req_id'] = credential1 + payload['auth_req_sec'] = credential2 + + # Use common token request method + data = self._make_token_request('get_token', payload) + + access_token = data.get('access_token') + if not access_token: + raise Exception("No access token in token response") + + # Store token by scope + self.access_tokens[scope] = access_token + + logger.debug(f"Token obtained for scope: {scope}") + return access_token + + except Exception as e: + logger.error(f"Token acquisition failed: {e}") + raise + + def refresh_access_token(self, scope: str) -> str: + """ + Refresh access token + Matching C++ Sam3Client::RefreshToken() + """ + if not self.refresh_token: + raise Exception("No refresh token available") + + return self.get_token(GRANT_TYPES['REFRESH_TOKEN'], scope, self.refresh_token) + + def get_access_token(self, scope: str) -> str: + """ + Get access token for specific scope with line auth fallback + """ + # Return cached token if available + if scope in self.access_tokens: + return self.access_tokens[scope] + + # Try to use line auth established session first + if self.refresh_token: + try: + token = self.refresh_access_token(scope) + if token: + return token + except Exception as e: + logger.warning(f"Token refresh failed for scope {scope}: {e}") + + # If no refresh token available, we can't get an access token + logger.warning(f"No access token available for scope {scope} - line auth may be needed") + return "" + + # ======================================================================== + # HTML Form Parsing Helpers + # ======================================================================== + + def _build_authorization_url(self) -> str: + """Build authorization URL matching C++ implementation""" + if not self.authorization_endpoint: + # Fallback to standard URL + return "https://login.telekom-dienste.de/oauth2/auth" + + params = { + 'client_id': self.sam3_client_id, + 'redirect_uri': 'https://web2.magentatv.de/authn/idm', + 'response_type': 'code', + 'scope': 'openid offline_access' + } + + param_string = '&'.join([f"{k}={v}" for k, v in params.items()]) + return f"{self.authorization_endpoint}?{param_string}" + + def _parse_html_form_fields(self, html_content: str) -> None: + """ + Parse HTML form fields matching C++ ParseHtml() + """ + self.form_fields.clear() + + # Look for form with id="login" or similar + form_start = html_content.find('form id="login"') + if form_start == -1: + form_start = html_content.find('', form_start) + if form_end == -1: + logger.warning("No form end tag found") + return + + form_html = html_content[form_start:form_end] + + # Find all hidden input fields + pattern = r']*type="hidden"[^>]*name="([^"]*)"[^>]*value="([^"]*)"[^>]*>' + matches = re.findall(pattern, form_html, re.IGNORECASE) + + for name, value in matches: + self.form_fields.append(Sam3FormField(name=name, value=value)) + logger.debug(f"Found form field: {name} = {value}") + + # Also look for input fields without explicit type="hidden" + pattern_all = r']*name="([^"]*)"[^>]*value="([^"]*)"[^>]*>' + all_matches = re.findall(pattern_all, form_html, re.IGNORECASE) + + for name, value in all_matches: + # Skip duplicates + if not any(field.name == name for field in self.form_fields): + self.form_fields.append(Sam3FormField(name=name, value=value)) + logger.debug(f"Found additional form field: {name} = {value}") + + def _build_username_payload(self, username: str) -> str: + """Build username submission payload""" + payload_parts = [] + + # Add all hidden fields + for field in self.form_fields: + payload_parts.append(f"{field.name}={field.value}") + + # Add username field + payload_parts.append(f"pw_usr={self._url_encode(username)}") + payload_parts.append("pw_submit=") + payload_parts.append("hidden_pwd=") + + return "&".join(payload_parts) + + def _build_password_payload(self, username: str, password: str) -> str: + """Build password submission payload""" + payload_parts = [] + + # Add all hidden fields (they might have changed) + for field in self.form_fields: + payload_parts.append(f"{field.name}={field.value}") + + # Add password fields + payload_parts.append(f"hidden_usr={self._url_encode(username)}") + payload_parts.append(f"pw_pwd={self._url_encode(password)}") + payload_parts.append("pw_submit=") + + return "&".join(payload_parts) + + def _extract_code_and_state(self, redirect_url: str) -> tuple[str, str]: + """Extract code and state from redirect URL""" + try: + parsed = urlparse(redirect_url) + query_params = parse_qs(parsed.query) + + code = query_params.get('code', [None])[0] + state = query_params.get('state', [None])[0] + + if code and state: + logger.debug(f"Extracted code: {code[:8]}..., state: {state}") + return code, state + + # Also check fragment (#) for SPA redirects + if '#' in redirect_url: + fragment = redirect_url.split('#')[1] + fragment_params = parse_qs(fragment) + code = fragment_params.get('code', [None])[0] + state = fragment_params.get('state', [None])[0] + + return code, state + + except Exception as e: + logger.error(f"Failed to extract code and state from URL: {e}") + return None, None + + @staticmethod + def _get_sso_headers() -> Dict[str, str]: + """Get headers for SSO requests""" + return { + 'User-Agent': SSO_USER_AGENT, + 'Content-Type': 'application/x-www-form-urlencoded', + 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', + 'Accept-Language': 'en-US,en;q=0.5', + 'Accept-Encoding': 'gzip, deflate, br', + 'Origin': 'https://web2.magentatv.de', + 'Referer': 'https://web2.magentatv.de/' + } + + @staticmethod + def _url_encode(value: str) -> str: + """URL encode a string""" + from urllib.parse import quote + return quote(value) + + # ======================================================================== + # Configuration Updates + # ======================================================================== + + def update_endpoints(self, openid_config: Dict[str, Any]) -> None: + """Update endpoints from OpenID configuration""" + # Update issuer URL from OpenID config + if 'issuer' in openid_config: + self.issuer_url = openid_config['issuer'] + + # Update other endpoints for user authentication flows + self.authorization_endpoint = openid_config.get('authorization_endpoint') + self.userinfo_endpoint = openid_config.get('userinfo_endpoint') + + # Get backchannel auth start endpoint + if 'backchannel_authentication_endpoint' in openid_config: + self.backchannel_start_url = openid_config['backchannel_authentication_endpoint'] + logger.debug(f"Backchannel auth endpoint from OpenID: {self.backchannel_start_url}") + + # OAuth token endpoint might be different from line auth endpoint + if 'token_endpoint' in openid_config: + self.oauth_token_endpoint = openid_config['token_endpoint'] + + logger.debug(f"Updated SAM3 endpoints: " + f"issuer={self.issuer_url}, " + f"auth={bool(self.authorization_endpoint)}, " + f"oauth_token={bool(self.oauth_token_endpoint)}, " + f"line_auth={bool(self.line_auth_endpoint)}, " + f"backchannel={bool(self.backchannel_start_url)}") \ No newline at end of file diff --git a/lib/streaming_providers/providers/magenta2/sso_client.py b/lib/streaming_providers/providers/magenta2/sso_client.py new file mode 100644 index 0000000..4cc6a13 --- /dev/null +++ b/lib/streaming_providers/providers/magenta2/sso_client.py @@ -0,0 +1,219 @@ +# streaming_providers/providers/magenta2/sso_client.py +from typing import Dict, Optional, Any + +from ...base.network import HTTPManager +from ...base.utils.logger import logger +from .constants import ( + SSO_URL, + SSO_USER_AGENT, + DEFAULT_REQUEST_TIMEOUT +) + + +class SsoClient: + """ + SSO (Single Sign-On) client implementing the complete SSO flow from C++ code + """ + + def __init__(self, http_manager: HTTPManager, session_id: str, device_id: str): + self.http_manager = http_manager + self.session_id = session_id + self.device_id = device_id + + def sso_login(self) -> str: + """ + Get SSO login redirect URL + Matching C++ SsoClient::SSOLogin() + """ + try: + logger.debug("Getting SSO login redirect URL") + + url = f"{SSO_URL}login" + headers = self._get_sso_headers() + + response = self.http_manager.get( + url, + operation='sso_login', + headers=headers, + timeout=DEFAULT_REQUEST_TIMEOUT + ) + response.raise_for_status() + + data = response.json() + + login_redirect_url = data.get('loginRedirectUrl') + if not login_redirect_url: + raise Exception("No loginRedirectUrl in SSO response") + + logger.debug(f"SSO login redirect URL obtained: {login_redirect_url}") + return login_redirect_url + + except Exception as e: + logger.error(f"SSO login failed: {e}") + raise Exception(f"SSO login failed: {e}") + + def sso_authenticate(self, code: str = "", state: str = "") -> Dict[str, Any]: + """ + SSO authentication with code/state or refresh token + Matching C++ SsoClient::SSOAuthenticate() + + Returns: + Dict with userInfo including userId, accountId, displayName, personaToken + """ + try: + logger.debug("Performing SSO authentication") + + url = f"{SSO_URL}authenticate" + headers = self._get_sso_headers() + + # Build request body matching C++ structure + if code and state: + # Authentication with authorization code + body = { + "checkRefreshToken": True, + "returnCode": { + "code": code, + "state": state + } + } + logger.debug(f"SSO auth with code: {code[:8]}..., state: {state}") + else: + # Authentication with refresh token only + body = { + "checkRefreshToken": True + } + logger.debug("SSO auth with refresh token only") + + response = self.http_manager.post( + url, + operation='sso_authenticate', + headers=headers, + json_data=body, + timeout=DEFAULT_REQUEST_TIMEOUT + ) + response.raise_for_status() + + data = response.json() + + # Check for userInfo in response + if 'userInfo' not in data: + raise Exception("No userInfo in SSO authentication response") + + user_info = data['userInfo'] + + # Extract required fields with fallbacks + result = { + 'userId': user_info.get('userId', ''), + 'accountId': user_info.get('accountId', ''), + 'displayName': user_info.get('displayName', ''), + 'personaToken': user_info.get('personaToken', ''), + 'raw_response': data + } + + # Validate required fields + if not result['userId'] or not result['accountId']: + logger.warning("SSO authentication missing required user identifiers") + + if not result['personaToken']: + logger.warning("SSO authentication did not return personaToken") + + logger.info( + f"SSO authentication successful: " + f"userId={result['userId']}, " + f"accountId={result['accountId']}, " + f"displayName={result['displayName'][:20]}... " + f"personaToken={'YES' if result['personaToken'] else 'NO'}" + ) + + return result + + except Exception as e: + logger.error(f"SSO authentication failed: {e}") + raise Exception(f"SSO authentication failed: {e}") + + def sso_refresh(self) -> Dict[str, Any]: + """ + Refresh SSO session using refresh token + This is essentially sso_authenticate without code/state + """ + return self.sso_authenticate() + + @staticmethod + def validate_persona_token(self, persona_token: str) -> bool: + """ + Validate persona token (optional enhancement) + Not in original C++ code but useful for token management + """ + try: + # Simple validation - check if token has expected structure + if not persona_token or len(persona_token) < 10: + return False + + # Could add more sophisticated validation here + # For now, just check if it looks like a JWT or base64 token + parts = persona_token.split('.') + if len(parts) == 3: + # Looks like a JWT + return True + else: + # Might be base64 encoded + import base64 + try: + decoded = base64.b64decode(persona_token + '==') + return len(decoded) > 0 + except: + return False + + except Exception as e: + logger.debug(f"Persona token validation failed: {e}") + return False + + def get_user_profile(self) -> Optional[Dict[str, Any]]: + """ + Get additional user profile information (optional enhancement) + Not in original C++ code + """ + try: + url = f"{SSO_URL}profile" + headers = self._get_sso_headers() + + response = self.http_manager.get( + url, + operation='sso_profile', + headers=headers, + timeout=DEFAULT_REQUEST_TIMEOUT + ) + response.raise_for_status() + + return response.json() + + except Exception as e: + logger.debug(f"Failed to get user profile: {e}") + return None + + def _get_sso_headers(self) -> Dict[str, str]: + """Get headers for SSO requests matching C++ implementation""" + headers = { + 'User-Agent': SSO_USER_AGENT, + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'origin': 'https://web2.magentatv.de', + 'referer': 'https://web2.magentatv.de/' + } + + # Add session and device headers if available + if self.session_id: + headers['session-id'] = self.session_id + if self.device_id: + headers['device-id'] = self.device_id + + return headers + + def debug_sso_state(self) -> Dict[str, Any]: + """Debug method to check SSO client state""" + return { + 'session_id': self.session_id, + 'device_id': self.device_id, + 'sso_url': SSO_URL, + 'headers_configured': bool(self.session_id and self.device_id) + } \ No newline at end of file diff --git a/lib/streaming_providers/providers/magenta2/taa_client.py b/lib/streaming_providers/providers/magenta2/taa_client.py new file mode 100644 index 0000000..3b7db6d --- /dev/null +++ b/lib/streaming_providers/providers/magenta2/taa_client.py @@ -0,0 +1,374 @@ +# streaming_providers/providers/magenta2/taa_client.py +import base64 +import json +import time +from typing import Dict, Optional, Any +from dataclasses import dataclass + +from ...base.network import HTTPManager +from ...base.utils.logger import logger +from .constants import ( + IDM, + APPVERSION2, + MAGENTA2_PLATFORMS, + DEFAULT_PLATFORM +) + + +@dataclass +class TaaAuthResult: + """Result of TAA authentication""" + access_token: str + refresh_token: Optional[str] = None + dc_cts_persona_token: Optional[str] = None + persona_id: Optional[str] = None + account_id: Optional[str] = None + consumer_id: Optional[str] = None + tv_account_id: Optional[str] = None + account_token: Optional[str] = None + account_uri: Optional[str] = None + token_exp: Optional[int] = None + raw_response: Optional[Dict[str, Any]] = None + device_limit_exceeded: bool = False + + +class TaaClient: + """ + Telekom Authentication and Authorization (TAA) client + Handles complete TAA authentication flow with proper JWT parsing + """ + + def __init__(self, http_manager: HTTPManager, platform: str = DEFAULT_PLATFORM): + self.http_manager = http_manager + self.platform = platform + self.platform_config = MAGENTA2_PLATFORMS.get(platform, MAGENTA2_PLATFORMS[DEFAULT_PLATFORM]) + + def authenticate(self, sam3_token: str, device_id: str, client_model: Optional[str] = None, + device_model: Optional[str] = None, taa_endpoint: Optional[str] = None) -> TaaAuthResult: + """ + Perform complete TAA authentication + + Args: + sam3_token: SAM3 access token for TAA scope + device_id: Device identifier + client_model: Client model from bootstrap + device_model: Device model from bootstrap + taa_endpoint: TAA endpoint URL + + Returns: + TaaAuthResult with complete authentication data + """ + try: + logger.debug("Starting TAA authentication") + + # Build complete TAA payload + taa_payload = self._build_complete_taa_payload( + sam3_token=sam3_token, + device_id=device_id, + client_model=client_model, + device_model=device_model + ) + + # Build headers + headers = self._get_taa_headers(sam3_token) + + # Use provided endpoint or fallback + endpoint = taa_endpoint or "https://taa.telekom-dienste.de/taa/v1/token" + + logger.debug(f"TAA request to: {endpoint}") + logger.debug(f"TAA payload keyValue: {taa_payload.get('keyValue', 'MISSING')}") + + # Perform TAA request + response = self.http_manager.post( + endpoint, + operation='taa_auth', + headers=headers, + json_data=taa_payload + ) + + # Check for device limit exceeded + if response.status_code == 400: + try: + error_data = response.json() + if error_data.get('deviceLimitExceeded'): + logger.error("Device limit exceeded in TAA authentication") + return TaaAuthResult( + access_token="", + device_limit_exceeded=True + ) + except (ValueError, KeyError): + pass # Will be handled by raise_for_status below + + response.raise_for_status() + taa_data = response.json() + + # Parse TAA response + result = self._parse_taa_response(taa_data) + + logger.info("TAA authentication successful") + return result + + except Exception as e: + logger.error(f"TAA authentication failed: {e}") + raise Exception(f"TAA authentication failed: {e}") + + def _build_complete_taa_payload(self, sam3_token: str, device_id: str, + client_model: Optional[str] = None, + device_model: Optional[str] = None) -> Dict[str, Any]: + """ + Build complete TAA payload matching C++ structure exactly + + C++ structure: + { + "keyValue": "IDM/APPVERSION2/TokenChannelParams(id=Tv)/TokenDeviceParams(id=...,model=...,os=...)/DE/telekom", + "accessToken": "...", + "accessTokenSource": "IDM", + "appVersion": "APPVERSION2", + "channel": {"id": "Tv"}, + "device": {"id": "...", "model": "...", "os": "..."}, + "natco": "DE", + "type": "telekom" + } + """ + # Use provided models or fallback to platform defaults + resolved_device_model = device_model or self.platform_config['device_name'] + resolved_client_model = client_model or f"ftv-{self.platform}" + + # Build keyValue string matching C++ format exactly + key_value_parts = [ + IDM, + APPVERSION2, + "TokenChannelParams(id=Tv)", + f"TokenDeviceParams(id={device_id},model={resolved_device_model},os={self.platform_config['firmware']})", + "DE", + "telekom" + ] + + key_value = "/".join(key_value_parts) + + # Build complete payload matching C++ structure + payload = { + "keyValue": key_value, + "accessToken": sam3_token, + "accessTokenSource": IDM, + "appVersion": APPVERSION2, + "channel": { + "id": "Tv" + }, + "device": { + "id": device_id, + "model": resolved_device_model, + "os": self.platform_config['firmware'] + }, + "natco": "DE", + "type": "telekom" + } + + # Add client model if available (not in original C++ but useful) + if resolved_client_model: + payload["client"] = {"model": resolved_client_model} + + logger.debug(f"Built TAA payload with keyValue: {key_value}") + return payload + + def _parse_taa_response(self, taa_data: Dict[str, Any]) -> TaaAuthResult: + """ + Parse TAA response and extract all required claims from JWT + """ + # Handle different response key formats + access_token = taa_data.get('access_token', taa_data.get('accessToken')) + refresh_token = taa_data.get('refresh_token', taa_data.get('refreshToken')) + + if not access_token: + raise ValueError("No access token in TAA response") + + # Parse JWT to extract all claims + jwt_claims = self._parse_taa_jwt_complete(access_token) + + # Create result with all extracted data + result = TaaAuthResult( + access_token=access_token, + refresh_token=refresh_token, + dc_cts_persona_token=jwt_claims.get('dc_cts_persona_token'), + persona_id=jwt_claims.get('persona_id'), + account_id=jwt_claims.get('account_id'), + consumer_id=jwt_claims.get('consumer_id'), + tv_account_id=jwt_claims.get('tv_account_id'), + account_token=jwt_claims.get('account_token'), + account_uri=jwt_claims.get('account_uri'), + token_exp=jwt_claims.get('token_exp'), + raw_response=taa_data + ) + + # Log critical fields + if result.dc_cts_persona_token: + logger.debug("✓ dc_cts_persona_token found in TAA JWT") + else: + logger.warning("✗ dc_cts_persona_token NOT found in TAA JWT") + + if result.account_uri: + logger.debug(f"✓ account_uri found: {result.account_uri}") + else: + logger.warning("✗ account_uri NOT found in TAA JWT") + + return result + + def _parse_taa_jwt_complete(self, jwt_token: str) -> Dict[str, Any]: + """ + Complete JWT parsing extracting ALL required fields from TAA token + """ + try: + parts = jwt_token.split('.') + if len(parts) != 3: + logger.warning("Invalid JWT format in TAA token") + 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"TAA JWT claims: {list(claims.keys())}") + + result = {} + + # Enhanced claim mappings - ALL fields from C++ implementation + claim_mappings = { + # Core persona token (most important!) + 'dc_cts_persona_token': [ + 'dc_cts_persona_token', + 'personaToken', + 'urn:telekom:ott:dc_cts_persona_token' + ], + + # Account URI (needed for composition!) + 'account_uri': [ + 'dc_cts_account_uri', + 'accountUri', + 'urn:telekom:ott:dc_cts_account_uri', + 'mpxAccountUri' + ], + + # IDs + 'persona_id': [ + 'dc_cts_personaId', + 'personaId', + 'urn:telekom:ott:dc_cts_personaId' + ], + 'account_id': [ + 'dc_cts_accountId', + 'accountId', + 'urn:telekom:ott:dc_cts_accountId' + ], + 'consumer_id': [ + 'dc_cts_consumerId', + 'consumerId', + 'urn:telekom:ott:dc_cts_consumerId' + ], + 'tv_account_id': [ + 'dc_tvAccountId', + 'tvAccountId', + 'urn:telekom:ott:dc_tvAccountId' + ], + + # Account token + 'account_token': [ + 'dc_cts_account_token', + 'accountToken', + 'urn:telekom:ott:dc_cts_account_token' + ], + } + + # Extract all claims + for target_key, source_keys in claim_mappings.items(): + for source_key in source_keys: + if source_key in claims: + result[target_key] = claims[source_key] + logger.debug(f"Extracted TAA claim {target_key} from {source_key}") + break + + # Extract token expiration + if 'exp' in claims: + result['token_exp'] = claims['exp'] + logger.debug(f"TAA token expires at: {claims['exp']}") + + # Extract issuance time + if 'iat' in claims: + result['token_iat'] = claims['iat'] + + # CRITICAL CHECK: Verify we have the essential fields + essential_fields = ['dc_cts_persona_token', 'account_uri'] + missing_essential = [field for field in essential_fields if field not in result] + + if missing_essential: + logger.error(f"CRITICAL: Missing essential TAA claims: {missing_essential}") + logger.debug(f"Available TAA claims: {list(claims.keys())}") + else: + logger.info("✓ All essential TAA claims found") + + return result + + except Exception as e: + logger.error(f"Failed to parse TAA JWT completely: {e}") + return {} + + def _get_taa_headers(self, sam3_token: str) -> Dict[str, str]: + """Get headers for TAA requests""" + return { + 'User-Agent': self.platform_config['user_agent'], + 'Accept': 'application/json', + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {sam3_token}' + } + + def validate_taa_token(self, taa_token: str) -> bool: + """ + Validate TAA token expiration and basic structure + """ + try: + if not taa_token: + return False + + # Check if token is expired + claims = self._parse_taa_jwt_complete(taa_token) + token_exp = claims.get('token_exp') + + if token_exp and token_exp < time.time(): + logger.debug("TAA token is expired") + return False + + # Check for essential claims + if claims.get('dc_cts_persona_token') and claims.get('account_uri'): + return True + + return False + + except Exception as e: + logger.debug(f"TAA token validation failed: {e}") + return False + + def debug_taa_token(self, taa_token: str) -> Dict[str, Any]: + """ + Debug method to analyze TAA token contents + """ + claims = self._parse_taa_jwt_complete(taa_token) + + return { + 'token_structure': 'VALID' if len(taa_token.split('.')) == 3 else 'INVALID', + 'claims_available': list(claims.keys()), + 'essential_claims': { + 'dc_cts_persona_token': bool(claims.get('dc_cts_persona_token')), + 'account_uri': bool(claims.get('account_uri')), + 'persona_id': bool(claims.get('persona_id')), + 'account_id': bool(claims.get('account_id')) + }, + 'token_expiration': { + 'exp': claims.get('token_exp'), + 'is_expired': claims.get('token_exp', 0) < time.time() if claims.get('token_exp') else None, + 'current_time': time.time() + } if claims.get('token_exp') else None + } \ No newline at end of file diff --git a/resources/settings.xml b/resources/settings.xml index 62b0eac..8e014c1 100644 --- a/resources/settings.xml +++ b/resources/settings.xml @@ -67,6 +67,27 @@ + + + 0 + + false + + + + + 0 + + false + + + + + + + + +