Add web config

This commit is contained in:
Nirvana
2025-12-19 23:17:20 +01:00
parent 4445250d82
commit bb351a4723
13 changed files with 1117 additions and 258 deletions
+117
View File
@@ -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
+303 -12
View File
@@ -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]
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 {}
@@ -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]:
"""
+73
View File
@@ -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)
@@ -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
@@ -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
@@ -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
@@ -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(
@@ -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"""
@@ -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))
@@ -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]:
+216 -85
View File
@@ -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 ? '' : `
<div class="form-group">
<label for="username-${provider.name}">
<i class="fas fa-user"></i> Username/Email
</label>
<input type="text"
id="username-${provider.name}"
class="form-control"
placeholder="user@example.com"
value="${existingCreds?.username_masked || ''}"
${existingCreds?.username_masked ? 'readonly style="background-color:#f5f5f5;"' : ''}>
${existingCreds?.username_masked ? `
<small style="color:#666; display:block; margin-top:5px;">
<i class="fas fa-info-circle"></i> Credentials saved. Enter new values to update.
</small>
` : ''}
</div>
// Determine provider card class
let providerCardClass = 'provider-card';
if (!needsUserCredentials) {
providerCardClass += ' client-credentials-only';
}
<div class="form-group">
<label for="password-${provider.name}">
<i class="fas fa-lock"></i> ${existingCreds ? 'New Password (leave blank to keep current)' : 'Password'}
</label>
<input type="password"
id="password-${provider.name}"
class="form-control"
placeholder="${existingCreds ? '•••••••• (optional)' : '••••••••'}"
value="">
</div>
`;
// 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 ? `
<div class="btn-group">
<button onclick="testAuth('${provider.name}')" class="btn btn-success">
<i class="fas fa-check"></i> Test Connection
</button>
</div>
` : `
<div class="btn-group">
<button onclick="saveCredentials('${provider.name}')" class="btn btn-primary">
<i class="fas fa-save"></i> ${existingCreds ? 'Update' : 'Save'}
</button>
${existingCreds ? `
<button onclick="deleteCredentials('${provider.name}')" class="btn btn-danger">
<i class="fas fa-trash"></i> Delete
</button>
` : ''}
<button onclick="testAuth('${provider.name}')" class="btn btn-success">
<i class="fas fa-check"></i> Test
</button>
</div>
`;
if (needsUserCredentials) {
// User credentials form
formContent = `
<div class="form-group">
<label for="username-${provider.name}">
<i class="fas fa-user"></i> Username/Email
</label>
<input type="text"
id="username-${provider.name}"
class="form-control"
placeholder="user@example.com"
value="${existingCreds?.username_masked || ''}"
${existingCreds?.username_masked ? 'readonly style="background-color:#f5f5f5;"' : ''}>
${existingCreds?.username_masked ? `
<small style="color:#666; display:block; margin-top:5px;">
<i class="fas fa-info-circle"></i> Credentials saved. Enter new values to update.
</small>
` : ''}
</div>
<div class="form-group">
<label for="password-${provider.name}">
<i class="fas fa-lock"></i> ${existingCreds ? 'New Password (leave blank to keep current)' : 'Password'}
</label>
<input type="password"
id="password-${provider.name}"
class="form-control"
placeholder="${existingCreds ? '•••••••• (optional)' : '••••••••'}"
value="">
</div>
`;
authDescription = 'Requires username and password';
} else if (needsClientCredentials && !needsUserCredentials) {
// Client credentials only (no user input needed)
formContent = `
<div class="no-credentials-required">
<i class="fas fa-key"></i>
<strong>Client Credentials Only</strong>
<p>This provider uses hardcoded client credentials that don't require manual setup.</p>
<small>Authentication type: ${auth.preferred_auth_type || 'client_credentials'}</small>
</div>
`;
authDescription = 'Uses client credentials';
} else if (isAnonymous || isNetworkBased || usesEmbeddedClient) {
// No credentials required
formContent = `
<div class="no-credentials-required">
<i class="fas fa-check-circle"></i>
<strong>No Credentials Required</strong>
<p>This provider uses ${auth.preferred_auth_type?.replace('_', ' ') || 'automatic'} authentication.</p>
${supportedAuthTypes.length > 0 ? `
<small>Supported auth types: ${supportedAuthTypes.join(', ')}</small>
` : ''}
</div>
`;
authDescription = 'No credentials required';
} else if (usesDeviceRegistration) {
// Device registration
formContent = `
<div class="no-credentials-required">
<i class="fas fa-mobile-alt"></i>
<strong>Device Registration</strong>
<p>This provider requires device registration. Follow provider-specific setup instructions.</p>
<small>Authentication type: ${auth.preferred_auth_type || 'device_registration'}</small>
</div>
`;
authDescription = 'Requires device registration';
} else {
// Fallback for unknown auth types
formContent = `
<div class="no-credentials-required">
<i class="fas fa-question-circle"></i>
<strong>Authentication Type Unknown</strong>
<p>This provider's authentication method could not be determined.</p>
${supportedAuthTypes.length > 0 ? `
<small>Supported auth types: ${supportedAuthTypes.join(', ')}</small>
` : ''}
</div>
`;
authDescription = 'Unknown authentication';
}
// Create buttons based on auth type
let buttonsHTML = '';
if (needsUserCredentials) {
buttonsHTML = `
<div class="btn-group">
<button onclick="saveCredentials('${provider.name}')" class="btn btn-primary">
<i class="fas fa-save"></i> ${existingCreds ? 'Update' : 'Save'}
</button>
${existingCreds ? `
<button onclick="deleteCredentials('${provider.name}')" class="btn btn-danger">
<i class="fas fa-trash"></i> Delete
</button>
` : ''}
<button onclick="testAuth('${provider.name}')" class="btn btn-success">
<i class="fas fa-check"></i> Test
</button>
</div>
`;
} else {
// For non-user-credential providers, only show test button
buttonsHTML = `
<div class="btn-group">
<button onclick="testAuth('${provider.name}')" class="btn btn-success">
<i class="fas fa-check"></i> Test Connection
</button>
</div>
`;
}
return `
<div class="${providerCardClass}" data-provider="${provider.name}">
@@ -189,6 +265,9 @@ async function renderCredentialsForms() {
<div class="provider-info">
<h3>${provider.label}</h3>
<div class="provider-id">${provider.name} ${provider.country}</div>
<div class="auth-info">
<small><i class="fas fa-fingerprint"></i> ${authDescription}</small>
</div>
<div id="status-${provider.name}" class="status-indicator">
${getStatusIcon(status)}
${getStatusText(status)}
@@ -196,15 +275,7 @@ async function renderCredentialsForms() {
</div>
</div>
${isClientOnly ? `
<div class="no-credentials-required">
<i class="fas fa-info-circle"></i>
<strong>No manual credentials required</strong>
<p>This provider uses client credentials that are hardcoded in the application.</p>
</div>
` : ''}
${formGroupsHTML}
${formContent}
${buttonsHTML}
</div>
`;
@@ -326,30 +397,43 @@ function getStatusIcon(status) {
if (!status) return '<i class="fas fa-question-circle"></i>';
switch (status.auth_state) {
case 'AUTHENTICATED':
case 'user_authenticated':
case 'client_authenticated':
return '<i class="fas fa-check-circle"></i>';
case 'EXPIRED':
case 'credentials_only':
return '<i class="fas fa-exclamation-triangle"></i>';
default:
case 'NOT_AUTHENTICATED':
return '<i class="fas fa-times-circle"></i>';
case 'NOT_APPLICABLE':
return '<i class="fas fa-info-circle"></i>';
default:
return '<i class="fas fa-question-circle"></i>';
}
}
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 = '<span class="loader"></span> Deleting...';
if (statusEl) {
statusEl.innerHTML = '<span class="loader"></span> 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 = '<i class="fas fa-info-circle"></i> Credentials deleted';
if (statusEl) {
statusEl.className = 'status-indicator status-warning';
statusEl.innerHTML = '<i class="fas fa-info-circle"></i> 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 = `<i class="fas fa-exclamation-circle"></i> ${result.error || 'Delete failed'}`;
if (statusEl) {
statusEl.className = 'status-indicator status-error';
statusEl.innerHTML = `<i class="fas fa-exclamation-circle"></i> ${result.error || 'Delete failed'}`;
}
showAlert('error', result.error || `Failed to delete credentials for ${providerName}`);
}
} catch (error) {
statusEl.className = 'status-indicator status-error';
statusEl.innerHTML = '<i class="fas fa-exclamation-circle"></i> Network error';
if (statusEl) {
statusEl.className = 'status-indicator status-error';
statusEl.innerHTML = '<i class="fas fa-exclamation-circle"></i> Network error';
}
showAlert('error', `Network error: ${error.message}`);
console.error('Delete error:', error);
}
+80 -24
View File
@@ -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/<provider>/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/<provider>/credentials', method='GET')
def get_provider_credentials(provider):