diff --git a/lib/streaming_providers/base/auth/session_manager.py b/lib/streaming_providers/base/auth/session_manager.py index 1dbc3c8..b0826e3 100644 --- a/lib/streaming_providers/base/auth/session_manager.py +++ b/lib/streaming_providers/base/auth/session_manager.py @@ -234,7 +234,7 @@ class SessionManager: def load_scoped_token(self, provider: str, scope: str, country: Optional[str] = None) -> Optional[Dict[str, Any]]: """ - Load token data for a specific scope + Load token data for a specific scope (ENHANCED VERSION) Args: provider: Provider name @@ -243,6 +243,11 @@ class SessionManager: Returns: Token data dictionary or None + + Enhanced: + - Returns token even if access_token expired (for refresh attempts) + - Properly handles yo_digital tokens with separate expiry times + - Logs detailed expiration status """ country_str = f" (country: {country})" if country else "" @@ -266,10 +271,28 @@ class SessionManager: 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): + # Check access token expiration + access_token_expired = self._is_token_expired(token_data) + + # For yo_digital, also check refresh token + if scope == 'yo_digital' and 'refresh_token' in token_data: + refresh_token_expired = self._is_refresh_token_expired(token_data) + + if access_token_expired and not refresh_token_expired: + logger.info(f"Access token expired for scope '{scope}' but refresh token still valid") + return token_data # Return so caller can attempt refresh + elif access_token_expired and refresh_token_expired: + logger.warning(f"Both access and refresh tokens expired for scope '{scope}'") + return None # Both expired, no point returning + else: + logger.info(f"Loaded valid token for {provider}{country_str}/{scope}") + return token_data + + # Standard token handling (tvhubs, taa, etc.) + if access_token_expired: logger.info(f"Token for scope '{scope}' is expired") - return token_data # Return anyway so refresh can be attempted + # Still return it - caller might want to attempt refresh or re-auth + return token_data logger.info(f"Loaded valid token for {provider}{country_str}/{scope}") return token_data @@ -279,22 +302,103 @@ class SessionManager: """ Check if token is expired with buffer + Enhanced to support multiple expiration formats: + 1. Standard format: expires_in + issued_at (for access_token) + 2. yo_digital format: separate expiry for access_token and refresh_token + Args: token_data: Token data dictionary buffer_seconds: Seconds buffer before expiry (default 5 minutes) Returns: True if expired, False otherwise + + Note: + For yo_digital tokens with both access_token and refresh_token, + this checks the access_token expiration only (not refresh_token). """ - 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) + # Format 1: Standard single token expiration + # Used by: tvhubs tokens, taa tokens, SAM3 tokens + if 'expires_in' in token_data and 'issued_at' in token_data: + expires_in = token_data.get('expires_in', 0) + issued_at = token_data.get('issued_at', 0) + expires_at = issued_at + expires_in + + is_expired = current_time >= (expires_at - buffer_seconds) + + if is_expired: + logger.debug(f"Token expired (standard format): " + f"issued_at={issued_at}, expires_in={expires_in}, " + f"expires_at={expires_at}, current={current_time}") + + return is_expired + + # Format 2: yo_digital separate expiration for access_token + # yo_digital tokens have separate expiry for access and refresh tokens + if 'access_token_expires_in' in token_data and 'access_token_issued_at' in token_data: + expires_in = token_data.get('access_token_expires_in', 0) + issued_at = token_data.get('access_token_issued_at', 0) + expires_at = issued_at + expires_in + + is_expired = current_time >= (expires_at - buffer_seconds) + + if is_expired: + logger.debug(f"Access token expired (yo_digital format): " + f"issued_at={issued_at}, expires_in={expires_in}, " + f"expires_at={expires_at}, current={current_time}") + + return is_expired + + # Format 3: Direct expiration timestamp (if some API returns 'exp' or 'expires_at') + if 'expires_at' in token_data: + expires_at = token_data.get('expires_at', 0) + is_expired = current_time >= (expires_at - buffer_seconds) + + if is_expired: + logger.debug(f"Token expired (direct timestamp): " + f"expires_at={expires_at}, current={current_time}") + + return is_expired + + # No expiration info available - assume valid + logger.debug("No expiration info in token data - assuming valid") + return False + + @staticmethod + def _is_refresh_token_expired(token_data: Dict[str, Any], buffer_seconds: int = 300) -> bool: + """ + Check if refresh token is expired (for yo_digital tokens) + + This is a separate check specifically for yo_digital refresh tokens + which have their own expiration separate from access tokens. + + Args: + token_data: Token data dictionary + buffer_seconds: Seconds buffer before expiry (default 5 minutes) + + Returns: + True if refresh token expired, False otherwise or if no refresh token + """ + # yo_digital format with separate refresh token expiry + if 'refresh_token_expires_in' in token_data and 'refresh_token_issued_at' in token_data: + current_time = time.time() + expires_in = token_data.get('refresh_token_expires_in', 0) + issued_at = token_data.get('refresh_token_issued_at', 0) + expires_at = issued_at + expires_in + + is_expired = current_time >= (expires_at - buffer_seconds) + + if is_expired: + logger.debug(f"Refresh token expired (yo_digital format): " + f"issued_at={issued_at}, expires_in={expires_in}, " + f"expires_at={expires_at}, current={current_time}") + + return is_expired + + # No refresh token or no expiration info + return False def save_token(self, provider: str, token: BaseAuthToken, country: Optional[str] = None) -> bool: diff --git a/lib/streaming_providers/base/settings/settings_manager.py b/lib/streaming_providers/base/settings/settings_manager.py index b5d3b86..7a04508 100644 --- a/lib/streaming_providers/base/settings/settings_manager.py +++ b/lib/streaming_providers/base/settings/settings_manager.py @@ -547,6 +547,16 @@ class SettingsManager: all_countries = self.session_manager.get_all_countries(provider_name) status['configured_countries'] = all_countries + scoped_tokens = {} + available_scopes = self.list_scoped_tokens(provider_name, country) + + for scope in available_scopes: + scope_status = self.get_scoped_token_status(provider_name, scope, country) + scoped_tokens[scope] = scope_status + + if scoped_tokens: + status['scoped_tokens'] = scoped_tokens + return status def is_provider_ready(self, provider_name: str, country: Optional[str] = None) -> bool: @@ -1122,6 +1132,189 @@ class SettingsManager: logger.error(f"Error migrating {provider_name} to country structure: {e}") return False + # ============= Scoped Token Management (Pass-through to SessionManager) ============= + + def save_scoped_token(self, provider_name: str, scope: str, token_data: Dict[str, Any], + country: Optional[str] = None) -> bool: + """ + Save authentication token for a specific scope + + Pass-through method to SessionManager.save_scoped_token() + + Args: + provider_name: Name of the provider + scope: Token scope (e.g., 'tvhubs', 'taa', 'yo_digital') + token_data: Token data to save (should include access_token, expires_in, etc.) + country: Optional country code + + Returns: + True if successful, False otherwise + + Examples: + # Save tvhubs token (access token only) + settings_manager.save_scoped_token('magenta2', 'tvhubs', { + 'access_token': '...', + 'token_type': 'Bearer', + 'expires_in': 7200, + 'issued_at': 1234567890 + }, 'de') + + # Save yo_digital token (access + refresh, each with own expiry) + settings_manager.save_scoped_token('magenta2', 'yo_digital', { + 'access_token': '...', + 'access_token_expires_in': 3600, + 'access_token_issued_at': 1234567890, + 'refresh_token': '...', + 'refresh_token_expires_in': 86400, + 'refresh_token_issued_at': 1234567890, + 'token_type': 'Bearer' + }, 'de') + """ + return self.session_manager.save_scoped_token(provider_name, scope, token_data, country) + + def load_scoped_token(self, provider_name: str, scope: str, + country: Optional[str] = None) -> Optional[Dict[str, Any]]: + """ + Load token data for a specific scope + + Pass-through method to SessionManager.load_scoped_token() + + Args: + provider_name: Name of the provider + scope: Token scope (e.g., 'tvhubs', 'taa', 'yo_digital') + country: Optional country code + + Returns: + Token data dictionary or None if not found or expired + + Note: + This method checks token expiration automatically. + For yo_digital tokens, it checks both access_token and refresh_token expiry. + """ + return self.session_manager.load_scoped_token(provider_name, scope, country) + + def clear_scoped_token(self, provider_name: str, scope: str, + country: Optional[str] = None) -> bool: + """ + Clear token for a specific scope + + Pass-through method to SessionManager.clear_scoped_token() + + Args: + provider_name: Name of the provider + scope: Token scope to clear + country: Optional country code + + Returns: + True if successful, False otherwise + + Example: + # Clear only the tvhubs token, keep taa and yo_digital + settings_manager.clear_scoped_token('magenta2', 'tvhubs', 'de') + """ + return self.session_manager.clear_scoped_token(provider_name, scope, country) + + def list_scoped_tokens(self, provider_name: str, country: Optional[str] = None) -> List[str]: + """ + List all available token scopes for a provider + + Args: + provider_name: Name of the provider + country: Optional country code + + Returns: + List of scope names (e.g., ['tvhubs', 'taa', 'yo_digital']) + + Example: + scopes = settings_manager.list_scoped_tokens('magenta2', 'de') + # Returns: ['tvhubs', 'taa', 'yo_digital'] + """ + session_data = self.session_manager.load_session(provider_name, country) + if not session_data: + return [] + + # Filter out non-scope keys (device_id, refresh_token at provider level) + non_scope_keys = {'device_id', 'refresh_token'} + scopes = [ + key for key in session_data.keys() + if isinstance(session_data[key], dict) and key not in non_scope_keys + ] + + return scopes + + def get_scoped_token_status(self, provider_name: str, scope: str, + country: Optional[str] = None) -> Dict[str, Any]: + """ + Get detailed status information for a scoped token + + Args: + provider_name: Name of the provider + scope: Token scope + country: Optional country code + + Returns: + Dictionary with token status information + + Example: + status = settings_manager.get_scoped_token_status('magenta2', 'yo_digital', 'de') + # Returns: + # { + # 'exists': True, + # 'access_token_valid': True, + # 'access_token_expires_at': 1234567890, + # 'refresh_token_valid': True, + # 'refresh_token_expires_at': 1234654290, + # 'scope': 'yo_digital' + # } + """ + token_data = self.load_scoped_token(provider_name, scope, country) + + if not token_data: + return { + 'exists': False, + 'scope': scope, + 'provider_name': provider_name, + 'country': country + } + + status = { + 'exists': True, + 'scope': scope, + 'provider_name': provider_name, + 'country': country, + 'token_type': token_data.get('token_type', 'Bearer') + } + + # Check access token expiration + if 'access_token' in token_data: + status['has_access_token'] = True + + # Standard expiration (single expires_in and issued_at) + if 'expires_in' in token_data and 'issued_at' in token_data: + expires_at = token_data['issued_at'] + token_data['expires_in'] + status['access_token_expires_at'] = expires_at + status['access_token_valid'] = time.time() < expires_at + + # yo_digital style (separate access_token_expires_in) + elif 'access_token_expires_in' in token_data and 'access_token_issued_at' in token_data: + expires_at = token_data['access_token_issued_at'] + token_data['access_token_expires_in'] + status['access_token_expires_at'] = expires_at + status['access_token_valid'] = time.time() < expires_at + else: + status['access_token_valid'] = None # Cannot determine + + # Check refresh token expiration (for yo_digital) + if 'refresh_token' in token_data: + status['has_refresh_token'] = True + + if 'refresh_token_expires_in' in token_data and 'refresh_token_issued_at' in token_data: + expires_at = token_data['refresh_token_issued_at'] + token_data['refresh_token_expires_in'] + status['refresh_token_expires_at'] = expires_at + status['refresh_token_valid'] = time.time() < expires_at + else: + status['refresh_token_valid'] = None # Cannot determine + + return status # For imports that expect the old interface UnifiedSettingsManager = SettingsManager # Backward compatibility alias