From bb351a4723c436d69c8ffe253926440cb6775730 Mon Sep 17 00:00:00 2001 From: Nirvana Date: Fri, 19 Dec 2025 23:17:20 +0100 Subject: [PATCH] Add web config --- lib/streaming_providers/base/models/auth.py | 117 +++++++ lib/streaming_providers/base/provider.py | 315 +++++++++++++++++- .../base/settings/settings_manager.py | 134 -------- lib/streaming_providers/providers/auth.py | 73 ++++ .../providers/auth_builder.py | 254 ++++++++++++++ .../providers/auth_context.py | 47 +++ .../providers/hrti/provider.py | 4 + .../providers/joyn/provider.py | 4 + .../providers/magenta2/provider.py | 12 +- .../providers/magentaeu/provider.py | 4 + .../providers/rtlplus/provider.py | 6 +- resources/web/config.js | 301 ++++++++++++----- service.py | 104 ++++-- 13 files changed, 1117 insertions(+), 258 deletions(-) create mode 100644 lib/streaming_providers/base/models/auth.py create mode 100644 lib/streaming_providers/providers/auth.py create mode 100644 lib/streaming_providers/providers/auth_builder.py create mode 100644 lib/streaming_providers/providers/auth_context.py diff --git a/lib/streaming_providers/base/models/auth.py b/lib/streaming_providers/base/models/auth.py new file mode 100644 index 0000000..18ef7e0 --- /dev/null +++ b/lib/streaming_providers/base/models/auth.py @@ -0,0 +1,117 @@ +# streaming_providers/base/models/auth.py +from dataclasses import dataclass, field +from typing import Dict, Any, Optional +from enum import Enum +import time + + +class AuthState(Enum): + """Standardized authentication states""" + NOT_AUTHENTICATED = "not_authenticated" + AUTHENTICATED = "authenticated" + EXPIRED = "expired" + PENDING = "pending" + ERROR = "error" + NOT_APPLICABLE = "not_applicable" + + +@dataclass +class TokenInfo: + """Standardized token information""" + scope: str + has_token: bool + is_valid: bool + expires_at: Optional[float] = None + has_refresh_token: bool = False + auth_level: Optional[str] = None + + def to_dict(self) -> Dict[str, Any]: + return { + 'scope': self.scope, + 'has_token': self.has_token, + 'is_valid': self.is_valid, + 'expires_at': self.expires_at, + 'has_refresh_token': self.has_refresh_token, + 'auth_level': self.auth_level + } + + +@dataclass +class AuthStatus: + """Complete authentication status for a provider""" + # Core identification + provider_name: str + provider_label: str + country: str + + # Authentication + auth_type: str + auth_state: AuthState + + # Readiness + is_ready: bool + readiness_reason: Optional[str] = None + + # Tokens + primary_token_scope: Optional[str] = None + token_scopes: Dict[str, TokenInfo] = field(default_factory=dict) + has_valid_token: bool = False + + # Credentials + requires_stored_credentials: bool = True + has_credentials: bool = False + credentials_type: Optional[str] = None + + # Metadata + timestamp: float = field(default_factory=time.time) + last_authentication: Optional[float] = None + provider_specific: Dict[str, Any] = field(default_factory=dict) + + def to_dict(self) -> Dict[str, Any]: + """Convert to API response format""" + result = { + # Identification + 'provider': f"{self.provider_name}_{self.country}", + 'provider_name': self.provider_name, + 'provider_label': self.provider_label, + 'country': self.country, + + # Authentication + 'auth_type': self.auth_type, + 'auth_state': self.auth_state.value, + + # Readiness + 'is_ready': self.is_ready, + 'readiness_reason': self.readiness_reason, + + # Tokens + 'primary_token_scope': self.primary_token_scope, + 'has_valid_token': self.has_valid_token, + 'token_scopes': { + scope: token.to_dict() + for scope, token in self.token_scopes.items() + }, + + # Credentials + 'requires_stored_credentials': self.requires_stored_credentials, + 'has_credentials': self.has_credentials, + 'credentials_type': self.credentials_type, + + # Metadata + 'timestamp': self.timestamp, + 'last_authentication': self.last_authentication, + 'provider_specific': self.provider_specific + } + + # Add state description + descriptions = { + AuthState.NOT_AUTHENTICATED: "Not authenticated", + AuthState.AUTHENTICATED: "Successfully authenticated", + AuthState.EXPIRED: "Authentication expired", + AuthState.PENDING: "Authentication in progress", + AuthState.ERROR: "Authentication error", + AuthState.NOT_APPLICABLE: "Authentication not required" + } + result['auth_state_description'] = descriptions.get(self.auth_state, "Unknown state") + + return result \ No newline at end of file diff --git a/lib/streaming_providers/base/provider.py b/lib/streaming_providers/base/provider.py index 3f789e9..0e9b7ef 100644 --- a/lib/streaming_providers/base/provider.py +++ b/lib/streaming_providers/base/provider.py @@ -1,7 +1,75 @@ # streaming_providers/base/provider.py - Enhanced with Header Abstractions +""" +Streaming Provider Base Class + +Authentication System: +--------------------- +Providers implement authentication through three core components: + +1. CAPABILITIES (Declarative): + - supported_auth_types: List[str] - What auth methods the provider CAN use + - preferred_auth_type: str - Which method SHOULD be used by default + +2. STATE (Dynamic): + - get_current_auth_type(context) -> str - Which method IS currently active + - get_auth_status(context) -> AuthStatus - Complete authentication status + +3. LOGIC (Optional Overrides): + - _calculate_auth_state(context) -> Optional[AuthState] - Custom auth state logic + - _calculate_readiness(context) -> Optional[Tuple[bool, str]] - Custom readiness + - get_auth_details(context) -> Dict[str, Any] - Provider-specific details + +Auth Type Definitions: + - 'user_credentials': Username/password (Joyn, RTL+) + - 'client_credentials': Client ID/secret (Joyn fallback, some APIs) + - 'network_based': Fixed-line/network auth (Magenta2, cable providers) + - 'anonymous': No auth needed (ZDF, ARD) + - 'device_registration': Device-based auth (Smart TV apps) + - 'embedded_client': Built-in credentials + +Implementation Examples: +---------------------- +# Simple provider (ZDF) +class ZDFProvider(StreamingProvider): + @property + def supported_auth_types(self) -> List[str]: + return ['anonymous'] # Just one type + +# Multi-auth provider (Joyn) +class JoynProvider(StreamingProvider): + @property + def supported_auth_types(self) -> List[str]: + return ['client_credentials', 'user_credentials'] + + @property + def preferred_auth_type(self) -> str: + return 'user_credentials' # Prefer full access + + def get_current_auth_type(self, context: AuthContext) -> str: + # Custom logic to detect current auth mode + token = context.get_token(self.provider_name, None, self.country) + return 'user_credentials' if token and token.get('auth_level') == 'user_authenticated' else 'client_credentials' + +# Network-based provider (Magenta2) +class Magenta2Provider(StreamingProvider): + @property + def supported_auth_types(self) -> List[str]: + return ['network_based'] + + @property + def primary_token_scope(self) -> Optional[str]: + return 'yo_digital' # Uses scoped tokens + + def _calculate_readiness(self, context: AuthContext): + # Check multiple token scopes + yo_token = context.get_token(self.provider_name, 'yo_digital', self.country) + if yo_token and not context.session._is_token_expired(yo_token): + return True, "Has valid streaming token" + return False, "No valid tokens found" +""" from abc import ABC, abstractmethod -from typing import Dict, List, Optional, Callable +from typing import Dict, List, Optional, Callable, Any from enum import Enum import json from datetime import datetime @@ -11,6 +79,7 @@ from .models.drm_models import DRMConfig from .models.proxy_models import ProxyConfig from .network import HTTPManagerFactory, HTTPManager from .utils.logger import logger +from ..providers.auth import AuthContext, AuthStatus class AuthType(Enum): @@ -448,16 +517,6 @@ class StreamingProvider(ABC): """ return self.catchup_window > 0 - @property - def requires_user_credentials(self) -> bool: - """ - Some providers do not need to authenticate - - Returns: - bool: True if user credentials are required - """ - return True - def get_epg(self, channel_id: str, start_time: Optional[datetime] = None, end_time: Optional[datetime] = None, @@ -737,4 +796,236 @@ class StreamingProvider(ABC): # Single-country providers accept any country (or ignore it) return True - return country.lower() in [c.lower() for c in cls.SUPPORTED_COUNTRIES] \ No newline at end of file + return country.lower() in [c.lower() for c in cls.SUPPORTED_COUNTRIES] + + def validate_auth_type(self, auth_type: str) -> bool: + """ + Check if an auth type is supported by this provider. + + Useful for: + - Validating user input in configuration UI + - Safely switching auth modes + - Error messages when unsupported auth is requested + + Args: + auth_type: Auth type to check (e.g., 'user_credentials') + + Returns: + True if supported, False otherwise + + Example: + if provider.validate_auth_type('user_credentials'): + # Safe to request user credentials + """ + return auth_type in self.supported_auth_types + + def get_auth_type_description(self, auth_type: str) -> str: + """ + Get human-readable description of an auth type. + + Args: + auth_type: Auth type to describe + + Returns: + Description string or empty string if not supported + """ + descriptions = { + 'user_credentials': 'Username and password authentication', + 'client_credentials': 'Client ID and secret authentication', + 'network_based': 'Network/fixed-line authentication', + 'anonymous': 'No authentication required', + 'device_registration': 'Device registration authentication', + 'embedded_client': 'Built-in credentials authentication' + } + + if auth_type in descriptions: + return descriptions[auth_type] + + # For custom auth types + return f"Custom authentication: {auth_type}" + + def get_auth_requirements(self, auth_type: str) -> Dict[str, Any]: + """ + Get requirements for a specific auth type. + + Args: + auth_type: Auth type to get requirements for + + Returns: + Dictionary with requirement information + + Raises: + ValueError: If auth_type is not supported + """ + if not self.validate_auth_type(auth_type): + raise ValueError(f"Auth type '{auth_type}' not supported by {self.provider_name}") + + requirements = { + 'auth_type': auth_type, + 'needs_storage': auth_type in ['user_credentials', 'client_credentials'], + 'provides_token': auth_type != 'anonymous', + 'user_interaction_required': auth_type in ['user_credentials', 'device_registration'] + } + + # Type-specific details + if auth_type == 'user_credentials': + requirements.update({ + 'fields': ['username', 'password'], + 'optional_fields': ['client_id'], + 'storage_key': 'user_password' + }) + elif auth_type == 'client_credentials': + requirements.update({ + 'fields': ['client_id', 'client_secret'], + 'storage_key': 'client_credentials' + }) + elif auth_type == 'network_based': + requirements.update({ + 'description': 'Authenticates via your network provider', + 'automatic': True + }) + + return requirements + + # ===== AUTHENTICATION PROPERTIES AND METHODS ===== + + @property + @abstractmethod + def supported_auth_types(self) -> List[str]: + """List of authentication types this provider supports.""" + pass + + @property + def preferred_auth_type(self) -> str: + """Preferred authentication type (first in supported list).""" + types = self.supported_auth_types + return types[0] if types else 'unknown' + + @property + def requires_stored_credentials(self) -> bool: + """True if provider needs credentials stored in settings.""" + credential_types = ['user_credentials', 'client_credentials'] + return any(auth_type in credential_types + for auth_type in self.supported_auth_types) + + # ===== AUTHENTICATION PROPERTIES ===== + + def get_current_auth_type(self, context: AuthContext) -> str: + """ + Determine which auth type is currently active. + + Default implementation checks tokens/credentials. + Override for providers with complex auth logic. + + Args: + context: AuthContext for accessing tokens/credentials + + Returns: + Current active auth type + """ + return self._determine_current_auth_type_default(context) + + def _determine_current_auth_type_default(self, context: AuthContext) -> str: + """ + Default logic for determining current auth type. + Providers can override get_current_auth_type() directly instead. + """ + # 1. Check if provider requires stored credentials + if self.requires_stored_credentials: + credentials = context.get_credentials(self.provider_name, self.country) + if credentials: + # Map credential type to auth type + if hasattr(credentials, 'credential_type'): + if credentials.credential_type == 'user_password': + return 'user_credentials' + elif credentials.credential_type == 'client_credentials': + return 'client_credentials' + + # 2. Check token auth level + primary_token = context.get_token( + self.provider_name, + self.primary_token_scope, + self.country + ) + if primary_token: + auth_level = primary_token.get('auth_level') + if auth_level == 'user_authenticated': + return 'user_credentials' + elif auth_level == 'client_credentials': + return 'client_credentials' + elif auth_level == 'anonymous': + return 'anonymous' + elif auth_level == 'network_based': + return 'network_based' + + # 3. Return first supported type as default + return self.preferred_auth_type + + # Token management properties (keep these) + @property + def primary_token_scope(self) -> Optional[str]: + """ + Primary token scope for this provider. + None = uses root-level token or no token needed. + + Returns: + Token scope string or None + """ + return None + + @property + def token_scopes(self) -> List[str]: + """ + All token scopes this provider uses. + + Returns: + List of token scope strings + """ + scope = self.primary_token_scope + return [scope] if scope else [] + + def get_auth_status(self, context: AuthContext) -> 'AuthStatus': + """ + Get authentication status for this provider. + Uses AuthStatusBuilder by default. + + Override only for providers with special requirements. + + Args: + context: AuthContext with access to settings + + Returns: + AuthStatus object + """ + from ..providers.auth_builder import AuthStatusBuilder # Import here to avoid circular imports + return AuthStatusBuilder.for_provider(self, context) + + # Optional override methods for providers with special logic + def _calculate_auth_state(self, context: AuthContext): + """ + Override to provide custom auth state calculation. + Return None to use standard calculation. + + Returns: + AuthState or None + """ + return None + + def _calculate_readiness(self, context: AuthContext): + """ + Override to provide custom readiness calculation. + Return None to use standard calculation. + + Returns: + Tuple of (is_ready: bool, reason: str) or None + """ + return None + + def get_auth_details(self, context: AuthContext) -> Dict[str, Any]: + """ + Override to provide provider-specific auth details. + + Returns: + Dictionary with provider-specific information + """ + return {} diff --git a/lib/streaming_providers/base/settings/settings_manager.py b/lib/streaming_providers/base/settings/settings_manager.py index 42cb51e..3e2ffa7 100644 --- a/lib/streaming_providers/base/settings/settings_manager.py +++ b/lib/streaming_providers/base/settings/settings_manager.py @@ -1361,140 +1361,6 @@ class SettingsManager: # No country suffix detected return provider_name, None - - def get_auth_status(self, provider_name: str) -> Dict[str, Any]: - """ - Get comprehensive authentication status for a provider - - Determines the "real" authentication state accounting for: - - Providers with credentials but no token (lazy auth) - - Expired vs valid tokens - - Different credential types - - Args: - provider_name: Provider name, optionally with country (e.g., "joyn_de") - - Returns: - Dictionary with authentication status information - """ - # Parse provider and country - provider, country = self.parse_provider_country(provider_name) - - # Check if provider is registered - if not self.is_provider_registered(provider): - return { - 'provider': provider_name, - 'auth_state': 'not_authenticated', - 'error': f'Provider "{provider}" is not registered', - 'is_ready': False - } - - # Load credentials - credentials = self.get_provider_credentials(provider, country) - has_credentials = credentials is not None and credentials.validate() - - # Load token data - token_data = self.load_token_data(provider, country) - has_token = token_data is not None - - # Check token expiration (using same buffer as SessionManager) - token_valid = False - token_expires_at = None - - if has_token: - # Use SessionManager's expiration check - token_valid = not self.session_manager._is_token_expired(token_data, buffer_seconds=300) - - # Calculate expiration timestamp - if 'issued_at' in token_data and 'expires_in' in token_data: - token_expires_at = token_data['issued_at'] + token_data['expires_in'] - - # Determine authentication state - auth_state = self._determine_auth_state( - has_credentials=has_credentials, - has_token=has_token, - token_valid=token_valid, - credential_type=credentials.credential_type if credentials else None, - token_auth_level=token_data.get('auth_level') if has_token else None - ) - - # Build response - status = { - 'provider': provider_name, - 'auth_state': auth_state, - 'credential_type': credentials.credential_type if credentials else None, - 'has_credentials': has_credentials, - 'has_active_token': token_valid, - 'token_expires_at': token_expires_at, - 'is_ready': auth_state in ['user_authenticated', 'client_authenticated'] - } - - return status - - @staticmethod - def _determine_auth_state(has_credentials: bool, has_token: bool, - token_valid: bool, credential_type: Optional[str], - token_auth_level: Optional[str] = None) -> str: - """ - Determine authentication state from credential and token status - ENHANCED: Now considers token classification when credentials not saved - - Args: - has_credentials: Whether valid credentials exist in credential manager - has_token: Whether token data exists in session manager - token_valid: Whether token is valid (not expired) - credential_type: Type of credential ("user_password" or "client_credentials") - token_auth_level: Token's authentication level from metadata - - Returns: - One of: "not_authenticated", "credentials_only", - "user_authenticated", "client_authenticated" - """ - # CASE 1: Valid token exists - check its classification FIRST - if has_token and token_valid: - if token_auth_level == "client_credentials": - return "client_authenticated" - elif token_auth_level == "user_authenticated": - return "user_authenticated" - # If token has unknown/no auth_level but is valid, still authenticated - elif token_auth_level == "anonymous": - return "client_authenticated" # Anonymous tokens count as client auth - elif token_auth_level == "unknown": - # Valid token but unknown type - assume client credentials - return "client_authenticated" - - # CASE 2: Token exists but expired - credentials determine state - if has_token and not token_valid and has_credentials: - return "credentials_only" - - # CASE 3: No token but has credentials (lazy auth - hasn't authenticated yet) - if not has_token and has_credentials: - return "credentials_only" - - # CASE 4: Valid token exists but no credentials saved (common for client credentials) - # This handles cases like Joyn where fallback credentials aren't saved - if has_token and token_valid and not has_credentials: - # Valid token without saved credentials - assume client authenticated - # (This covers embedded/client credentials flows) - return "client_authenticated" - - # CASE 5: No credentials and no token (or invalid token without credentials) - if not has_credentials and (not has_token or (has_token and not token_valid)): - return "not_authenticated" - - # CASE 6: Has credentials and valid token - determine by credential type - if has_credentials and has_token and token_valid: - if credential_type == "user_password": - return "user_authenticated" - elif credential_type == "client_credentials": - return "client_authenticated" - else: - # Unknown credential type but authenticated - return "user_authenticated" # Default to user authenticated for safety - - # Fallback - shouldn't reach here with above logic - return "not_authenticated" - def save_provider_credentials_from_api(self, provider_name: str, credentials_data: Dict[str, Any]) -> Tuple[bool, str]: """ diff --git a/lib/streaming_providers/providers/auth.py b/lib/streaming_providers/providers/auth.py new file mode 100644 index 0000000..944b6b3 --- /dev/null +++ b/lib/streaming_providers/providers/auth.py @@ -0,0 +1,73 @@ +from abc import ABC, abstractmethod +from typing import Dict, Any, List +from ..base.models.auth import AuthStatus + + +class ProviderAuthInterface(ABC): + """ + Interface for providers to report their authentication status. + Providers implement only their unique logic. + """ + + def __init__(self, provider_instance): + self.provider = provider_instance + + @abstractmethod + def collect_auth_data(self, context) -> Dict[str, Any]: + """ + Collect all authentication data needed for status calculation. + This is provider-specific. + + Args: + context: AuthContext with access to settings, sessions, etc. + + Returns: + Dictionary with provider-specific auth data + """ + pass + + @abstractmethod + def build_auth_status(self, auth_data: Dict[str, Any]) -> AuthStatus: + """ + Build AuthStatus from collected data. + This is provider-specific. + + Args: + auth_data: Data collected by collect_auth_data() + + Returns: + Complete AuthStatus object + """ + pass + + def get_status(self, context) -> AuthStatus: + """ + Template method: collects data and builds status. + Providers shouldn't override this unless special handling needed. + """ + auth_data = self.collect_auth_data(context) + return self.build_auth_status(auth_data) + + +class AuthContext: + """ + Context passed to providers for accessing shared services. + Encapsulates all external dependencies. + """ + + def __init__(self, settings_manager, session_manager, credential_manager): + self.settings = settings_manager + self.session = session_manager + self.credentials = credential_manager + + def get_credentials(self, provider_name: str, country: str = None): + return self.settings.get_provider_credentials(provider_name, country) + + def get_token(self, provider_name: str, scope: str = None, country: str = None): + if scope: + return self.session.load_scoped_token(provider_name, scope, country) + else: + return self.session.load_token_data(provider_name, country) + + def get_all_scopes(self, provider_name: str, country: str = None) -> List[str]: + return self.session.list_scoped_tokens(provider_name, country) \ No newline at end of file diff --git a/lib/streaming_providers/providers/auth_builder.py b/lib/streaming_providers/providers/auth_builder.py new file mode 100644 index 0000000..1f7e832 --- /dev/null +++ b/lib/streaming_providers/providers/auth_builder.py @@ -0,0 +1,254 @@ +# streaming_providers/providers/auth_builder.py + +from typing import Dict, Any, Optional, Tuple +from .auth_context import AuthContext +from ..base.models.auth import AuthStatus, TokenInfo, AuthState + + +class AuthStatusBuilder: + """ + Simplified builder for authentication status. + All logic in one class, no intermediate data dict. + """ + + @staticmethod + def for_provider(provider, context: AuthContext) -> AuthStatus: + """Build auth status directly from provider and context""" + + # Get current auth type + current_auth_type = provider.get_current_auth_type(context) + + # Calculate auth state (with provider override support) + auth_state = AuthStatusBuilder._calculate_auth_state_with_override( + provider, context + ) + + # Calculate readiness (with provider override support) + is_ready, reason = AuthStatusBuilder._calculate_readiness_with_override( + provider, context + ) + + # Build token info + token_scopes = AuthStatusBuilder._build_token_scopes(provider, context) + + # Get credentials info + has_credentials, credentials_type = AuthStatusBuilder._get_credentials_info( + provider, context + ) + + # Get provider-specific details + provider_specific = AuthStatusBuilder._get_provider_specific_details( + provider, context, current_auth_type + ) + + # Build and return + return AuthStatus( + provider_name=provider.provider_name, + provider_label=provider.provider_label, + country=provider.country, + auth_type=current_auth_type, + auth_state=auth_state, + is_ready=is_ready, + readiness_reason=reason, + requires_stored_credentials=provider.requires_stored_credentials, + has_credentials=has_credentials, + credentials_type=credentials_type, + has_valid_token=AuthStatusBuilder._has_valid_token(provider, context), + primary_token_scope=provider.primary_token_scope, + token_scopes=token_scopes, + last_authentication=AuthStatusBuilder._get_last_auth_time(provider, context), + provider_specific=provider_specific + ) + + # ===== Calculation Methods (with provider override support) ===== + + @staticmethod + def _calculate_auth_state_with_override(provider, context: AuthContext) -> AuthState: + """Calculate auth state, allowing provider override""" + # Check if provider has custom logic + if hasattr(provider, '_calculate_auth_state'): + custom_state = provider._calculate_auth_state(context) + if custom_state: + return custom_state + + # Standard calculation + return AuthStatusBuilder._calculate_auth_state(provider, context) + + @staticmethod + def _calculate_readiness_with_override(provider, context: AuthContext) -> Tuple[bool, str]: + """Calculate readiness, allowing provider override""" + # Check if provider has custom logic + if hasattr(provider, '_calculate_readiness'): + custom_result = provider._calculate_readiness(context) + if custom_result: + return custom_result + + # Standard calculation + return AuthStatusBuilder._calculate_readiness(provider, context) + + # ===== Standard Calculation Methods ===== + + @staticmethod + def _calculate_auth_state(provider, context: AuthContext) -> AuthState: + """Standard auth state calculation""" + + # Anonymous providers don't need auth + if 'anonymous' in provider.supported_auth_types: + return AuthState.NOT_APPLICABLE + + # Check if we have any valid token + if AuthStatusBuilder._has_valid_token(provider, context): + return AuthState.AUTHENTICATED + + # Check if we have an expired token that can be refreshed + expired_token = AuthStatusBuilder._get_expired_token_with_refresh(provider, context) + if expired_token: + return AuthState.EXPIRED + + # Not authenticated + return AuthState.NOT_AUTHENTICATED + + @staticmethod + def _calculate_readiness(provider, context: AuthContext) -> Tuple[bool, str]: + """Standard readiness calculation""" + + # 1. Anonymous providers are always ready + if 'anonymous' in provider.supported_auth_types: + return True, "Anonymous provider always ready" + + # 2. Check credentials if required + if provider.requires_stored_credentials: + credentials = context.get_credentials(provider.provider_name, provider.country) + if not credentials: + return False, "Missing required credentials" + + # 3. Check if we have a valid token + if AuthStatusBuilder._has_valid_token(provider, context): + return True, "Has valid authentication token" + + # 4. Network-based providers might be authenticating + if 'network_based' in provider.supported_auth_types: + return False, "Network authentication in progress" + + # 5. Not ready + return False, "Not authenticated" + + # ===== Helper Methods ===== + + @staticmethod + def _has_valid_token(provider, context: AuthContext) -> bool: + """Check if provider has any valid token""" + # Check primary scope first + if provider.primary_token_scope: + token = context.get_token(provider.provider_name, provider.primary_token_scope, provider.country) + if token and not context.is_token_expired(token): + return True + + # Check all scopes + for scope in provider.token_scopes: + token = context.get_token(provider.provider_name, scope, provider.country) + if token and not context.is_token_expired(token): + return True + + # Check root-level token + token = context.get_token(provider.provider_name, None, provider.country) + if token and not context.is_token_expired(token): + return True + + return False + + @staticmethod + def _get_expired_token_with_refresh(provider, context: AuthContext) -> Optional[Dict[str, Any]]: + """Get an expired token that has refresh capability""" + # Check primary scope + if provider.primary_token_scope: + token = context.get_token(provider.provider_name, provider.primary_token_scope, provider.country) + if token and context.is_token_expired(token) and token.get('refresh_token'): + return token + + # Check all scopes + for scope in provider.token_scopes: + token = context.get_token(provider.provider_name, scope, provider.country) + if token and context.is_token_expired(token) and token.get('refresh_token'): + return token + + # Check root-level token + token = context.get_token(provider.provider_name, None, provider.country) + if token and context.is_token_expired(token) and token.get('refresh_token'): + return token + + return None + + @staticmethod + def _build_token_scopes(provider, context: AuthContext) -> Dict[str, TokenInfo]: + """Build token scope information""" + token_scopes = {} + + for scope in provider.token_scopes: + token_data = context.get_token(provider.provider_name, scope, provider.country) + if token_data: + expires_at = None + if 'issued_at' in token_data and 'expires_in' in token_data: + expires_at = token_data['issued_at'] + token_data['expires_in'] + + token_info = TokenInfo( + scope=scope, + has_token=True, + is_valid=not context.is_token_expired(token_data), + expires_at=expires_at, + has_refresh_token=bool(token_data.get('refresh_token')), + auth_level=token_data.get('auth_level') + ) + else: + token_info = TokenInfo( + scope=scope, + has_token=False, + is_valid=False + ) + token_scopes[scope] = token_info + + return token_scopes + + @staticmethod + def _get_credentials_info(provider, context: AuthContext) -> Tuple[bool, Optional[str]]: + """Get credentials information""" + if not provider.requires_stored_credentials: + return False, None + + credentials = context.get_credentials(provider.provider_name, provider.country) + if credentials: + return True, credentials.credential_type + + return False, None + + @staticmethod + def _get_last_auth_time(provider, context: AuthContext) -> Optional[float]: + """Get last authentication time from token""" + if provider.primary_token_scope: + token = context.get_token(provider.provider_name, provider.primary_token_scope, provider.country) + if token and 'issued_at' in token: + return token['issued_at'] + + # Check root-level token + token = context.get_token(provider.provider_name, None, provider.country) + if token and 'issued_at' in token: + return token['issued_at'] + + return None + + @staticmethod + def _get_provider_specific_details(provider, context: AuthContext, + current_auth_type: str) -> Dict[str, Any]: + """Get provider-specific auth details""" + details = { + 'supported_auth_types': provider.supported_auth_types, + 'preferred_auth_type': provider.preferred_auth_type, + 'current_auth_type': current_auth_type + } + + # Add provider's own details if available + if hasattr(provider, 'get_auth_details'): + custom_details = provider.get_auth_details(context) + details.update(custom_details) + + return details \ No newline at end of file diff --git a/lib/streaming_providers/providers/auth_context.py b/lib/streaming_providers/providers/auth_context.py new file mode 100644 index 0000000..54475e8 --- /dev/null +++ b/lib/streaming_providers/providers/auth_context.py @@ -0,0 +1,47 @@ +# streaming_providers/providers/auth_context.py +from typing import Optional, List, Dict, Any + + +class AuthContext: + """ + Context passed to providers for accessing shared services. + Simple wrapper around SettingsManager. + """ + + def __init__(self, settings_manager): + self.settings = settings_manager + self.session = settings_manager.session_manager if settings_manager else None + self.credentials = settings_manager.credential_manager if settings_manager else None + + def get_credentials(self, provider_name: str, country: str = None) -> Optional[Any]: + """Get credentials for provider""" + if self.settings: + return self.settings.get_provider_credentials(provider_name, country) + return None + + def get_token(self, provider_name: str, scope: str = None, + country: str = None) -> Optional[Dict[str, Any]]: + """Get token for provider (scoped or root-level)""" + if not self.session: + return None + + if scope: + return self.session.load_scoped_token(provider_name, scope, country) + else: + return self.session.load_token_data(provider_name, country) + + def get_all_scopes(self, provider_name: str, country: str = None) -> List[str]: + """Get all token scopes for provider""" + if not self.session: + return [] + + if hasattr(self.session, 'list_scoped_tokens'): + return self.session.list_scoped_tokens(provider_name, country) + + return [] + + def is_token_expired(self, token_data): + """Check if token is expired""" + if self.session and hasattr(self.session, '_is_token_expired'): + return self.session._is_token_expired(token_data) + return True # Conservative default \ No newline at end of file diff --git a/lib/streaming_providers/providers/hrti/provider.py b/lib/streaming_providers/providers/hrti/provider.py index 4fe6da1..fcbdef2 100644 --- a/lib/streaming_providers/providers/hrti/provider.py +++ b/lib/streaming_providers/providers/hrti/provider.py @@ -65,6 +65,10 @@ class HRTiProvider(StreamingProvider): def implements_epg(self) -> bool: return False + @property + def supported_auth_types(self) -> List[str]: + return ['user_credentials'] + def _get_hrti_authenticated_headers(self) -> Dict[str, str]: """ Get headers with HRTi authentication for API requests diff --git a/lib/streaming_providers/providers/joyn/provider.py b/lib/streaming_providers/providers/joyn/provider.py index 2cbf00a..8c7b1fb 100644 --- a/lib/streaming_providers/providers/joyn/provider.py +++ b/lib/streaming_providers/providers/joyn/provider.py @@ -187,6 +187,10 @@ class JoynProvider(StreamingProvider): def implements_epg(self) -> bool: return False + @property + def supported_auth_types(self) -> List[str]: + return ['client_credentials','user_credentials'] + def authenticate(self, **kwargs) -> str: """Authenticate and return bearer token""" self.bearer_token = self.authenticator.get_bearer_token( diff --git a/lib/streaming_providers/providers/magenta2/provider.py b/lib/streaming_providers/providers/magenta2/provider.py index b463dce..985b3a0 100644 --- a/lib/streaming_providers/providers/magenta2/provider.py +++ b/lib/streaming_providers/providers/magenta2/provider.py @@ -406,8 +406,16 @@ class Magenta2Provider(StreamingProvider): return 4 @property - def requires_user_credentials(self) -> bool: - return False + def supported_auth_types(self) -> List[str]: + return ['network_based'] # ← Change 1 + + @property + def primary_token_scope(self) -> Optional[str]: + return 'persona' # ← Keep this + + @property + def token_scopes(self) -> List[str]: + return ['yo_digital', 'tvhubs', 'taa', 'persona'] def get_discovery_status(self) -> Dict[str, Any]: """Get discovery and configuration status""" diff --git a/lib/streaming_providers/providers/magentaeu/provider.py b/lib/streaming_providers/providers/magentaeu/provider.py index e0ec950..eddd6f3 100644 --- a/lib/streaming_providers/providers/magentaeu/provider.py +++ b/lib/streaming_providers/providers/magentaeu/provider.py @@ -141,6 +141,10 @@ class MagentaProvider(StreamingProvider): # This provider offers 168 hours (7 days) of catchup return 168 + @property + def supported_auth_types(self) -> List[str]: + return ['user_credentials'] + def authenticate(self, **kwargs) -> str: logger.info(f"=== MagentaProvider.authenticate() CALLED with kwargs: {kwargs} ===") self.bearer_token = self.authenticator.get_bearer_token(force_refresh=kwargs.get('force_refresh', False)) diff --git a/lib/streaming_providers/providers/rtlplus/provider.py b/lib/streaming_providers/providers/rtlplus/provider.py index 2f6aedf..e75c8cc 100644 --- a/lib/streaming_providers/providers/rtlplus/provider.py +++ b/lib/streaming_providers/providers/rtlplus/provider.py @@ -69,7 +69,11 @@ class RTLPlusProvider(StreamingProvider): def implements_epg(self) -> bool: return False - # ============================================================================ + @property + def supported_auth_types(self) -> List[str]: + return ['user_credentials'] + + # ============================================================================ # OPTION 1: Provider-specific method (RECOMMENDED - No signature conflict) # ============================================================================ def _get_rtlplus_authenticated_headers(self) -> Dict[str, str]: diff --git a/resources/web/config.js b/resources/web/config.js index c79b14e..90ef6e1 100644 --- a/resources/web/config.js +++ b/resources/web/config.js @@ -94,6 +94,7 @@ async function loadAuthStatus() { } } +// Render credentials forms // Render credentials forms async function renderCredentialsForms() { if (providers.length === 0) { @@ -121,66 +122,141 @@ async function renderCredentialsForms() { } } - // Check if this provider requires user credentials - const isClientOnly = !provider.requires_user_credentials; - const providerCardClass = isClientOnly ? 'provider-card client-credentials-only' : 'provider-card'; + // Get auth information from provider + const auth = provider.auth || {}; + const supportedAuthTypes = auth.supported_auth_types || []; - // Get the current auth status + // Determine UI based on auth properties + const needsUserCredentials = auth.needs_user_credentials || false; + const needsClientCredentials = auth.needs_client_credentials || false; + const isAnonymous = auth.is_anonymous || false; + const isNetworkBased = auth.is_network_based || false; + const usesEmbeddedClient = auth.uses_embedded_client || false; + const usesDeviceRegistration = auth.uses_device_registration || false; + + // Get current auth status const status = authStatus[provider.name] || {}; - // Create form groups HTML based on provider type - const formGroupsHTML = isClientOnly ? '' : ` -
- - - ${existingCreds?.username_masked ? ` - - Credentials saved. Enter new values to update. - - ` : ''} -
+ // Determine provider card class + let providerCardClass = 'provider-card'; + if (!needsUserCredentials) { + providerCardClass += ' client-credentials-only'; + } -
- - -
- `; + // Create form content based on auth type + let formContent = ''; + let authDescription = ''; - // Create buttons HTML - only show save/delete for providers that need credentials - const buttonsHTML = isClientOnly ? ` -
- -
- ` : ` -
- - ${existingCreds ? ` - - ` : ''} - -
- `; + if (needsUserCredentials) { + // User credentials form + formContent = ` +
+ + + ${existingCreds?.username_masked ? ` + + Credentials saved. Enter new values to update. + + ` : ''} +
+ +
+ + +
+ `; + authDescription = 'Requires username and password'; + } else if (needsClientCredentials && !needsUserCredentials) { + // Client credentials only (no user input needed) + formContent = ` +
+ + Client Credentials Only +

This provider uses hardcoded client credentials that don't require manual setup.

+ Authentication type: ${auth.preferred_auth_type || 'client_credentials'} +
+ `; + authDescription = 'Uses client credentials'; + } else if (isAnonymous || isNetworkBased || usesEmbeddedClient) { + // No credentials required + formContent = ` +
+ + No Credentials Required +

This provider uses ${auth.preferred_auth_type?.replace('_', ' ') || 'automatic'} authentication.

+ ${supportedAuthTypes.length > 0 ? ` + Supported auth types: ${supportedAuthTypes.join(', ')} + ` : ''} +
+ `; + authDescription = 'No credentials required'; + } else if (usesDeviceRegistration) { + // Device registration + formContent = ` +
+ + Device Registration +

This provider requires device registration. Follow provider-specific setup instructions.

+ Authentication type: ${auth.preferred_auth_type || 'device_registration'} +
+ `; + authDescription = 'Requires device registration'; + } else { + // Fallback for unknown auth types + formContent = ` +
+ + Authentication Type Unknown +

This provider's authentication method could not be determined.

+ ${supportedAuthTypes.length > 0 ? ` + Supported auth types: ${supportedAuthTypes.join(', ')} + ` : ''} +
+ `; + authDescription = 'Unknown authentication'; + } + + // Create buttons based on auth type + let buttonsHTML = ''; + if (needsUserCredentials) { + buttonsHTML = ` +
+ + ${existingCreds ? ` + + ` : ''} + +
+ `; + } else { + // For non-user-credential providers, only show test button + buttonsHTML = ` +
+ +
+ `; + } return `
@@ -189,6 +265,9 @@ async function renderCredentialsForms() {

${provider.label}

${provider.name} • ${provider.country}
+
+ ${authDescription} +
${getStatusIcon(status)} ${getStatusText(status)} @@ -196,15 +275,7 @@ async function renderCredentialsForms() {
- ${isClientOnly ? ` -
- - No manual credentials required -

This provider uses client credentials that are hardcoded in the application.

-
- ` : ''} - - ${formGroupsHTML} + ${formContent} ${buttonsHTML}
`; @@ -326,30 +397,43 @@ function getStatusIcon(status) { if (!status) return ''; switch (status.auth_state) { + case 'AUTHENTICATED': case 'user_authenticated': case 'client_authenticated': return ''; + case 'EXPIRED': case 'credentials_only': return ''; - default: + case 'NOT_AUTHENTICATED': return ''; + case 'NOT_APPLICABLE': + return ''; + default: + return ''; } } function getStatusText(status) { if (!status) return 'Unknown'; + // Map Python AuthState values to readable text switch (status.auth_state) { - case 'user_authenticated': + case 'AUTHENTICATED': return 'Authenticated'; + case 'EXPIRED': + return 'Token Expired (can refresh)'; + case 'NOT_AUTHENTICATED': + return 'Not Authenticated'; + case 'NOT_APPLICABLE': + return 'No Auth Required'; + case 'user_authenticated': // Keep old values for backward compatibility + return 'User Authenticated'; case 'client_authenticated': return 'Client Authenticated'; case 'credentials_only': return 'Credentials Saved'; - case 'not_authenticated': - return 'Not Authenticated'; default: - return status.auth_state; + return status.auth_state || 'Unknown'; } } @@ -362,9 +446,27 @@ async function saveCredentials(providerName) { return; } - // Check if provider is client-only - if (!provider.requires_user_credentials) { - showAlert('info', `${provider.label} uses hardcoded client credentials - no manual setup needed`); + // Check auth properties + const auth = provider.auth || {}; + + // Check if provider actually needs user credentials + if (!auth.needs_user_credentials) { + const authType = auth.preferred_auth_type || 'unknown'; + const authTypeName = authType.replace('_', ' '); + + if (auth.needs_client_credentials && !auth.needs_user_credentials) { + showAlert('info', `${provider.label} uses client credentials - credentials are hardcoded in the application`); + } else if (auth.is_anonymous) { + showAlert('info', `${provider.label} is an anonymous provider - no credentials needed`); + } else if (auth.is_network_based) { + showAlert('info', `${provider.label} uses network-based authentication - no manual setup needed`); + } else if (auth.uses_embedded_client) { + showAlert('info', `${provider.label} uses embedded client credentials - no manual setup needed`); + } else if (auth.uses_device_registration) { + showAlert('info', `${provider.label} requires device registration - follow provider setup instructions`); + } else { + showAlert('info', `${provider.label} uses ${authTypeName} authentication - no manual credential setup`); + } return; } @@ -485,16 +587,36 @@ async function deleteCredentials(providerName) { return; } - // Check if provider is client-only - if (!provider.requires_user_credentials) { - showAlert('info', `${provider.label} uses hardcoded client credentials - nothing to delete`); + // Check auth properties + const auth = provider.auth || {}; + + // Check if provider actually uses user credentials + if (!auth.needs_user_credentials) { + const authType = auth.preferred_auth_type || 'unknown'; + const authTypeName = authType.replace('_', ' '); + + if (auth.needs_client_credentials && !auth.needs_user_credentials) { + showAlert('info', `${provider.label} uses hardcoded client credentials - nothing to delete`); + } else if (auth.is_anonymous) { + showAlert('info', `${provider.label} is an anonymous provider - no credentials to delete`); + } else if (auth.is_network_based) { + showAlert('info', `${provider.label} uses network-based authentication - no credentials stored`); + } else if (auth.uses_embedded_client) { + showAlert('info', `${provider.label} uses embedded client credentials - nothing to delete`); + } else if (auth.uses_device_registration) { + showAlert('info', `${provider.label} uses device registration - no stored credentials to delete`); + } else { + showAlert('info', `${provider.label} uses ${authTypeName} authentication - no credentials to delete`); + } return; } if (!confirm(`Delete credentials for ${providerName}?`)) return; const statusEl = document.getElementById(`status-${providerName}`); - statusEl.innerHTML = ' Deleting...'; + if (statusEl) { + statusEl.innerHTML = ' Deleting...'; + } try { const response = await fetch(`${API_BASE}/api/providers/${providerName}/credentials`, { @@ -502,12 +624,17 @@ async function deleteCredentials(providerName) { }); if (response.ok) { - statusEl.className = 'status-indicator status-warning'; - statusEl.innerHTML = ' Credentials deleted'; + if (statusEl) { + statusEl.className = 'status-indicator status-warning'; + statusEl.innerHTML = ' Credentials deleted'; + } - // Clear fields - document.getElementById(`username-${providerName}`).value = ''; - document.getElementById(`password-${providerName}`).value = ''; + // Clear fields if they exist + const usernameInput = document.getElementById(`username-${providerName}`); + const passwordInput = document.getElementById(`password-${providerName}`); + + if (usernameInput) usernameInput.value = ''; + if (passwordInput) passwordInput.value = ''; // Reload auth status await loadAuthStatus(); @@ -516,13 +643,17 @@ async function deleteCredentials(providerName) { showAlert('success', `Credentials deleted for ${providerName}`); } else { const result = await response.json(); - statusEl.className = 'status-indicator status-error'; - statusEl.innerHTML = ` ${result.error || 'Delete failed'}`; + if (statusEl) { + statusEl.className = 'status-indicator status-error'; + statusEl.innerHTML = ` ${result.error || 'Delete failed'}`; + } showAlert('error', result.error || `Failed to delete credentials for ${providerName}`); } } catch (error) { - statusEl.className = 'status-indicator status-error'; - statusEl.innerHTML = ' Network error'; + if (statusEl) { + statusEl.className = 'status-indicator status-error'; + statusEl.innerHTML = ' Network error'; + } showAlert('error', `Network error: ${error.message}`); console.error('Delete error:', error); } diff --git a/service.py b/service.py index 030a855..e584aa3 100644 --- a/service.py +++ b/service.py @@ -692,9 +692,8 @@ class UltimateService: def list_providers(): try: provider_names = self.manager.list_providers() - default_country = self._get_setting('default_country', 'DE') # Changed + default_country = self._get_setting('default_country', 'DE') - # Enhanced response with provider details including labels providers_details = [] for provider_name in provider_names: provider_instance = self.manager.get_provider(provider_name) @@ -702,18 +701,51 @@ class UltimateService: provider_label = getattr(provider_instance, 'provider_label', provider_name) country = getattr(provider_instance, 'country', default_country) provider_logo = getattr(provider_instance, 'provider_logo', '') - requires_user_credentials = getattr( - provider_instance, - 'requires_user_credentials', - True # Default to True if property doesn't exist (for backward compatibility) + + # Get authentication properties + supported_auth_types = getattr(provider_instance, 'supported_auth_types', []) + preferred_auth_type = getattr(provider_instance, 'preferred_auth_type', 'unknown') + requires_stored_credentials = getattr( + provider_instance, 'requires_stored_credentials', False ) + # Check specific auth type needs + needs_user_creds = 'user_credentials' in supported_auth_types + needs_client_creds = 'client_credentials' in supported_auth_types + is_network_based = 'network_based' in supported_auth_types + is_anonymous = 'anonymous' in supported_auth_types + uses_device_reg = 'device_registration' in supported_auth_types + uses_embedded = 'embedded_client' in supported_auth_types + providers_details.append({ 'name': provider_name, 'label': provider_label, 'logo': provider_logo, 'country': country, - 'requires_user_credentials': requires_user_credentials + + # Core authentication properties + 'auth': { + 'supported_auth_types': supported_auth_types, + 'preferred_auth_type': preferred_auth_type, + 'requires_stored_credentials': requires_stored_credentials, + + # Specific auth type flags for easy UI decisions + 'needs_user_credentials': needs_user_creds, + 'needs_client_credentials': needs_client_creds, + 'is_network_based': is_network_based, + 'is_anonymous': is_anonymous, + 'uses_device_registration': uses_device_reg, + 'uses_embedded_client': uses_embedded, + + # Derived summary for UI + 'needs_user_input': needs_user_creds or uses_device_reg, + 'needs_configuration': needs_user_creds or needs_client_creds, + 'is_automatic': is_network_based or is_anonymous or uses_embedded, + }, + + # Token properties + 'primary_token_scope': getattr(provider_instance, 'primary_token_scope', None), + 'token_scopes': getattr(provider_instance, 'token_scopes', []), }) return { @@ -1341,28 +1373,52 @@ class UltimateService: @self.app.route('/api/providers//auth/status') def get_provider_auth_status(provider): - """ - Get authentication status for a provider - - Returns current authentication state including: - - Authentication state (not_authenticated, credentials_only, user_authenticated, client_authenticated) - - Credential type - - Token validity and expiration - - Whether provider is ready to use - - Example: GET /api/providers/joyn_de/auth/status - """ + """Get authentication status from provider itself""" try: + # Get provider instance + provider_instance = self.manager.get_provider(provider) + if not provider_instance: + response.status = 404 + return {'error': f'Provider {provider} not found'} + + # Get SettingsManager settings_manager = self._get_settings_manager() - status = settings_manager.get_auth_status(provider) + if not settings_manager: + response.status = 500 + return {'error': 'Settings manager not available'} - response.content_type = 'application/json; charset=utf-8' - return status + # Import and use new auth system + from streaming_providers.base.provider.auth_context import AuthContext - except Exception as api_err: - logger.error(f"API Error in /api/providers/{provider}/auth/status: {str(api_err)}") + try: + auth_context = AuthContext(settings_manager) + auth_status = provider_instance.get_auth_status(auth_context) + return auth_status.to_dict() + except AttributeError as attr_err: + logger.error(f"Provider {provider} missing required auth property: {attr_err}") + response.status = 501 # Not Implemented + return { + 'error': f'Provider {provider} does not fully implement auth status', + 'details': str(attr_err) + } + except Exception as e: + logger.error(f"Error getting auth status: {e}", exc_info=True) + response.status = 500 + return {'error': str(e)} + + except ImportError as import_error: + # This happens during development if modules not created yet + logger.warning(f"Auth modules not available: {import_error}") + return { + 'provider': provider, + 'auth_state': 'not_implemented', + 'is_ready': False, + 'message': 'New auth system in development' + } + except Exception as e: + logger.error(f"Error getting auth status for {provider}: {e}", exc_info=True) response.status = 500 - return {'error': f'Internal server error: {str(api_err)}'} + return {'error': f'Internal server error: {str(e)}'} @self.app.route('/api/providers//credentials', method='GET') def get_provider_credentials(provider):