mirror of
https://github.com/nirvana-7777/script.service.ultimate.git
synced 2026-09-24 10:02:37 +02:00
Add web config
This commit is contained in:
@@ -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
|
||||
@@ -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]:
|
||||
"""
|
||||
|
||||
@@ -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
@@ -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
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user