mirror of
https://github.com/nirvana-7777/script.service.ultimate.git
synced 2026-09-16 06:02:35 +02:00
Initial commit
This commit is contained in:
Generated
+3
@@ -0,0 +1,3 @@
|
||||
# Default ignored files
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
<component name="InspectionProjectProfileManager">
|
||||
<settings>
|
||||
<option name="USE_PROJECT_PROFILE" value="false" />
|
||||
<version value="1.0" />
|
||||
</settings>
|
||||
</component>
|
||||
Generated
+7
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="Black">
|
||||
<option name="sdkName" value="Python 3.12" />
|
||||
</component>
|
||||
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.12 (.venv)" project-jdk-type="Python SDK" />
|
||||
</project>
|
||||
Generated
+8
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectModuleManager">
|
||||
<modules>
|
||||
<module fileurl="file://$PROJECT_DIR$/.idea/script.service.ultimate.iml" filepath="$PROJECT_DIR$/.idea/script.service.ultimate.iml" />
|
||||
</modules>
|
||||
</component>
|
||||
</project>
|
||||
Generated
+12
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module type="PYTHON_MODULE" version="4">
|
||||
<component name="NewModuleRootManager">
|
||||
<content url="file://$MODULE_DIR$" />
|
||||
<orderEntry type="jdk" jdkName="Python 3.12 (.venv)" jdkType="Python SDK" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
<component name="PyDocumentationSettings">
|
||||
<option name="format" value="PLAIN" />
|
||||
<option name="myDocStringFormat" value="Plain" />
|
||||
</component>
|
||||
</module>
|
||||
Generated
+6
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<addon id="script.service.ultimate" name="Ultimate Backend" version="1.0.0" provider-name="Nirvana">
|
||||
<requires>
|
||||
<import addon="xbmc.python" version="3.0.0"/>
|
||||
<import addon="script.module.bottle" version="0.12.25"/>
|
||||
<import addon="script.module.requests" version="2.25.1"/>
|
||||
<import addon="script.module.pycryptodome" version="3.4.3"/>
|
||||
</requires>
|
||||
<extension point="xbmc.python.script" library="service.py">
|
||||
<provides>executable</provides>
|
||||
</extension>
|
||||
<extension point="xbmc.service" start="login" stop="logout"/>
|
||||
<extension point="xbmc.addon.metadata">
|
||||
<summary>Backend service for Ultimate streaming providers</summary>
|
||||
<description>
|
||||
Provides channel lists, EPG data and manifest information for various streaming providers.
|
||||
Acts as a backend for Ultimate PVR and video addons.
|
||||
</description>
|
||||
<platform>all</platform>
|
||||
<license>GPL-3.0</license>
|
||||
<forum>https://example.com/forum</forum>
|
||||
<website>https://example.com</website>
|
||||
<email>support@example.com</email>
|
||||
<source>https://github.com/yourrepo/ultimate-backend</source>
|
||||
</extension>
|
||||
</addon>
|
||||
@@ -0,0 +1,126 @@
|
||||
# lib/streaming_providers/__init__.py
|
||||
import importlib
|
||||
from typing import Dict, Type
|
||||
import os
|
||||
import sys
|
||||
|
||||
# Import the centralized logger
|
||||
from .base.utils.logger import logger
|
||||
|
||||
AVAILABLE_PROVIDERS: Dict[str, Type] = {}
|
||||
|
||||
|
||||
def _discover_providers():
|
||||
"""Kodi-compatible provider discovery"""
|
||||
try:
|
||||
# Import the base provider class first
|
||||
from .base.provider import StreamingProvider
|
||||
|
||||
# Get the current package path
|
||||
current_dir = os.path.dirname(__file__)
|
||||
providers_dir = os.path.join(current_dir, 'providers')
|
||||
|
||||
logger.info(f"Looking for providers in: {providers_dir}")
|
||||
|
||||
# Check if providers directory exists
|
||||
if not os.path.exists(providers_dir):
|
||||
logger.error(f"Providers directory does not exist: {providers_dir}")
|
||||
return
|
||||
|
||||
# Add the lib directory to Python path if not already there
|
||||
lib_dir = os.path.dirname(current_dir)
|
||||
if lib_dir not in sys.path:
|
||||
sys.path.insert(0, lib_dir)
|
||||
logger.debug(f"Added lib directory to Python path: {lib_dir}")
|
||||
|
||||
# Iterate through subdirectories in providers folder
|
||||
for item in os.listdir(providers_dir):
|
||||
provider_path = os.path.join(providers_dir, item)
|
||||
|
||||
# Skip if not a directory or if it starts with __
|
||||
if not os.path.isdir(provider_path) or item.startswith('__'):
|
||||
continue
|
||||
|
||||
# Check if __init__.py exists in the provider directory
|
||||
init_file = os.path.join(provider_path, '__init__.py')
|
||||
if not os.path.exists(init_file):
|
||||
logger.debug(f"No __init__.py found in {item}, skipping")
|
||||
continue
|
||||
|
||||
try:
|
||||
logger.debug(f"Attempting to import provider: {item}")
|
||||
|
||||
# Import the provider module using absolute import
|
||||
module_name = f'streaming_providers.providers.{item}'
|
||||
module = importlib.import_module(module_name)
|
||||
|
||||
# Find provider classes in the module
|
||||
provider_found = False
|
||||
for attr_name in dir(module):
|
||||
attr = getattr(module, attr_name)
|
||||
if (isinstance(attr, type) and
|
||||
issubclass(attr, StreamingProvider) and
|
||||
attr != StreamingProvider):
|
||||
AVAILABLE_PROVIDERS[item] = attr
|
||||
logger.info(f"Discovered provider: {item} -> {attr_name}")
|
||||
provider_found = True
|
||||
break
|
||||
|
||||
if not provider_found:
|
||||
logger.warning(f"No valid provider class found in {item}")
|
||||
|
||||
except ImportError as e:
|
||||
logger.error(f"Could not import provider {item}: {e}")
|
||||
logger.debug(f"Python path: {sys.path}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing provider {item}: {e}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error during provider discovery: {e}")
|
||||
logger.debug(f"Current working directory: {os.getcwd()}")
|
||||
logger.debug(f"Python path: {sys.path}")
|
||||
|
||||
|
||||
def get_configured_manager(country: str = 'de') -> 'ProviderManager':
|
||||
"""
|
||||
Get manager with settings-aware providers
|
||||
|
||||
Args:
|
||||
country: Default country code for providers without country detection (default: 'de')
|
||||
|
||||
Returns:
|
||||
Configured ProviderManager instance with all detected providers
|
||||
"""
|
||||
from .base.manager import ProviderManager
|
||||
from .base.settings.settings_manager import SettingsManager
|
||||
|
||||
# Create manager
|
||||
manager = ProviderManager()
|
||||
|
||||
# Initialize settings manager to detect providers
|
||||
settings_manager = SettingsManager(enable_kodi_integration=True)
|
||||
|
||||
# Detect providers from Kodi (if available) or use all available providers
|
||||
detected_providers = None
|
||||
|
||||
if settings_manager.kodi_bridge and settings_manager.kodi_bridge.is_kodi_environment():
|
||||
detected_providers = settings_manager.kodi_bridge.detect_all_providers_from_kodi()
|
||||
logger.info(f"Detected providers from Kodi: {detected_providers}")
|
||||
else:
|
||||
logger.info(f"Not in Kodi environment, will register all available providers with default country '{country}'")
|
||||
|
||||
# Use the new discover_providers method with detected providers
|
||||
registered = manager.discover_providers(
|
||||
country=country,
|
||||
detected_providers=detected_providers
|
||||
)
|
||||
|
||||
logger.info(f"Registered {len(registered)} providers: {registered}")
|
||||
|
||||
return manager
|
||||
|
||||
|
||||
# Initial discovery
|
||||
_discover_providers()
|
||||
|
||||
__all__ = ['AVAILABLE_PROVIDERS', 'get_configured_manager']
|
||||
@@ -0,0 +1,19 @@
|
||||
# streaming_providers/base/__init__.py
|
||||
"""
|
||||
Base module for streaming providers
|
||||
|
||||
This module contains the core abstractions and models used by all providers.
|
||||
"""
|
||||
|
||||
from .models import StreamingChannel
|
||||
from .provider import StreamingProvider
|
||||
from .manager import ProviderManager
|
||||
from .drm import DRMPlugin, DRMPluginManager
|
||||
|
||||
__all__ = [
|
||||
"StreamingChannel",
|
||||
"StreamingProvider",
|
||||
"ProviderManager",
|
||||
"DRMPlugin",
|
||||
"DRMPluginManager",
|
||||
]
|
||||
@@ -0,0 +1,16 @@
|
||||
# streaming_providers/base/auth/__init__.py
|
||||
from .base_auth import BaseAuthenticator, BaseAuthToken
|
||||
from .credentials import BaseCredentials, UserPasswordCredentials, ClientCredentials
|
||||
from .session_manager import SessionManager
|
||||
from .credential_manager import CredentialManager
|
||||
|
||||
# Only export what consumers should use
|
||||
__all__ = [
|
||||
'BaseAuthenticator',
|
||||
'BaseAuthToken',
|
||||
'BaseCredentials',
|
||||
'UserPasswordCredentials',
|
||||
'ClientCredentials',
|
||||
'SessionManager',
|
||||
'CredentialManager'
|
||||
]
|
||||
@@ -0,0 +1,694 @@
|
||||
# streaming_providers/base/auth/base_auth.py (Country-Aware)
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict, Optional, Any
|
||||
from enum import Enum
|
||||
import time
|
||||
|
||||
from ..utils.logger import logger
|
||||
|
||||
|
||||
class TokenAuthLevel(Enum):
|
||||
"""Classification of token authentication levels"""
|
||||
ANONYMOUS = "anonymous" # No user authentication
|
||||
CLIENT_CREDENTIALS = "client_credentials" # Client credentials flow
|
||||
USER_AUTHENTICATED = "user_authenticated" # User login flow
|
||||
UNKNOWN = "unknown" # Cannot determine
|
||||
|
||||
|
||||
@dataclass
|
||||
class BaseAuthToken(ABC):
|
||||
"""Base class for authentication tokens with enhanced metadata"""
|
||||
access_token: str
|
||||
token_type: str
|
||||
expires_in: int
|
||||
issued_at: float
|
||||
refresh_token: Optional[str] = None
|
||||
refresh_expires_in: int = 0
|
||||
|
||||
# Token metadata for upgrade logic
|
||||
auth_level: TokenAuthLevel = TokenAuthLevel.UNKNOWN
|
||||
credential_type: Optional[str] = None # Type of credentials used
|
||||
|
||||
@property
|
||||
def is_expired(self) -> bool:
|
||||
"""Check if token is expired (with 5 minute buffer)"""
|
||||
return time.time() >= (self.issued_at + self.expires_in - 300)
|
||||
|
||||
def needs_refresh(self) -> bool:
|
||||
"""Check if token should be refreshed"""
|
||||
if not self.refresh_token:
|
||||
return False
|
||||
|
||||
current_time = time.time()
|
||||
access_token_expiry = self.issued_at + self.expires_in
|
||||
needs_access_refresh = current_time > (access_token_expiry - 300)
|
||||
|
||||
refresh_token_valid = True
|
||||
if self.refresh_expires_in > 0:
|
||||
refresh_token_expiry = self.issued_at + self.refresh_expires_in
|
||||
refresh_token_valid = current_time < (refresh_token_expiry - 300)
|
||||
|
||||
return needs_access_refresh and refresh_token_valid
|
||||
|
||||
def is_anonymous(self) -> bool:
|
||||
"""Check if this is an anonymous token"""
|
||||
return self.auth_level == TokenAuthLevel.ANONYMOUS
|
||||
|
||||
def is_client_credentials(self) -> bool:
|
||||
"""Check if this is a client credentials token"""
|
||||
return self.auth_level == TokenAuthLevel.CLIENT_CREDENTIALS
|
||||
|
||||
def is_user_authenticated(self) -> bool:
|
||||
"""Check if this is a user-authenticated token"""
|
||||
return self.auth_level == TokenAuthLevel.USER_AUTHENTICATED
|
||||
|
||||
def can_be_upgraded(self) -> bool:
|
||||
"""Check if this token can be upgraded to a higher auth level"""
|
||||
return self.auth_level in [
|
||||
TokenAuthLevel.ANONYMOUS,
|
||||
TokenAuthLevel.CLIENT_CREDENTIALS,
|
||||
TokenAuthLevel.UNKNOWN
|
||||
]
|
||||
|
||||
@property
|
||||
def bearer_token(self) -> str:
|
||||
"""Get the bearer token string"""
|
||||
return self.access_token
|
||||
|
||||
@abstractmethod
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert token to dictionary representation"""
|
||||
pass
|
||||
|
||||
|
||||
class BaseAuthenticator(ABC):
|
||||
"""
|
||||
Abstract base class for provider authenticators
|
||||
Now supports country-specific authentication
|
||||
"""
|
||||
|
||||
def __init__(self, provider_name: str, settings_manager=None, credentials=None,
|
||||
country: Optional[str] = None, config_dir: Optional[str] = None,
|
||||
enable_kodi_integration: bool = True):
|
||||
"""
|
||||
Initialize authenticator
|
||||
|
||||
Args:
|
||||
provider_name: Name of the streaming provider
|
||||
settings_manager: Injected settings manager (SettingsManager or compatible)
|
||||
credentials: Optional credentials to use (overrides all other sources)
|
||||
country: Optional country code (e.g., 'de', 'at', 'ch') for country-specific sessions
|
||||
config_dir: Optional config directory override (for backward compatibility)
|
||||
enable_kodi_integration: Whether to enable Kodi settings integration (for backward compatibility)
|
||||
"""
|
||||
self.provider_name = provider_name
|
||||
self.country = country
|
||||
self._current_token: Optional[BaseAuthToken] = None
|
||||
|
||||
# Log country configuration
|
||||
if self.country:
|
||||
logger.info(f"Initializing {provider_name} authenticator for country: {country}")
|
||||
else:
|
||||
logger.info(f"Initializing {provider_name} authenticator (no country specified)")
|
||||
|
||||
# Use injected settings manager or create one for backward compatibility
|
||||
if settings_manager is not None:
|
||||
self.settings_manager = settings_manager
|
||||
logger.info(f"Using injected settings manager for {provider_name}")
|
||||
else:
|
||||
# Create settings manager for backward compatibility
|
||||
self.settings_manager = self._create_settings_manager(config_dir, enable_kodi_integration)
|
||||
logger.info(f"Created settings manager for backward compatibility for {provider_name}")
|
||||
|
||||
# Register provider with settings manager
|
||||
if hasattr(self.settings_manager, 'register_provider'):
|
||||
self.settings_manager.register_provider(provider_name)
|
||||
|
||||
# Load credentials with priority:
|
||||
# 1. Provided credentials (highest priority)
|
||||
# 2. Settings manager
|
||||
if credentials:
|
||||
self.credentials = credentials
|
||||
logger.info(f"Using provided credentials for {provider_name}")
|
||||
else:
|
||||
self.credentials = self._load_credentials_from_manager()
|
||||
|
||||
# Load existing session/token
|
||||
self._load_session()
|
||||
|
||||
def _create_settings_manager(self, config_dir: Optional[str] = None, enable_kodi_integration: bool = True):
|
||||
"""Create settings manager for backward compatibility"""
|
||||
try:
|
||||
# Try to use the new SettingsManager
|
||||
from ..settings.settings_manager import SettingsManager
|
||||
|
||||
settings_manager = SettingsManager(
|
||||
config_dir=config_dir,
|
||||
enable_kodi_integration=enable_kodi_integration
|
||||
)
|
||||
logger.debug(f"Created SettingsManager for {self.provider_name}")
|
||||
return settings_manager
|
||||
|
||||
except ImportError as e:
|
||||
logger.warning(f"Could not import SettingsManager: {e}")
|
||||
# Fall back to adapter approach
|
||||
return self._create_adapter_fallback(config_dir, enable_kodi_integration)
|
||||
|
||||
def _create_adapter_fallback(self, config_dir: Optional[str] = None, enable_kodi_integration: bool = True):
|
||||
"""Create fallback using adapter pattern"""
|
||||
try:
|
||||
from ..settings.settings_manager_adapter import SettingsManagerFactory
|
||||
|
||||
adapter = SettingsManagerFactory.create_default_adapter(
|
||||
prefer_unified=True,
|
||||
config_dir=config_dir,
|
||||
enable_kodi_integration=enable_kodi_integration
|
||||
)
|
||||
logger.debug(f"Created adapter fallback for {self.provider_name}")
|
||||
return adapter
|
||||
|
||||
except ImportError as e:
|
||||
logger.warning(f"Could not create adapter fallback: {e}")
|
||||
# Create minimal fallback
|
||||
return self._create_minimal_fallback(config_dir)
|
||||
|
||||
def _create_minimal_fallback(self, config_dir: Optional[str] = None):
|
||||
"""Create minimal fallback manager"""
|
||||
try:
|
||||
# Try to create basic managers directly
|
||||
from .session_manager import SessionManager
|
||||
from .credential_manager import CredentialManager
|
||||
|
||||
class MinimalSettingsManager:
|
||||
def __init__(self, config_dir):
|
||||
self.session_manager = SessionManager(config_dir)
|
||||
self.credential_manager = CredentialManager(config_dir) if hasattr(self,
|
||||
'CredentialManager') else None
|
||||
|
||||
def get_provider_credentials(self, provider_name, country=None):
|
||||
if self.credential_manager:
|
||||
return self.credential_manager.load_credentials(provider_name, country)
|
||||
return None
|
||||
|
||||
def save_provider_credentials(self, provider_name, credentials, country=None):
|
||||
if self.credential_manager:
|
||||
return self.credential_manager.save_credentials(provider_name, credentials, country)
|
||||
return False
|
||||
|
||||
def load_token_data(self, provider_name, country=None):
|
||||
return self.session_manager.load_token_data(provider_name, country)
|
||||
|
||||
def save_token_data(self, provider_name, token_data, country=None):
|
||||
return self.session_manager.save_session(provider_name, token_data, country)
|
||||
|
||||
def get_device_id(self, provider_name, country=None):
|
||||
return self.session_manager.get_device_id(provider_name, country)
|
||||
|
||||
def clear_token(self, provider_name, country=None):
|
||||
return self.session_manager.clear_token(provider_name, country)
|
||||
|
||||
def get_credential_info(self, provider_name, country=None):
|
||||
return {
|
||||
'provider_name': provider_name,
|
||||
'country': country,
|
||||
'source': 'minimal_fallback',
|
||||
'config_dir': config_dir
|
||||
}
|
||||
|
||||
def register_provider(self, provider_name):
|
||||
# No-op for minimal fallback
|
||||
return True
|
||||
|
||||
return MinimalSettingsManager(config_dir)
|
||||
|
||||
except ImportError:
|
||||
# Absolute minimal fallback
|
||||
return self._create_absolute_minimal_fallback()
|
||||
|
||||
def _create_absolute_minimal_fallback(self):
|
||||
"""Create absolute minimal fallback when nothing else works"""
|
||||
|
||||
class AbsoluteMinimalManager:
|
||||
def get_provider_credentials(self, provider_name, country=None):
|
||||
return None
|
||||
|
||||
def save_provider_credentials(self, provider_name, credentials, country=None):
|
||||
return False
|
||||
|
||||
def load_token_data(self, provider_name, country=None):
|
||||
return None
|
||||
|
||||
def save_token_data(self, provider_name, token_data, country=None):
|
||||
return False
|
||||
|
||||
def get_device_id(self, provider_name, country=None):
|
||||
import uuid
|
||||
return str(uuid.uuid4())
|
||||
|
||||
def clear_token(self, provider_name, country=None):
|
||||
return False
|
||||
|
||||
def get_credential_info(self, provider_name, country=None):
|
||||
return {'provider_name': provider_name, 'country': country, 'source': 'absolute_minimal'}
|
||||
|
||||
def register_provider(self, provider_name):
|
||||
return True
|
||||
|
||||
logger.warning(f"Using absolute minimal fallback for {self.provider_name}")
|
||||
return AbsoluteMinimalManager()
|
||||
|
||||
def _load_credentials_from_manager(self):
|
||||
"""Load credentials from the settings manager"""
|
||||
try:
|
||||
# Check if it's a SettingsManager instance (or compatible)
|
||||
if hasattr(self.settings_manager, 'get_provider_credentials'):
|
||||
# Try country-aware call first
|
||||
try:
|
||||
return self.settings_manager.get_provider_credentials(self.provider_name, self.country)
|
||||
except TypeError:
|
||||
# Fallback for managers that don't support country parameter
|
||||
logger.debug(f"Settings manager doesn't support country parameter, using without")
|
||||
return self.settings_manager.get_provider_credentials(self.provider_name)
|
||||
else:
|
||||
logger.warning(f"Settings manager has no credential loading method for {self.provider_name}")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading credentials from manager for {self.provider_name}: {e}")
|
||||
return None
|
||||
|
||||
# Abstract methods remain unchanged
|
||||
@property
|
||||
@abstractmethod
|
||||
def auth_endpoint(self) -> str:
|
||||
"""Authentication endpoint URL"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def _get_auth_headers(self) -> Dict[str, str]:
|
||||
"""Get headers for authentication request"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def _build_auth_payload(self) -> Dict[str, Any]:
|
||||
"""Build authentication payload"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def _create_token_from_response(self, response_data: Dict[str, Any]) -> BaseAuthToken:
|
||||
"""Create token object from API response"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_fallback_credentials(self):
|
||||
"""Get fallback credentials when no user credentials are available"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def _perform_authentication(self) -> BaseAuthToken:
|
||||
"""Perform the actual authentication request"""
|
||||
pass
|
||||
|
||||
# Session management methods
|
||||
def _load_session(self) -> None:
|
||||
"""Load token from persistent storage"""
|
||||
try:
|
||||
country_str = f" (country: {self.country})" if self.country else ""
|
||||
logger.debug(f"Loading session for {self.provider_name}{country_str}")
|
||||
|
||||
# Try country-aware call first
|
||||
try:
|
||||
token_data = self.settings_manager.load_token_data(self.provider_name, self.country)
|
||||
except TypeError:
|
||||
# Fallback for managers that don't support country parameter
|
||||
logger.debug(f"Settings manager doesn't support country parameter, using without")
|
||||
token_data = self.settings_manager.load_token_data(self.provider_name)
|
||||
|
||||
if token_data:
|
||||
self._current_token = self._create_token_from_response(token_data)
|
||||
logger.info(f"Successfully loaded existing session for {self.provider_name}{country_str}")
|
||||
else:
|
||||
logger.info(f"No existing session found for {self.provider_name}{country_str}")
|
||||
self._current_token = None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading session for {self.provider_name}: {e}")
|
||||
self._current_token = None
|
||||
|
||||
def _save_session(self) -> None:
|
||||
"""Save current token to persistent storage"""
|
||||
if self._current_token:
|
||||
try:
|
||||
country_str = f" (country: {self.country})" if self.country else ""
|
||||
|
||||
# Try country-aware call first
|
||||
try:
|
||||
success = self.settings_manager.save_token_data(
|
||||
self.provider_name,
|
||||
self._current_token.to_dict(),
|
||||
self.country
|
||||
)
|
||||
except TypeError:
|
||||
# Fallback for managers that don't support country parameter
|
||||
logger.debug(f"Settings manager doesn't support country parameter, using without")
|
||||
success = self.settings_manager.save_token_data(
|
||||
self.provider_name,
|
||||
self._current_token.to_dict()
|
||||
)
|
||||
|
||||
if success:
|
||||
logger.debug(f"Saved session for {self.provider_name}{country_str}")
|
||||
else:
|
||||
logger.warning(f"Failed to save session for {self.provider_name}{country_str}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error saving session for {self.provider_name}: {e}")
|
||||
|
||||
def _ensure_credentials(self) -> bool:
|
||||
"""Ensure we have valid credentials"""
|
||||
# Try to refresh credentials from settings manager
|
||||
if not self.credentials or not self.credentials.validate():
|
||||
fresh_credentials = self._load_credentials_from_manager()
|
||||
if fresh_credentials and fresh_credentials.validate():
|
||||
self.credentials = fresh_credentials
|
||||
logger.debug(f"Refreshed credentials for {self.provider_name}")
|
||||
|
||||
# If we still don't have valid credentials, try fallback
|
||||
if not self.credentials or not self.credentials.validate():
|
||||
logger.info(f"No valid user credentials found for {self.provider_name}, using fallback")
|
||||
self.credentials = self.get_fallback_credentials()
|
||||
|
||||
return self.credentials is not None and self.credentials.validate()
|
||||
|
||||
# Authentication methods remain mostly unchanged
|
||||
def authenticate(self, force_refresh: bool = False) -> BaseAuthToken:
|
||||
"""Authenticate and get access token with persistent storage"""
|
||||
country_str = f" (country: {self.country})" if self.country else ""
|
||||
logger.debug(f"[{self.provider_name}{country_str}] Starting authentication, force_refresh={force_refresh}")
|
||||
|
||||
# Check current token state for debugging
|
||||
if self._current_token:
|
||||
logger.debug(
|
||||
f"[{self.provider_name}{country_str}] Current token - is_expired: {self._current_token.is_expired}, "
|
||||
f"has_refresh: {bool(self._current_token.refresh_token)}")
|
||||
else:
|
||||
logger.debug(f"[{self.provider_name}{country_str}] No current token")
|
||||
|
||||
# 1. Return existing token if valid
|
||||
if not force_refresh and self._current_token and not self._current_token.is_expired:
|
||||
logger.info(f"[{self.provider_name}{country_str}] Using existing valid token")
|
||||
return self._current_token
|
||||
|
||||
# 2. Try refresh if available
|
||||
if (not force_refresh and
|
||||
self._current_token and
|
||||
self._current_token.refresh_token and
|
||||
self._current_token.needs_refresh()):
|
||||
|
||||
logger.info(f"[{self.provider_name}{country_str}] Attempting token refresh")
|
||||
try:
|
||||
refreshed_token = self._refresh_token()
|
||||
logger.debug(f"[{self.provider_name}{country_str}] Refresh result: {refreshed_token is not None}")
|
||||
|
||||
if refreshed_token:
|
||||
self._current_token = refreshed_token
|
||||
self._save_session()
|
||||
logger.info(f"[{self.provider_name}{country_str}] Token refresh successful")
|
||||
return self._current_token
|
||||
else:
|
||||
logger.debug(
|
||||
f"[{self.provider_name}{country_str}] Refresh returned None, falling back to full auth")
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"[{self.provider_name}{country_str}] Token refresh failed: {e}, attempting new authentication")
|
||||
|
||||
# 3. Ensure we have credentials before attempting full authentication
|
||||
if not self._ensure_credentials():
|
||||
raise Exception(f"No valid credentials available for {self.provider_name}")
|
||||
|
||||
# 4. Perform full authentication
|
||||
logger.info(f"[{self.provider_name}{country_str}] Performing new authentication")
|
||||
token = self._perform_authentication()
|
||||
self._current_token = token
|
||||
self._save_session()
|
||||
logger.info(f"[{self.provider_name}{country_str}] Authentication successful")
|
||||
return token
|
||||
|
||||
# Credential management methods
|
||||
def save_credentials(self, credentials, sync_to_kodi: bool = False) -> bool:
|
||||
"""Save credentials to persistent storage"""
|
||||
try:
|
||||
# Try country-aware call first
|
||||
try:
|
||||
success = self.settings_manager.save_provider_credentials(
|
||||
self.provider_name,
|
||||
credentials,
|
||||
self.country
|
||||
)
|
||||
except TypeError:
|
||||
# Fallback for managers that don't support country parameter
|
||||
logger.debug(f"Settings manager doesn't support country parameter, using without")
|
||||
success = self.settings_manager.save_provider_credentials(
|
||||
self.provider_name,
|
||||
credentials
|
||||
)
|
||||
|
||||
if success:
|
||||
self.credentials = credentials
|
||||
country_str = f" (country: {self.country})" if self.country else ""
|
||||
logger.info(f"Saved credentials for {self.provider_name}{country_str}")
|
||||
return success
|
||||
except Exception as e:
|
||||
logger.error(f"Error saving credentials for {self.provider_name}: {e}")
|
||||
return False
|
||||
|
||||
def sync_credentials_from_kodi(self) -> bool:
|
||||
"""
|
||||
Manually sync credentials from Kodi settings (for backward compatibility)
|
||||
"""
|
||||
try:
|
||||
if hasattr(self.settings_manager, 'sync_all_from_kodi'):
|
||||
results = self.settings_manager.sync_all_from_kodi()
|
||||
success = results.get(self.provider_name, True)
|
||||
|
||||
if success:
|
||||
# Reload credentials after successful sync
|
||||
self.credentials = self._load_credentials_from_manager()
|
||||
logger.info(f"Successfully synced and reloaded credentials from Kodi for {self.provider_name}")
|
||||
|
||||
return success
|
||||
else:
|
||||
logger.debug(f"No Kodi sync capability available for {self.provider_name}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error syncing credentials from Kodi for {self.provider_name}: {e}")
|
||||
return False
|
||||
|
||||
def get_credential_info(self) -> Dict[str, Any]:
|
||||
"""Get information about credential sources"""
|
||||
base_info = {
|
||||
'provider': self.provider_name,
|
||||
'country': self.country,
|
||||
'has_current_credentials': self.credentials is not None,
|
||||
'current_credentials_valid': self.credentials.validate() if self.credentials else False,
|
||||
'current_credential_type': self.credentials.credential_type if self.credentials else None
|
||||
}
|
||||
|
||||
# Add settings manager info if available
|
||||
try:
|
||||
# Try country-aware call first
|
||||
try:
|
||||
manager_info = self.settings_manager.get_credential_info(self.provider_name, self.country)
|
||||
except TypeError:
|
||||
manager_info = self.settings_manager.get_credential_info(self.provider_name)
|
||||
base_info.update(manager_info)
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not get extended credential info for {self.provider_name}: {e}")
|
||||
|
||||
return base_info
|
||||
|
||||
# Backward compatibility aliases
|
||||
def get_credential_source_info(self) -> Dict[str, Any]:
|
||||
"""Alias for get_credential_info for backward compatibility"""
|
||||
return self.get_credential_info()
|
||||
|
||||
def clear_stored_credentials(self) -> bool:
|
||||
"""Clear stored credentials and revert to fallback credentials"""
|
||||
try:
|
||||
self.credentials = self.get_fallback_credentials()
|
||||
|
||||
# Try to clear through settings manager
|
||||
success = True
|
||||
if hasattr(self.settings_manager, 'credential_manager'):
|
||||
# Try country-aware call first
|
||||
try:
|
||||
success = self.settings_manager.credential_manager.delete_credentials(
|
||||
self.provider_name,
|
||||
self.country
|
||||
)
|
||||
except TypeError:
|
||||
success = self.settings_manager.credential_manager.delete_credentials(self.provider_name)
|
||||
else:
|
||||
logger.debug(f"No credential deletion capability in settings manager for {self.provider_name}")
|
||||
|
||||
if success:
|
||||
logger.info(f"{self.provider_name}: Stored credentials cleared successfully")
|
||||
self.invalidate_token()
|
||||
else:
|
||||
logger.warning(f"{self.provider_name}: Failed to clear stored credentials")
|
||||
return success
|
||||
except Exception as e:
|
||||
logger.error(f"Error clearing stored credentials for {self.provider_name}: {e}")
|
||||
return False
|
||||
|
||||
def has_stored_credentials(self) -> bool:
|
||||
"""Check if stored credentials exist"""
|
||||
try:
|
||||
# Try country-aware call first
|
||||
try:
|
||||
credentials = self.settings_manager.get_provider_credentials(self.provider_name, self.country)
|
||||
except TypeError:
|
||||
credentials = self.settings_manager.get_provider_credentials(self.provider_name)
|
||||
return credentials is not None and credentials.validate()
|
||||
except Exception as e:
|
||||
logger.debug(f"Error checking stored credentials for {self.provider_name}: {e}")
|
||||
return False
|
||||
|
||||
def test_current_credentials(self) -> bool:
|
||||
"""Test current credentials by attempting authentication"""
|
||||
try:
|
||||
country_str = f" (country: {self.country})" if self.country else ""
|
||||
logger.debug(f"Testing credentials for {self.provider_name}{country_str}")
|
||||
self.authenticate(force_refresh=True)
|
||||
logger.info(f"Credential test passed for {self.provider_name}{country_str}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning(f"Credential test failed for {self.provider_name}: {e}")
|
||||
return False
|
||||
|
||||
# Token management methods
|
||||
def _refresh_token(self) -> Optional[BaseAuthToken]:
|
||||
"""Refresh the current token using refresh token"""
|
||||
return None
|
||||
|
||||
def get_bearer_token(self, force_refresh: bool = False) -> str:
|
||||
"""Get current bearer token, authenticating if necessary"""
|
||||
token = self.authenticate(force_refresh)
|
||||
return token.bearer_token
|
||||
|
||||
def is_authenticated(self) -> bool:
|
||||
"""Check if currently authenticated with valid token"""
|
||||
return self._current_token is not None and not self._current_token.is_expired
|
||||
|
||||
def invalidate_token(self) -> None:
|
||||
"""Invalidate current token"""
|
||||
country_str = f" (country: {self.country})" if self.country else ""
|
||||
logger.debug(f"Invalidating token for {self.provider_name}{country_str}")
|
||||
self._current_token = None
|
||||
|
||||
try:
|
||||
# Try country-aware call first
|
||||
try:
|
||||
self.settings_manager.clear_token(self.provider_name, self.country)
|
||||
except TypeError:
|
||||
self.settings_manager.clear_token(self.provider_name)
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not clear token from settings manager for {self.provider_name}: {e}")
|
||||
|
||||
def get_device_id(self) -> str:
|
||||
"""Get persistent device ID for this provider"""
|
||||
try:
|
||||
# Try country-aware call first
|
||||
try:
|
||||
return self.settings_manager.get_device_id(self.provider_name, self.country)
|
||||
except TypeError:
|
||||
return self.settings_manager.get_device_id(self.provider_name)
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting device ID for {self.provider_name}: {e}")
|
||||
import uuid
|
||||
return str(uuid.uuid4())
|
||||
|
||||
def get_token_info(self) -> Optional[Dict[str, Any]]:
|
||||
"""Get information about the current token"""
|
||||
if not self._current_token:
|
||||
return None
|
||||
|
||||
base_info = {
|
||||
'expires_in': self._current_token.expires_in,
|
||||
'issued_at': self._current_token.issued_at,
|
||||
'is_expired': self._current_token.is_expired,
|
||||
'token_type': self._current_token.token_type,
|
||||
'has_refresh_token': self._current_token.refresh_token is not None,
|
||||
'country': self.country
|
||||
}
|
||||
|
||||
base_info.update(self._current_token.to_dict())
|
||||
return base_info
|
||||
|
||||
def get_authentication_status(self) -> Dict[str, Any]:
|
||||
"""Get detailed authentication status information"""
|
||||
return {
|
||||
'provider': self.provider_name,
|
||||
'country': self.country,
|
||||
'is_authenticated': self.is_authenticated(),
|
||||
'token_info': self.get_token_info(),
|
||||
'credential_info': self.get_credential_info()
|
||||
}
|
||||
|
||||
@abstractmethod
|
||||
def _classify_token(self, token: BaseAuthToken) -> TokenAuthLevel:
|
||||
"""
|
||||
Classify the authentication level of a token
|
||||
|
||||
Must be implemented by subclasses to provide provider-specific logic
|
||||
for determining if a token is anonymous, client credentials, or user-authenticated.
|
||||
|
||||
Args:
|
||||
token: Token to classify
|
||||
|
||||
Returns:
|
||||
TokenAuthLevel indicating the authentication level
|
||||
"""
|
||||
pass
|
||||
|
||||
def should_upgrade_token(self, token: BaseAuthToken) -> bool:
|
||||
"""
|
||||
Determine if a token should be upgraded based on:
|
||||
1. Token's current authentication level
|
||||
2. Availability of user credentials
|
||||
3. Provider-specific upgrade policy
|
||||
|
||||
Args:
|
||||
token: Current token to evaluate
|
||||
|
||||
Returns:
|
||||
True if token should be upgraded, False otherwise
|
||||
"""
|
||||
if not token:
|
||||
return False
|
||||
|
||||
# Classify token if not already classified
|
||||
if token.auth_level == TokenAuthLevel.UNKNOWN:
|
||||
token.auth_level = self._classify_token(token)
|
||||
|
||||
# Check if token can be upgraded
|
||||
if not token.can_be_upgraded():
|
||||
return False
|
||||
|
||||
# Check if we have user credentials available
|
||||
from ...base.auth.credentials import UserPasswordCredentials
|
||||
|
||||
# Check stored credentials first
|
||||
try:
|
||||
stored_creds = self.settings_manager.get_provider_credentials(self.provider_name, self.country)
|
||||
except TypeError:
|
||||
stored_creds = self.settings_manager.get_provider_credentials(self.provider_name)
|
||||
|
||||
has_user_creds = isinstance(stored_creds, UserPasswordCredentials) and stored_creds.validate()
|
||||
|
||||
# Check current credentials
|
||||
if not has_user_creds:
|
||||
has_user_creds = isinstance(self.credentials, UserPasswordCredentials) and self.credentials.validate()
|
||||
|
||||
# Only upgrade if we have user credentials
|
||||
return has_user_creds
|
||||
@@ -0,0 +1,917 @@
|
||||
# streaming_providers/base/auth/base_oauth2_auth.py
|
||||
from abc import abstractmethod
|
||||
from typing import Dict, Optional, Any, Callable
|
||||
import uuid
|
||||
import hashlib
|
||||
import base64
|
||||
import secrets
|
||||
import re
|
||||
import html
|
||||
from urllib.parse import urlencode, parse_qs, urlparse
|
||||
|
||||
from .base_auth import BaseAuthenticator, BaseAuthToken, TokenAuthLevel
|
||||
from ..utils.logger import logger
|
||||
from ..models.proxy_models import ProxyConfig
|
||||
|
||||
|
||||
class OAuth2Error(Exception):
|
||||
"""OAuth2-specific error with structured error information"""
|
||||
|
||||
def __init__(self, error: str, error_description: str = None, error_uri: str = None):
|
||||
self.error = error
|
||||
self.error_description = error_description
|
||||
self.error_uri = error_uri
|
||||
message = error
|
||||
if error_description:
|
||||
message = f"{error}: {error_description}"
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
class SessionAwareHTTPManager:
|
||||
"""Wraps http_manager to provide session-like cookie handling while maintaining proxy support"""
|
||||
|
||||
def __init__(self, http_manager):
|
||||
self.http_manager = http_manager
|
||||
self.cookies = {}
|
||||
self.headers = {}
|
||||
|
||||
def get(self, url: str, **kwargs):
|
||||
"""GET request with cookie handling"""
|
||||
headers = kwargs.get('headers', {}).copy()
|
||||
headers.update(self.headers)
|
||||
|
||||
# Add cookies
|
||||
if self.cookies:
|
||||
cookie_str = '; '.join([f"{k}={v}" for k, v in self.cookies.items()])
|
||||
headers['Cookie'] = cookie_str
|
||||
|
||||
kwargs['headers'] = headers
|
||||
response = self.http_manager.get(url, operation='oauth', **kwargs)
|
||||
|
||||
# Update cookies from response
|
||||
self._update_cookies_from_response(response)
|
||||
return response
|
||||
|
||||
def post(self, url: str, **kwargs):
|
||||
"""POST request with cookie handling"""
|
||||
headers = kwargs.get('headers', {}).copy()
|
||||
headers.update(self.headers)
|
||||
|
||||
# Add cookies
|
||||
if self.cookies:
|
||||
cookie_str = '; '.join([f"{k}={v}" for k, v in self.cookies.items()])
|
||||
headers['Cookie'] = cookie_str
|
||||
|
||||
kwargs['headers'] = headers
|
||||
response = self.http_manager.post(url, operation='oauth', **kwargs)
|
||||
|
||||
# Update cookies from response
|
||||
self._update_cookies_from_response(response)
|
||||
return response
|
||||
|
||||
def _update_cookies_from_response(self, response):
|
||||
"""Extract and update cookies from response"""
|
||||
if hasattr(response, 'cookies'):
|
||||
for cookie in response.cookies:
|
||||
self.cookies[cookie.name] = cookie.value
|
||||
|
||||
|
||||
class BaseOAuth2Authenticator(BaseAuthenticator):
|
||||
def __init__(self, provider_name: str, settings_manager=None, credentials=None,
|
||||
country: Optional[str] = None, # ADD THIS PARAMETER
|
||||
config_dir: Optional[str] = None, enable_kodi_integration: bool = True,
|
||||
proxy_config: Optional[ProxyConfig] = None,
|
||||
http_manager=None):
|
||||
# Pass country to parent BaseAuthenticator
|
||||
super().__init__(
|
||||
provider_name,
|
||||
settings_manager,
|
||||
credentials,
|
||||
country=country, # ADD THIS LINE
|
||||
config_dir=config_dir,
|
||||
enable_kodi_integration=enable_kodi_integration
|
||||
)
|
||||
self._oauth_state = None
|
||||
self._pkce_verifier = None
|
||||
|
||||
# Preserve _config if subclass already set it, otherwise initialize to None
|
||||
if not hasattr(self, '_config'):
|
||||
self._config = None
|
||||
|
||||
self._proxy_config = proxy_config
|
||||
self._auth_endpoint = None
|
||||
self._http_manager = http_manager
|
||||
self._token_expiry_buffer = 300
|
||||
|
||||
@property
|
||||
def http_manager(self):
|
||||
"""Safe access to http_manager - use provided one or create fallback"""
|
||||
if self._http_manager is not None:
|
||||
return self._http_manager
|
||||
|
||||
logger.warning(f"No HTTP manager available for {self.provider_name}, creating one")
|
||||
|
||||
try:
|
||||
from ...base.network import HTTPManagerFactory
|
||||
self._http_manager = HTTPManagerFactory.create_for_provider(
|
||||
self.provider_name,
|
||||
proxy_config=self._proxy_config,
|
||||
user_agent=getattr(self.config, 'user_agent', 'Mozilla/5.0'),
|
||||
timeout=getattr(self.config, 'timeout', 30)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Error creating HTTP manager via factory: {e}, using minimal fallback")
|
||||
self._http_manager = self._create_minimal_http_manager()
|
||||
|
||||
return self._http_manager
|
||||
|
||||
@http_manager.setter
|
||||
def http_manager(self, value):
|
||||
"""Allow setting http_manager"""
|
||||
self._http_manager = value
|
||||
|
||||
@property
|
||||
def config(self):
|
||||
"""Safe access to config with fallback"""
|
||||
import traceback
|
||||
|
||||
if self._config is not None:
|
||||
return self._config
|
||||
|
||||
# Log who's calling this before config is set
|
||||
logger.warning(f"Config accessed before initialization for {self.provider_name}")
|
||||
logger.debug(f"Call stack:\n{''.join(traceback.format_stack()[-5:])}")
|
||||
|
||||
# Only create minimal config if absolutely necessary
|
||||
class MinimalConfig:
|
||||
def __init__(self):
|
||||
self.timeout = 30
|
||||
self.user_agent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
|
||||
self.base_website = "https://example.com"
|
||||
self.auth_endpoint = "https://auth.example.com"
|
||||
|
||||
def get_base_headers(self):
|
||||
return {
|
||||
'User-Agent': self.user_agent,
|
||||
'Accept': 'application/json',
|
||||
}
|
||||
|
||||
def get_auth_headers(self):
|
||||
return self.get_base_headers()
|
||||
|
||||
self._config = MinimalConfig()
|
||||
logger.warning(f"Using minimal config for {self.provider_name} - subclass should set config")
|
||||
return self._config
|
||||
|
||||
@config.setter
|
||||
def config(self, value):
|
||||
"""Allow subclasses to set config"""
|
||||
self._config = value
|
||||
|
||||
@staticmethod
|
||||
def _create_minimal_http_manager():
|
||||
"""Create absolute minimal HTTP manager fallback"""
|
||||
|
||||
class MinimalHTTPManager:
|
||||
@staticmethod
|
||||
def get(url, operation=None, headers=None, **kwargs):
|
||||
import requests
|
||||
return requests.get(url, headers=headers, **kwargs)
|
||||
|
||||
@staticmethod
|
||||
def post(url, operation=None, headers=None, data=None, **kwargs):
|
||||
import requests
|
||||
return requests.post(url, headers=headers, data=data, **kwargs)
|
||||
|
||||
return MinimalHTTPManager()
|
||||
|
||||
@property
|
||||
def auth_endpoint(self) -> str:
|
||||
"""Get authentication endpoint - subclasses can override"""
|
||||
if hasattr(self, '_auth_endpoint') and self._auth_endpoint:
|
||||
return self._auth_endpoint
|
||||
|
||||
if hasattr(self.config, 'auth_endpoint'):
|
||||
return self.config.auth_endpoint
|
||||
|
||||
raise NotImplementedError("Subclass must implement auth_endpoint or set _auth_endpoint")
|
||||
|
||||
@auth_endpoint.setter
|
||||
def auth_endpoint(self, value):
|
||||
"""Allow setting auth_endpoint directly"""
|
||||
self._auth_endpoint = value
|
||||
|
||||
# Abstract properties
|
||||
@property
|
||||
@abstractmethod
|
||||
def oauth_client_id(self) -> str:
|
||||
pass
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def oauth_scope(self) -> str:
|
||||
pass
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def oauth_redirect_uri(self) -> str:
|
||||
pass
|
||||
|
||||
@property
|
||||
def oauth_authorize_endpoint(self) -> str:
|
||||
"""Get OAuth2 authorization endpoint"""
|
||||
if hasattr(self, 'auth_endpoint'):
|
||||
auth_endpoint = self.auth_endpoint
|
||||
else:
|
||||
logger.warning(f"auth_endpoint not defined for {self.provider_name}, using default")
|
||||
return "https://auth.example.com/oauth2/auth"
|
||||
|
||||
if auth_endpoint.endswith('/token'):
|
||||
return auth_endpoint.replace('/token', '/auth')
|
||||
elif '/protocol/openid-connect/token' in auth_endpoint:
|
||||
return auth_endpoint.replace('/token', '/auth')
|
||||
else:
|
||||
return '/'.join(auth_endpoint.split('/')[:-1]) + '/auth'
|
||||
|
||||
# PKCE Implementation
|
||||
@staticmethod
|
||||
def generate_pkce_verifier() -> str:
|
||||
"""Generate PKCE code verifier (RFC 7636)"""
|
||||
token = secrets.token_bytes(32)
|
||||
verifier = base64.urlsafe_b64encode(token).rstrip(b'=').decode('ascii')
|
||||
logger.debug(f"Generated PKCE verifier: {verifier}")
|
||||
return verifier
|
||||
|
||||
@staticmethod
|
||||
def generate_pkce_challenge(verifier: str) -> str:
|
||||
"""Generate PKCE code challenge from verifier"""
|
||||
challenge = hashlib.sha256(verifier.encode('ascii')).digest()
|
||||
challenge_b64 = base64.urlsafe_b64encode(challenge).rstrip(b'=').decode('ascii')
|
||||
logger.debug(f"Generated PKCE challenge: {challenge_b64}")
|
||||
return challenge_b64
|
||||
|
||||
# OAuth2 State Management
|
||||
def generate_oauth_state(self) -> str:
|
||||
"""Generate secure state parameter for OAuth2 flow"""
|
||||
state = str(uuid.uuid4())
|
||||
self._oauth_state = state
|
||||
return state
|
||||
|
||||
@staticmethod
|
||||
def generate_oauth_nonce() -> str:
|
||||
"""Generate secure nonce parameter for OAuth2 flow"""
|
||||
return str(uuid.uuid4())
|
||||
|
||||
@staticmethod
|
||||
def validate_oauth_state(received_state: str, original_state: str) -> bool:
|
||||
"""Validate OAuth2 state parameter to prevent CSRF"""
|
||||
if not received_state or not original_state:
|
||||
logger.warning("OAuth2 state validation failed: missing state parameters")
|
||||
return False
|
||||
|
||||
is_valid = received_state == original_state
|
||||
if not is_valid:
|
||||
logger.warning("OAuth2 state validation failed: state mismatch")
|
||||
|
||||
return is_valid
|
||||
|
||||
# Session Management
|
||||
def _create_oauth_session(self) -> SessionAwareHTTPManager:
|
||||
"""Create a session-aware HTTP manager for OAuth flows"""
|
||||
session = SessionAwareHTTPManager(self.http_manager)
|
||||
session.headers.update({
|
||||
'User-Agent': self.config.user_agent,
|
||||
'Referer': getattr(self.config, 'base_website', ''),
|
||||
'Origin': getattr(self.config, 'base_website', '')
|
||||
})
|
||||
return session
|
||||
|
||||
# Complete Client Credentials Flow
|
||||
def _perform_oauth_client_credentials_flow(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Complete manual implementation of OAuth2 client credentials flow
|
||||
Uses provider-specific headers and payload formatting
|
||||
"""
|
||||
try:
|
||||
logger.debug(f"Starting OAuth2 client credentials flow for {self.provider_name}")
|
||||
|
||||
headers = self._get_auth_headers()
|
||||
data = self._build_auth_payload()
|
||||
|
||||
response = self.http_manager.post(
|
||||
self.auth_endpoint,
|
||||
operation='auth',
|
||||
headers=headers,
|
||||
data=data
|
||||
)
|
||||
|
||||
self._check_oauth_error_response(response)
|
||||
response.raise_for_status()
|
||||
|
||||
token_data = response.json()
|
||||
logger.debug(f"OAuth2 client credentials flow successful for {self.provider_name}")
|
||||
return token_data
|
||||
|
||||
except OAuth2Error:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"OAuth2 client credentials flow failed for {self.provider_name}: {e}")
|
||||
raise Exception(f"OAuth2 client credentials flow failed: {e}")
|
||||
|
||||
# Authorization URL Building
|
||||
def _build_authorization_url(self, extra_params: Dict[str, Any] = None) -> tuple[str, str, str]:
|
||||
"""Build authorization URL with PKCE for authorization code flow"""
|
||||
code_verifier = self.generate_pkce_verifier()
|
||||
code_challenge = self.generate_pkce_challenge(code_verifier)
|
||||
state = self.generate_oauth_state()
|
||||
|
||||
params = {
|
||||
'response_type': 'code',
|
||||
'client_id': self.oauth_client_id,
|
||||
'redirect_uri': self.oauth_redirect_uri,
|
||||
'scope': self.oauth_scope,
|
||||
'state': state,
|
||||
'code_challenge': code_challenge,
|
||||
'code_challenge_method': 'S256'
|
||||
}
|
||||
|
||||
if extra_params:
|
||||
params.update(extra_params)
|
||||
|
||||
authorization_url = f"{self.oauth_authorize_endpoint}?{urlencode(params)}"
|
||||
|
||||
return authorization_url, state, code_verifier
|
||||
|
||||
# Authorization Code Exchange
|
||||
def _exchange_authorization_code_for_token(self, authorization_code: str, code_verifier: str,
|
||||
state: str = None, **kwargs) -> Dict[str, Any]:
|
||||
"""
|
||||
Exchange authorization code for access token (PKCE flow)
|
||||
Enhanced to support provider-specific customizations
|
||||
"""
|
||||
try:
|
||||
logger.debug(f"Exchanging authorization code for token for {self.provider_name}")
|
||||
|
||||
# Allow subclasses to override the default payload
|
||||
data = self._build_token_exchange_payload(
|
||||
authorization_code=authorization_code,
|
||||
code_verifier=code_verifier,
|
||||
state=state,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
# Allow subclasses to override headers
|
||||
headers = self._get_token_exchange_headers(**kwargs)
|
||||
|
||||
# Allow subclasses to override data format and endpoint
|
||||
endpoint = self._get_token_exchange_endpoint(**kwargs)
|
||||
use_json = self._should_use_json_for_token_exchange(**kwargs)
|
||||
|
||||
request_kwargs = {
|
||||
'operation': 'auth',
|
||||
'headers': headers,
|
||||
'timeout': getattr(self.config, 'timeout', 30)
|
||||
}
|
||||
|
||||
if use_json:
|
||||
request_kwargs['json_data'] = data
|
||||
else:
|
||||
request_kwargs['data'] = urlencode(data).encode()
|
||||
|
||||
response = self.http_manager.post(
|
||||
endpoint,
|
||||
**request_kwargs
|
||||
)
|
||||
|
||||
self._check_oauth_error_response(response)
|
||||
response.raise_for_status()
|
||||
|
||||
token_data = response.json()
|
||||
logger.debug(f"Authorization code exchange successful for {self.provider_name}")
|
||||
return token_data
|
||||
|
||||
except OAuth2Error:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Authorization code exchange failed for {self.provider_name}: {e}")
|
||||
raise Exception(f"Authorization code exchange failed: {e}")
|
||||
|
||||
# New flexible methods that subclasses can override
|
||||
def _build_token_exchange_payload(self, authorization_code: str, code_verifier: str,
|
||||
state: str = None, **kwargs) -> Dict[str, Any]:
|
||||
"""Build token exchange payload - subclasses can override for custom parameters"""
|
||||
data = {
|
||||
'grant_type': 'authorization_code',
|
||||
'client_id': self.oauth_client_id,
|
||||
'code': authorization_code,
|
||||
'redirect_uri': self.oauth_redirect_uri,
|
||||
'code_verifier': code_verifier
|
||||
}
|
||||
|
||||
client_secret = getattr(self.credentials, 'client_secret', None)
|
||||
if client_secret:
|
||||
data['client_secret'] = client_secret
|
||||
|
||||
return data
|
||||
|
||||
def _get_token_exchange_headers(self, **kwargs) -> Dict[str, str]:
|
||||
"""Get token exchange headers - subclasses can override for custom headers"""
|
||||
headers = self._get_auth_headers()
|
||||
|
||||
# Ensure Content-Type is appropriate
|
||||
if kwargs.get('use_json', False) or self._should_use_json_for_token_exchange(**kwargs):
|
||||
headers['Content-Type'] = 'application/json'
|
||||
else:
|
||||
headers['Content-Type'] = 'application/x-www-form-urlencoded'
|
||||
|
||||
return headers
|
||||
|
||||
def _get_token_exchange_endpoint(self, **kwargs) -> str:
|
||||
"""Get token exchange endpoint - subclasses can override for custom endpoints"""
|
||||
return self.auth_endpoint
|
||||
|
||||
@staticmethod
|
||||
def _should_use_json_for_token_exchange(**kwargs) -> bool:
|
||||
"""Determine if token exchange should use JSON - subclasses can override"""
|
||||
return False # Default to form-encoded for OAuth2 compliance
|
||||
|
||||
# Generic Form-Based Login Flow
|
||||
def _perform_generic_form_login(
|
||||
self,
|
||||
username: str,
|
||||
password: str,
|
||||
form_selector_pattern: str,
|
||||
login_fields: Dict[str, str],
|
||||
extra_params: Dict[str, Any] = None,
|
||||
additional_form_data: Dict[str, str] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Generic OAuth2 form-based login flow
|
||||
|
||||
Args:
|
||||
username: User's username
|
||||
password: User's password
|
||||
form_selector_pattern: Regex to find login form action URL
|
||||
login_fields: Field names mapping (e.g., {'username': 'email', 'password': 'pass'})
|
||||
extra_params: Additional authorization URL parameters
|
||||
additional_form_data: Additional form fields to submit
|
||||
|
||||
Returns:
|
||||
Token data dictionary
|
||||
"""
|
||||
try:
|
||||
auth_url, state, code_verifier = self._build_authorization_url(extra_params)
|
||||
|
||||
session = self._create_oauth_session()
|
||||
|
||||
# Step 1: Get login form
|
||||
auth_response = session.get(auth_url, timeout=self.config.timeout)
|
||||
auth_response.raise_for_status()
|
||||
|
||||
# Step 2: Extract login form action URL
|
||||
form_matches = re.findall(form_selector_pattern, auth_response.text)
|
||||
if not form_matches:
|
||||
raise Exception(f"Could not find login form using pattern: {form_selector_pattern}")
|
||||
|
||||
login_url = html.unescape(form_matches[0])
|
||||
|
||||
# Step 3: Build login data
|
||||
login_data = {}
|
||||
if additional_form_data:
|
||||
login_data.update(additional_form_data)
|
||||
|
||||
login_data[login_fields.get('username', 'username')] = username
|
||||
login_data[login_fields.get('password', 'password')] = password
|
||||
|
||||
# Step 4: Submit login credentials
|
||||
login_response = session.post(
|
||||
login_url,
|
||||
data=login_data,
|
||||
timeout=self.config.timeout,
|
||||
allow_redirects=False
|
||||
)
|
||||
|
||||
# Step 5: Handle redirect and extract authorization code
|
||||
if login_response.status_code in [302, 303]:
|
||||
redirect_url = login_response.headers.get('Location')
|
||||
if not redirect_url:
|
||||
raise Exception("No redirect URL found after login")
|
||||
else:
|
||||
redirect_response = session.get(login_response.url, timeout=self.config.timeout)
|
||||
redirect_url = redirect_response.url
|
||||
|
||||
# Step 6: Validate and extract authorization code
|
||||
is_valid, error_msg, authorization_code = self.validate_authentication_response(redirect_url, state)
|
||||
if not is_valid:
|
||||
raise Exception(f"Authentication response validation failed: {error_msg}")
|
||||
|
||||
# Step 7: Exchange code for token
|
||||
token_data = self._exchange_authorization_code_for_token(
|
||||
authorization_code=authorization_code,
|
||||
code_verifier=code_verifier,
|
||||
state=state
|
||||
)
|
||||
|
||||
return token_data
|
||||
|
||||
except Exception as e:
|
||||
raise Exception(f"OAuth2 form-based login failed: {e}")
|
||||
|
||||
# Token Refresh
|
||||
def _refresh_oauth_token(self) -> Optional[BaseAuthToken]:
|
||||
"""Complete manual token refresh implementation"""
|
||||
if not self._current_token or not self._current_token.refresh_token:
|
||||
logger.debug(f"No refresh token available for {self.provider_name}")
|
||||
return None
|
||||
|
||||
try:
|
||||
logger.debug(f"Refreshing OAuth2 token for {self.provider_name}")
|
||||
|
||||
data = {
|
||||
'grant_type': 'refresh_token',
|
||||
'refresh_token': self._current_token.refresh_token,
|
||||
'client_id': self.oauth_client_id,
|
||||
}
|
||||
|
||||
client_secret = getattr(self.credentials, 'client_secret', None)
|
||||
if client_secret:
|
||||
data['client_secret'] = client_secret
|
||||
|
||||
headers = self._get_auth_headers()
|
||||
encoded_data = urlencode(data).encode()
|
||||
|
||||
response = self.http_manager.post(
|
||||
self.auth_endpoint,
|
||||
operation='auth',
|
||||
headers=headers,
|
||||
data=encoded_data
|
||||
)
|
||||
|
||||
self._check_oauth_error_response(response)
|
||||
response.raise_for_status()
|
||||
|
||||
new_token_data = response.json()
|
||||
refreshed_token = self._create_token_from_response(new_token_data)
|
||||
logger.info(f"OAuth2 token refresh successful for {self.provider_name}")
|
||||
return refreshed_token
|
||||
|
||||
except OAuth2Error as e:
|
||||
logger.warning(f"OAuth2 token refresh failed for {self.provider_name}: {e}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.warning(f"OAuth2 token refresh failed for {self.provider_name}: {e}")
|
||||
return None
|
||||
|
||||
# Error Response Handling
|
||||
@staticmethod
|
||||
def _check_oauth_error_response(response):
|
||||
"""Check response for OAuth2 error and raise OAuth2Error if found"""
|
||||
try:
|
||||
if response.status_code >= 400:
|
||||
try:
|
||||
error_data = response.json()
|
||||
if 'error' in error_data:
|
||||
raise OAuth2Error(
|
||||
error=error_data.get('error'),
|
||||
error_description=error_data.get('error_description'),
|
||||
error_uri=error_data.get('error_uri')
|
||||
)
|
||||
except (ValueError, KeyError):
|
||||
pass
|
||||
except OAuth2Error:
|
||||
raise
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Dynamic Client ID Extraction
|
||||
def _extract_client_id_from_js(
|
||||
self,
|
||||
main_page_url: str,
|
||||
js_file_pattern: str,
|
||||
client_id_pattern: str
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Extract client ID from provider's JavaScript
|
||||
|
||||
Args:
|
||||
main_page_url: URL of the main page containing script references
|
||||
js_file_pattern: Regex pattern to find the JS file URL
|
||||
client_id_pattern: Regex pattern to extract client ID from JS content
|
||||
|
||||
Returns:
|
||||
Extracted client ID or None
|
||||
"""
|
||||
try:
|
||||
headers = self.config.get_base_headers()
|
||||
|
||||
response = self.http_manager.get(
|
||||
main_page_url,
|
||||
operation='api',
|
||||
headers=headers
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
js_matches = re.findall(js_file_pattern, response.text)
|
||||
if not js_matches:
|
||||
logger.warning(f"Could not find JS file using pattern: {js_file_pattern}")
|
||||
return None
|
||||
|
||||
js_url = main_page_url.rstrip('/') + '/' + js_matches[-1].lstrip('/')
|
||||
|
||||
js_response = self.http_manager.get(js_url, operation='api', headers=headers)
|
||||
js_response.raise_for_status()
|
||||
|
||||
client_id_match = re.search(client_id_pattern, js_response.text)
|
||||
if not client_id_match:
|
||||
logger.warning(f"Could not find client ID using pattern: {client_id_pattern}")
|
||||
return None
|
||||
|
||||
return client_id_match.group(1)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error extracting client ID from JS: {e}")
|
||||
return None
|
||||
|
||||
# Generic Config Extraction from JS
|
||||
def _extract_config_from_js(
|
||||
self,
|
||||
main_page_url: str,
|
||||
js_file_pattern: str,
|
||||
config_pattern: str,
|
||||
parse_function: Callable[[str], Dict[str, Any]]
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Generic JS config extraction
|
||||
|
||||
Args:
|
||||
main_page_url: URL of the main page
|
||||
js_file_pattern: Regex to find JS file
|
||||
config_pattern: Regex to extract config section
|
||||
parse_function: Function to parse the config string into a dict
|
||||
|
||||
Returns:
|
||||
Parsed configuration dictionary or None
|
||||
"""
|
||||
try:
|
||||
headers = self.config.get_base_headers()
|
||||
|
||||
response = self.http_manager.get(
|
||||
main_page_url,
|
||||
operation='api',
|
||||
headers=headers
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
js_matches = re.findall(js_file_pattern, response.text)
|
||||
if not js_matches:
|
||||
return None
|
||||
|
||||
js_url = main_page_url.rstrip('/') + '/' + js_matches[-1].lstrip('/')
|
||||
|
||||
js_response = self.http_manager.get(js_url, operation='api', headers=headers)
|
||||
js_response.raise_for_status()
|
||||
|
||||
config_match = re.search(config_pattern, js_response.text)
|
||||
if not config_match:
|
||||
return None
|
||||
|
||||
config_str = config_match.group(1)
|
||||
return parse_function(config_str)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error extracting config from JS: {e}")
|
||||
return None
|
||||
|
||||
# Token Upgrade Support
|
||||
def _should_upgrade_to_user_token(self, token: BaseAuthToken) -> bool:
|
||||
"""
|
||||
Check if token should be upgraded - now uses the base class logic
|
||||
|
||||
Override in subclass only if provider has specific upgrade rules
|
||||
"""
|
||||
return self.should_upgrade_token(token)
|
||||
|
||||
def _get_effective_credentials(self):
|
||||
"""
|
||||
Get effective credentials with priority:
|
||||
1. Stored user credentials (if available)
|
||||
2. Current credentials if valid
|
||||
3. Fallback credentials
|
||||
"""
|
||||
from ...base.auth.credentials import UserPasswordCredentials
|
||||
|
||||
# ALWAYS check stored credentials first for user credentials
|
||||
stored_creds = self.settings_manager.get_provider_credentials(self.provider_name)
|
||||
if stored_creds and isinstance(stored_creds, UserPasswordCredentials):
|
||||
if stored_creds.validate():
|
||||
return stored_creds
|
||||
|
||||
# Then use current credentials
|
||||
if self.credentials and self.credentials.validate():
|
||||
return self.credentials
|
||||
|
||||
# Finally fallback
|
||||
fallback = self.get_fallback_credentials()
|
||||
self.credentials = fallback
|
||||
return fallback
|
||||
|
||||
def get_bearer_token(self, force_refresh: bool = False, force_upgrade: bool = False) -> str:
|
||||
"""
|
||||
Get bearer token with automatic upgrade support
|
||||
|
||||
Args:
|
||||
force_refresh: Force token refresh even if not expired
|
||||
force_upgrade: Force token upgrade attempt regardless of current level
|
||||
|
||||
Returns:
|
||||
Bearer token string
|
||||
"""
|
||||
logger.debug(f"get_bearer_token called: force_refresh={force_refresh}, force_upgrade={force_upgrade}")
|
||||
|
||||
# Get current token (authenticate if needed)
|
||||
current_token = self.authenticate(force_refresh=force_refresh)
|
||||
|
||||
# Classify token if needed
|
||||
if current_token.auth_level == TokenAuthLevel.UNKNOWN:
|
||||
current_token.auth_level = self._classify_token(current_token)
|
||||
logger.debug(f"Token classified as: {current_token.auth_level.value}")
|
||||
|
||||
# Check if upgrade is needed/requested
|
||||
should_upgrade = force_upgrade or self._should_upgrade_to_user_token(current_token)
|
||||
|
||||
if should_upgrade and not force_refresh:
|
||||
logger.info(
|
||||
f"Token upgrade triggered (force={force_upgrade}, auto={self._should_upgrade_to_user_token(current_token)})")
|
||||
|
||||
original_credentials = self.credentials
|
||||
|
||||
try:
|
||||
# Get effective credentials (prioritizes stored user credentials)
|
||||
self.credentials = self._get_effective_credentials()
|
||||
|
||||
if not self.credentials or not self.credentials.validate():
|
||||
logger.debug("No valid credentials for upgrade")
|
||||
return current_token.bearer_token
|
||||
|
||||
# Perform authentication with new credentials
|
||||
user_token = self._perform_authentication()
|
||||
|
||||
if user_token and not user_token.is_expired:
|
||||
# Classify the new token
|
||||
user_token.auth_level = self._classify_token(user_token)
|
||||
|
||||
# Verify it's actually an upgrade
|
||||
if user_token.is_user_authenticated():
|
||||
logger.info("Successfully upgraded to user token")
|
||||
self._current_token = user_token
|
||||
self._save_session()
|
||||
return user_token.bearer_token
|
||||
else:
|
||||
logger.warning(
|
||||
f"Authentication succeeded but token is not user level: {user_token.auth_level.value}")
|
||||
self.credentials = original_credentials
|
||||
return current_token.bearer_token
|
||||
else:
|
||||
logger.warning("User authentication failed, keeping current token")
|
||||
self.credentials = original_credentials
|
||||
return current_token.bearer_token
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Token upgrade failed: {e}")
|
||||
self.credentials = original_credentials
|
||||
return current_token.bearer_token
|
||||
|
||||
return current_token.bearer_token if current_token else ""
|
||||
|
||||
# Main Authentication Flow
|
||||
def _perform_authentication(self) -> BaseAuthToken:
|
||||
"""Complete OAuth2 authentication based on credential type"""
|
||||
from ...base.auth.credentials import UserPasswordCredentials, ClientCredentials
|
||||
|
||||
logger.debug(
|
||||
f"Starting OAuth2 authentication for {self.provider_name} with credential type: {type(self.credentials)}")
|
||||
|
||||
original_credentials = self.credentials
|
||||
|
||||
try:
|
||||
if isinstance(self.credentials, UserPasswordCredentials):
|
||||
logger.info(f"Attempting OAuth2 user authentication for {self.provider_name}")
|
||||
token_data = self._perform_oauth_authorization_code_flow(
|
||||
self.credentials.username,
|
||||
self.credentials.password
|
||||
)
|
||||
elif isinstance(self.credentials, ClientCredentials):
|
||||
logger.info(f"Attempting OAuth2 client credentials authentication for {self.provider_name}")
|
||||
token_data = self._perform_oauth_client_credentials_flow()
|
||||
else:
|
||||
raise Exception(f"Unsupported credential type for OAuth2: {type(self.credentials)}")
|
||||
|
||||
token = self._create_token_from_response(token_data)
|
||||
logger.info(f"OAuth2 authentication successful for {self.provider_name}")
|
||||
return token
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Primary OAuth2 authentication failed for {self.provider_name}: {e}")
|
||||
|
||||
if isinstance(original_credentials, UserPasswordCredentials):
|
||||
logger.info(f"User authentication failed, falling back to client credentials for {self.provider_name}")
|
||||
try:
|
||||
self.credentials = self.get_fallback_credentials()
|
||||
token_data = self._perform_oauth_client_credentials_flow()
|
||||
result = self._create_token_from_response(token_data)
|
||||
logger.info(f"Successfully fell back to client credentials for {self.provider_name}")
|
||||
return result
|
||||
except Exception as fallback_error:
|
||||
self.credentials = original_credentials
|
||||
logger.error(
|
||||
f"Fallback to client credentials also failed for {self.provider_name}: {fallback_error}")
|
||||
raise e
|
||||
else:
|
||||
raise e
|
||||
|
||||
# Token Management
|
||||
@abstractmethod
|
||||
def _create_token_from_response(self, response_data: Dict[str, Any]) -> BaseAuthToken:
|
||||
"""Create provider-specific token from OAuth2 response"""
|
||||
pass
|
||||
|
||||
def _refresh_token(self) -> Optional[BaseAuthToken]:
|
||||
"""Override base refresh to use manual OAuth2 refresh flow"""
|
||||
return self._refresh_oauth_token()
|
||||
|
||||
# Status and Diagnostics
|
||||
def get_authentication_status(self) -> Dict[str, Any]:
|
||||
"""Get comprehensive OAuth2 authentication status information"""
|
||||
status = super().get_authentication_status()
|
||||
|
||||
oauth_status = {
|
||||
'oauth_client_id': self.oauth_client_id,
|
||||
'oauth_scope': self.oauth_scope,
|
||||
'oauth_redirect_uri': self.oauth_redirect_uri,
|
||||
'oauth_authorize_endpoint': self.oauth_authorize_endpoint,
|
||||
'authentication_flow': 'oauth2',
|
||||
'pkce_support': True,
|
||||
'proxy_support': hasattr(self, 'http_manager'),
|
||||
'credential_type': type(self.credentials).__name__,
|
||||
'has_refresh_token': bool(self._current_token and self._current_token.refresh_token),
|
||||
}
|
||||
|
||||
if self._current_token:
|
||||
oauth_status.update({
|
||||
'token_expires_in': self._current_token.expires_in,
|
||||
'token_issued_at': self._current_token.issued_at,
|
||||
'token_is_expired': self._current_token.is_expired,
|
||||
'token_needs_refresh': self._current_token.needs_refresh(),
|
||||
})
|
||||
|
||||
status.update(oauth_status)
|
||||
return status
|
||||
|
||||
# Utility Methods
|
||||
@staticmethod
|
||||
def extract_authorization_code_from_url(url: str) -> Optional[str]:
|
||||
"""Extract authorization code from callback URL"""
|
||||
try:
|
||||
parsed = urlparse(url)
|
||||
query_params = parse_qs(parsed.query)
|
||||
return query_params.get('code', [None])[0]
|
||||
except Exception as e:
|
||||
logger.error(f"Error extracting authorization code from URL: {e}")
|
||||
return None
|
||||
|
||||
def validate_authentication_response(self, url: str, original_state: str) -> tuple[
|
||||
bool, Optional[str], Optional[str]]:
|
||||
"""
|
||||
Validate OAuth2 authentication response
|
||||
Returns: (is_valid, error_message, authorization_code)
|
||||
"""
|
||||
try:
|
||||
parsed = urlparse(url)
|
||||
query_params = parse_qs(parsed.query)
|
||||
|
||||
if 'error' in query_params:
|
||||
error = query_params['error'][0]
|
||||
error_description = query_params.get('error_description', [''])[0]
|
||||
return False, f"{error}: {error_description}", None
|
||||
|
||||
received_state = query_params.get('state', [None])[0]
|
||||
if not self.validate_oauth_state(received_state, original_state):
|
||||
return False, "State validation failed", None
|
||||
|
||||
authorization_code = query_params.get('code', [None])[0]
|
||||
if not authorization_code:
|
||||
return False, "No authorization code in response", None
|
||||
|
||||
return True, None, authorization_code
|
||||
|
||||
except Exception as e:
|
||||
return False, f"Error processing authentication response: {e}", None
|
||||
|
||||
# Abstract method for provider-specific authorization code flow
|
||||
@abstractmethod
|
||||
def _perform_oauth_authorization_code_flow(self, username: str, password: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Perform OAuth2 authorization code flow with PKCE for user login
|
||||
Must be implemented by subclasses for provider-specific login forms
|
||||
"""
|
||||
pass
|
||||
@@ -0,0 +1,583 @@
|
||||
# streaming_providers/base/auth/credential_manager.py
|
||||
from typing import Optional
|
||||
from .credentials import BaseCredentials, UserPasswordCredentials, ClientCredentials
|
||||
|
||||
import time
|
||||
from typing import Dict, Any, List
|
||||
|
||||
# Import centralized logger and VFS
|
||||
from ..utils.logger import logger
|
||||
|
||||
|
||||
class CredentialManager:
|
||||
"""
|
||||
Manages loading and saving credentials from/to persistent storage
|
||||
Now supports country-specific credentials
|
||||
"""
|
||||
|
||||
def __init__(self, config_dir: Optional[str] = None):
|
||||
# Initialize VFS with config directory support
|
||||
from ..utils.vfs import VFS
|
||||
self.vfs = VFS(config_dir=config_dir)
|
||||
|
||||
# Credentials file is always in the root of the VFS base path
|
||||
self.credentials_file = 'credentials.json'
|
||||
|
||||
# Ensure base directory exists
|
||||
self.vfs.mkdirs('')
|
||||
logger.debug(f"CredentialManager initialized with VFS base: {self.vfs.base_path}")
|
||||
|
||||
@staticmethod
|
||||
def _get_credential_path(provider: str, country: Optional[str] = None) -> tuple:
|
||||
"""
|
||||
Determine the path to credential data based on country
|
||||
|
||||
Args:
|
||||
provider: Provider name
|
||||
country: Optional country code (e.g., 'de', 'at', 'ch')
|
||||
|
||||
Returns:
|
||||
Tuple of (keys_path, is_nested) where keys_path is list of keys to navigate
|
||||
"""
|
||||
if country:
|
||||
return [provider, country], True
|
||||
else:
|
||||
return [provider], False
|
||||
|
||||
def load_credentials(self, provider: str, country: Optional[str] = None) -> Optional[BaseCredentials]:
|
||||
"""
|
||||
Load credentials for a specific provider and optional country
|
||||
|
||||
Args:
|
||||
provider: Provider name (e.g., 'rtlplus', 'joyn')
|
||||
country: Optional country code (e.g., 'de', 'at', 'ch')
|
||||
|
||||
Returns:
|
||||
BaseCredentials instance or None if not found
|
||||
"""
|
||||
country_str = f" (country: {country})" if country else ""
|
||||
try:
|
||||
logger.debug(f"CredentialManager: Loading credentials for '{provider}{country_str}'")
|
||||
|
||||
# Check if credentials file exists
|
||||
if not self.vfs.exists(self.credentials_file):
|
||||
logger.debug(f"CredentialManager: Credentials file does not exist: {self.credentials_file}")
|
||||
return None
|
||||
|
||||
logger.debug(f"CredentialManager: Reading credentials file: {self.credentials_file}")
|
||||
data = self.vfs.read_json(self.credentials_file)
|
||||
|
||||
if not data:
|
||||
logger.debug(f"CredentialManager: Credentials file is empty or invalid JSON")
|
||||
return None
|
||||
|
||||
logger.debug(f"CredentialManager: Available providers in file: {list(data.keys())}")
|
||||
|
||||
# Navigate to the correct credential data
|
||||
keys_path, is_nested = self._get_credential_path(provider, country)
|
||||
provider_data = data
|
||||
|
||||
for key in keys_path:
|
||||
if not isinstance(provider_data, dict) or key not in provider_data:
|
||||
logger.debug(f"CredentialManager: No data found at path: {' -> '.join(keys_path)}")
|
||||
return None
|
||||
provider_data = provider_data[key]
|
||||
|
||||
if not provider_data:
|
||||
logger.debug(f"CredentialManager: No data found for provider '{provider}{country_str}'")
|
||||
return None
|
||||
|
||||
logger.debug(f"CredentialManager: Found data for '{provider}{country_str}': {list(provider_data.keys())}")
|
||||
|
||||
credential_type = provider_data.get('type')
|
||||
logger.debug(f"CredentialManager: Credential type for '{provider}{country_str}': {credential_type}")
|
||||
|
||||
if credential_type == 'user_password':
|
||||
username = provider_data.get('username', '')
|
||||
password_present = 'password' in provider_data
|
||||
client_id = provider_data.get('client_id')
|
||||
|
||||
logger.debug(f"CredentialManager: Creating UserPasswordCredentials for '{provider}{country_str}'")
|
||||
logger.debug(
|
||||
f"CredentialManager: Username: '{username}', Password present: {password_present}, Client ID: {client_id}")
|
||||
|
||||
creds = UserPasswordCredentials(
|
||||
username=username,
|
||||
password=self._decode_password(provider_data.get('password', '')),
|
||||
client_id=client_id,
|
||||
grant_type=provider_data.get('grant_type', 'password')
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
f"CredentialManager: Successfully created UserPasswordCredentials for '{provider}{country_str}'")
|
||||
return creds
|
||||
|
||||
elif credential_type == 'client_credentials':
|
||||
client_id = provider_data.get('client_id', '')
|
||||
client_secret_present = 'client_secret' in provider_data
|
||||
|
||||
logger.debug(f"CredentialManager: Creating ClientCredentials for '{provider}{country_str}'")
|
||||
logger.debug(
|
||||
f"CredentialManager: Client ID: '{client_id}', Client secret present: {client_secret_present}")
|
||||
|
||||
creds = ClientCredentials(
|
||||
client_id=client_id,
|
||||
client_secret=self._decode_password(provider_data.get('client_secret', '')),
|
||||
grant_type=provider_data.get('grant_type', 'client_credentials')
|
||||
)
|
||||
|
||||
logger.debug(f"CredentialManager: Successfully created ClientCredentials for '{provider}{country_str}'")
|
||||
return creds
|
||||
|
||||
else:
|
||||
logger.error(
|
||||
f"CredentialManager: Unknown credential type for '{provider}{country_str}': {credential_type}")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
country_str = f" (country: {country})" if country else ""
|
||||
logger.error(f"CredentialManager: Error loading credentials for '{provider}{country_str}': {e}")
|
||||
return None
|
||||
|
||||
def save_credentials(self, provider: str, credentials: BaseCredentials,
|
||||
country: Optional[str] = None) -> bool:
|
||||
"""
|
||||
Save credentials for a specific provider and optional country
|
||||
|
||||
Args:
|
||||
provider: Provider name
|
||||
credentials: Credentials to save
|
||||
country: Optional country code
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
country_str = f" (country: {country})" if country else ""
|
||||
try:
|
||||
logger.debug(f"Saving {credentials.credential_type} credentials for {provider}{country_str}")
|
||||
|
||||
# Load existing data
|
||||
data = {}
|
||||
if self.vfs.exists(self.credentials_file):
|
||||
data = self.vfs.read_json(self.credentials_file) or {}
|
||||
|
||||
# Prepare provider data based on credential type
|
||||
if isinstance(credentials, UserPasswordCredentials):
|
||||
provider_data = {
|
||||
'type': 'user_password',
|
||||
'username': credentials.username,
|
||||
'password': self._encode_password(credentials.password),
|
||||
'grant_type': credentials.grant_type
|
||||
}
|
||||
if credentials.client_id:
|
||||
provider_data['client_id'] = credentials.client_id
|
||||
|
||||
elif isinstance(credentials, ClientCredentials):
|
||||
provider_data = {
|
||||
'type': 'client_credentials',
|
||||
'client_id': credentials.client_id,
|
||||
'client_secret': self._encode_password(credentials.client_secret),
|
||||
'grant_type': credentials.grant_type
|
||||
}
|
||||
else:
|
||||
logger.error(f"Unsupported credential type: {type(credentials)}")
|
||||
return False
|
||||
|
||||
# Navigate and create nested structure if needed
|
||||
keys_path, is_nested = self._get_credential_path(provider, country)
|
||||
|
||||
# Build nested structure
|
||||
current = data
|
||||
for i, key in enumerate(keys_path[:-1]):
|
||||
if key not in current:
|
||||
current[key] = {}
|
||||
elif not isinstance(current[key], dict):
|
||||
logger.warning(f"Overwriting non-dict value at {key}")
|
||||
current[key] = {}
|
||||
current = current[key]
|
||||
|
||||
# Set the final value
|
||||
current[keys_path[-1]] = provider_data
|
||||
|
||||
# Save to file using VFS
|
||||
success = self.vfs.write_json(self.credentials_file, data)
|
||||
|
||||
if success:
|
||||
logger.info(f"Successfully saved credentials for {provider}{country_str}")
|
||||
|
||||
# Verify by reading back
|
||||
verify_data = self.vfs.read_json(self.credentials_file)
|
||||
if verify_data:
|
||||
verify_current = verify_data
|
||||
found = True
|
||||
for key in keys_path:
|
||||
if not isinstance(verify_current, dict) or key not in verify_current:
|
||||
found = False
|
||||
break
|
||||
verify_current = verify_current[key]
|
||||
|
||||
if found:
|
||||
logger.debug(f"Verification successful: {provider}{country_str} data found in saved file")
|
||||
else:
|
||||
logger.error(f"Verification failed: {provider}{country_str} data NOT found in saved file")
|
||||
return False
|
||||
|
||||
return True
|
||||
else:
|
||||
logger.error(f"Failed to save credentials for {provider}{country_str}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
country_str = f" (country: {country})" if country else ""
|
||||
logger.error(f"Error saving credentials for {provider}{country_str}: {e}")
|
||||
return False
|
||||
|
||||
def delete_credentials(self, provider: str, country: Optional[str] = None) -> bool:
|
||||
"""
|
||||
Delete credentials for a specific provider and optional country
|
||||
|
||||
Args:
|
||||
provider: Provider name
|
||||
country: Optional country code (if None, deletes entire provider or all countries)
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
country_str = f" (country: {country})" if country else ""
|
||||
try:
|
||||
|
||||
if not self.vfs.exists(self.credentials_file):
|
||||
logger.debug(f"No credentials file exists, nothing to delete for {provider}{country_str}")
|
||||
return True
|
||||
|
||||
data = self.vfs.read_json(self.credentials_file)
|
||||
if not data:
|
||||
logger.debug(f"Credentials file is empty, nothing to delete for {provider}{country_str}")
|
||||
return True
|
||||
|
||||
if country:
|
||||
# Delete specific country data
|
||||
if provider in data and isinstance(data[provider], dict) and country in data[provider]:
|
||||
del data[provider][country]
|
||||
logger.info(f"Deleted stored credentials for {provider}{country_str}")
|
||||
|
||||
# If provider dict is now empty, remove it entirely
|
||||
if not data[provider]:
|
||||
del data[provider]
|
||||
logger.debug(f"Provider {provider} had no more countries, removed entirely")
|
||||
|
||||
return self.vfs.write_json(self.credentials_file, data)
|
||||
else:
|
||||
logger.debug(f"No stored credentials found to delete for {provider}{country_str}")
|
||||
else:
|
||||
# Delete entire provider data (all countries)
|
||||
if provider in data:
|
||||
del data[provider]
|
||||
logger.info(f"Deleted all stored credentials for {provider}")
|
||||
return self.vfs.write_json(self.credentials_file, data)
|
||||
else:
|
||||
logger.debug(f"No stored credentials found to delete for {provider}")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
country_str = f" (country: {country})" if country else ""
|
||||
logger.error(f"Error deleting credentials for {provider}{country_str}: {e}")
|
||||
return False
|
||||
|
||||
def list_providers(self) -> List[str]:
|
||||
"""
|
||||
List all providers with stored credentials
|
||||
|
||||
Returns:
|
||||
List of provider names
|
||||
"""
|
||||
try:
|
||||
if not self.vfs.exists(self.credentials_file):
|
||||
logger.debug("No credentials file exists, returning empty provider list")
|
||||
return []
|
||||
|
||||
data = self.vfs.read_json(self.credentials_file)
|
||||
if not data:
|
||||
logger.debug("Credentials file is empty, returning empty provider list")
|
||||
return []
|
||||
|
||||
providers = list(data.keys())
|
||||
logger.debug(f"Found stored credentials for providers: {providers}")
|
||||
return providers
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error listing providers: {e}")
|
||||
return []
|
||||
|
||||
def get_all_countries(self, provider: str) -> List[str]:
|
||||
"""
|
||||
Get all countries that have credentials for a provider
|
||||
|
||||
Args:
|
||||
provider: Provider name
|
||||
|
||||
Returns:
|
||||
List of country codes
|
||||
"""
|
||||
try:
|
||||
if not self.vfs.exists(self.credentials_file):
|
||||
return []
|
||||
|
||||
data = self.vfs.read_json(self.credentials_file)
|
||||
if not data or provider not in data:
|
||||
return []
|
||||
|
||||
provider_data = data[provider]
|
||||
|
||||
# Check if this is a nested (country-aware) structure
|
||||
if isinstance(provider_data, dict):
|
||||
# Check if any keys look like country codes (2-3 char strings)
|
||||
# and their values are dicts (credential data)
|
||||
countries = []
|
||||
for key, value in provider_data.items():
|
||||
if isinstance(value, dict) and len(key) <= 3 and 'type' in value:
|
||||
countries.append(key)
|
||||
return countries
|
||||
|
||||
return []
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting countries for {provider}: {e}")
|
||||
return []
|
||||
|
||||
def has_credentials(self, provider: str, country: Optional[str] = None) -> bool:
|
||||
"""
|
||||
Check if credentials exist for a provider and optional country
|
||||
|
||||
Args:
|
||||
provider: Provider name
|
||||
country: Optional country code
|
||||
|
||||
Returns:
|
||||
True if credentials exist, False otherwise
|
||||
"""
|
||||
credentials = self.load_credentials(provider, country)
|
||||
return credentials is not None and credentials.validate()
|
||||
|
||||
@staticmethod
|
||||
def _encode_password(password: str) -> str:
|
||||
"""No encoding - store as plaintext (TEMPORARY INSECURE SOLUTION)."""
|
||||
logger.warning("Storing password in plaintext - this is insecure and should be replaced with proper encryption")
|
||||
return password
|
||||
|
||||
@staticmethod
|
||||
def _decode_password(encoded_password: str) -> str:
|
||||
"""No decoding needed - passwords are stored in plaintext."""
|
||||
return encoded_password
|
||||
|
||||
def export_config(self, provider: Optional[str] = None,
|
||||
country: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""
|
||||
Export credentials to a portable format
|
||||
|
||||
Args:
|
||||
provider: Optional provider name to export (if None, exports all)
|
||||
country: Optional country code (only used if provider is specified)
|
||||
|
||||
Returns:
|
||||
Dictionary with credentials data
|
||||
"""
|
||||
export_data = {
|
||||
'version': '1.1', # Bumped for country support
|
||||
'exported_at': time.time(),
|
||||
'credentials': {}
|
||||
}
|
||||
|
||||
if provider:
|
||||
# Export specific provider
|
||||
if country:
|
||||
# Export specific country
|
||||
credentials = self.load_credentials(provider, country)
|
||||
if credentials:
|
||||
export_data['credentials'][provider] = {
|
||||
country: self._credential_to_dict(credentials)
|
||||
}
|
||||
else:
|
||||
# Export all countries for provider or non-country data
|
||||
countries = self.get_all_countries(provider)
|
||||
if countries:
|
||||
# Provider has country-specific credentials
|
||||
provider_data = {}
|
||||
for ctry in countries:
|
||||
credentials = self.load_credentials(provider, ctry)
|
||||
if credentials:
|
||||
provider_data[ctry] = self._credential_to_dict(credentials)
|
||||
if provider_data:
|
||||
export_data['credentials'][provider] = provider_data
|
||||
else:
|
||||
# Non-country provider
|
||||
credentials = self.load_credentials(provider)
|
||||
if credentials:
|
||||
export_data['credentials'][provider] = self._credential_to_dict(credentials)
|
||||
else:
|
||||
# Export all providers
|
||||
for prov in self.list_providers():
|
||||
countries = self.get_all_countries(prov)
|
||||
if countries:
|
||||
# Provider has country-specific credentials
|
||||
provider_data = {}
|
||||
for ctry in countries:
|
||||
credentials = self.load_credentials(prov, ctry)
|
||||
if credentials:
|
||||
provider_data[ctry] = self._credential_to_dict(credentials)
|
||||
if provider_data:
|
||||
export_data['credentials'][prov] = provider_data
|
||||
else:
|
||||
# Non-country provider
|
||||
credentials = self.load_credentials(prov)
|
||||
if credentials:
|
||||
export_data['credentials'][prov] = self._credential_to_dict(credentials)
|
||||
|
||||
return export_data
|
||||
|
||||
@staticmethod
|
||||
def _credential_to_dict(credentials: BaseCredentials) -> Dict[str, Any]:
|
||||
"""Convert credentials object to dictionary for export"""
|
||||
if isinstance(credentials, UserPasswordCredentials):
|
||||
return {
|
||||
'type': 'user_password',
|
||||
'username': credentials.username,
|
||||
'password': credentials.password,
|
||||
'client_id': credentials.client_id
|
||||
}
|
||||
elif isinstance(credentials, ClientCredentials):
|
||||
return {
|
||||
'type': 'client_credentials',
|
||||
'client_id': credentials.client_id,
|
||||
'client_secret': credentials.client_secret
|
||||
}
|
||||
else:
|
||||
return {}
|
||||
|
||||
def import_config(self, config_data: Dict[str, Any]) -> Dict[str, bool]:
|
||||
"""
|
||||
Import credentials from exported configuration
|
||||
|
||||
Args:
|
||||
config_data: Configuration data from export_config()
|
||||
|
||||
Returns:
|
||||
Dictionary mapping provider names (or provider_country) to import success status
|
||||
"""
|
||||
results = {}
|
||||
|
||||
# Validate config data
|
||||
if not isinstance(config_data, dict) or 'credentials' not in config_data:
|
||||
logger.error("Invalid credential config data format")
|
||||
return results
|
||||
|
||||
credentials_data = config_data.get('credentials', {})
|
||||
|
||||
for provider, provider_data in credentials_data.items():
|
||||
try:
|
||||
# Check if this is country-aware structure
|
||||
if isinstance(provider_data, dict) and not provider_data.get('type'):
|
||||
# Nested structure with countries
|
||||
for country, cred_data in provider_data.items():
|
||||
if not isinstance(cred_data, dict):
|
||||
continue
|
||||
|
||||
credentials = self._dict_to_credential(cred_data)
|
||||
if credentials:
|
||||
success = self.save_credentials(provider, credentials, country)
|
||||
results[f"{provider}_{country}"] = success
|
||||
|
||||
if success:
|
||||
logger.info(f"Successfully imported credentials for {provider} ({country})")
|
||||
else:
|
||||
logger.error(f"Failed to import credentials for {provider} ({country})")
|
||||
else:
|
||||
# Flat structure (no country)
|
||||
credentials = self._dict_to_credential(provider_data)
|
||||
if credentials:
|
||||
success = self.save_credentials(provider, credentials)
|
||||
results[provider] = success
|
||||
|
||||
if success:
|
||||
logger.info(f"Successfully imported credentials for {provider}")
|
||||
else:
|
||||
logger.error(f"Failed to import credentials for {provider}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error importing credentials for {provider}: {e}")
|
||||
results[provider] = False
|
||||
|
||||
return results
|
||||
|
||||
@staticmethod
|
||||
def _dict_to_credential(cred_data: Dict[str, Any]) -> Optional[BaseCredentials]:
|
||||
"""Convert dictionary to credentials object"""
|
||||
try:
|
||||
if cred_data.get('type') == 'user_password':
|
||||
return UserPasswordCredentials(
|
||||
username=cred_data.get('username', ''),
|
||||
password=cred_data.get('password', ''),
|
||||
client_id=cred_data.get('client_id')
|
||||
)
|
||||
elif cred_data.get('type') == 'client_credentials':
|
||||
return ClientCredentials(
|
||||
client_id=cred_data.get('client_id', ''),
|
||||
client_secret=cred_data.get('client_secret', '')
|
||||
)
|
||||
else:
|
||||
logger.error(f"Unknown credential type: {cred_data.get('type')}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating credential from dict: {e}")
|
||||
return None
|
||||
|
||||
def debug_credentials_file(self) -> None:
|
||||
"""Debug method to log the current state of the credentials file"""
|
||||
try:
|
||||
logger.info(f"=== CREDENTIALS FILE DEBUG INFO ===")
|
||||
|
||||
credentials_file_path = self.vfs.join_path(self.credentials_file)
|
||||
logger.info(f"Credentials file path: {credentials_file_path}")
|
||||
logger.info(f"Credentials file exists: {self.vfs.exists(self.credentials_file)}")
|
||||
|
||||
if self.vfs.exists(self.credentials_file):
|
||||
file_size = self.vfs.get_size(self.credentials_file)
|
||||
logger.info(f"Credentials file size: {file_size} bytes")
|
||||
|
||||
if file_size and file_size > 0:
|
||||
data = self.vfs.read_json(self.credentials_file)
|
||||
if data:
|
||||
logger.info(f"Credentials file contains {len(data)} providers: {list(data.keys())}")
|
||||
|
||||
for provider, provider_data in data.items():
|
||||
if isinstance(provider_data, dict):
|
||||
# Check if nested (has countries)
|
||||
has_countries = any(
|
||||
isinstance(v, dict) and len(k) <= 3 and 'type' in v
|
||||
for k, v in provider_data.items()
|
||||
)
|
||||
|
||||
if has_countries:
|
||||
logger.info(f" {provider} (country-aware):")
|
||||
for country, cred_data in provider_data.items():
|
||||
if isinstance(cred_data, dict) and 'type' in cred_data:
|
||||
cred_type = cred_data.get('type')
|
||||
username = cred_data.get('username', 'N/A')
|
||||
logger.info(f" {country}: type={cred_type}, username={username}")
|
||||
else:
|
||||
# Flat structure (no country)
|
||||
cred_type = provider_data.get('type', 'unknown')
|
||||
username = provider_data.get('username', 'N/A')
|
||||
logger.info(f" {provider} (no country): type={cred_type}, username={username}")
|
||||
else:
|
||||
logger.error("Credentials file contains invalid JSON or is empty")
|
||||
else:
|
||||
logger.info("Credentials file is empty")
|
||||
else:
|
||||
logger.info("Credentials file does not exist yet")
|
||||
|
||||
logger.info(f"=== END CREDENTIALS FILE DEBUG ===")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error during credentials file debug: {e}")
|
||||
@@ -0,0 +1,83 @@
|
||||
# streaming_providers/base/auth/credentials.py
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict, Any, Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class BaseCredentials(ABC):
|
||||
"""
|
||||
Base class for authentication credentials
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def validate(self) -> bool:
|
||||
"""Validate credentials"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def to_auth_payload(self) -> Dict[str, Any]:
|
||||
"""Convert credentials to authentication payload"""
|
||||
pass
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def credential_type(self) -> str:
|
||||
"""Return the type of credentials for storage identification"""
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class UserPasswordCredentials(BaseCredentials):
|
||||
"""
|
||||
Username/password based credentials
|
||||
"""
|
||||
username: str
|
||||
password: str
|
||||
client_id: Optional[str] = None
|
||||
grant_type: str = 'password'
|
||||
|
||||
def validate(self) -> bool:
|
||||
"""Validate username/password credentials"""
|
||||
return bool(self.username and self.password)
|
||||
|
||||
def to_auth_payload(self) -> Dict[str, Any]:
|
||||
"""Convert to authentication payload"""
|
||||
payload = {
|
||||
'grant_type': self.grant_type,
|
||||
'username': self.username,
|
||||
'password': self.password
|
||||
}
|
||||
if self.client_id:
|
||||
payload['client_id'] = self.client_id
|
||||
return payload
|
||||
|
||||
@property
|
||||
def credential_type(self) -> str:
|
||||
return "user_password"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ClientCredentials(BaseCredentials):
|
||||
"""
|
||||
Client credentials (client_id/client_secret) based authentication
|
||||
"""
|
||||
client_id: str
|
||||
client_secret: str
|
||||
grant_type: str = 'client_credentials'
|
||||
|
||||
def validate(self) -> bool:
|
||||
"""Validate client credentials"""
|
||||
return bool(self.client_id and self.client_secret)
|
||||
|
||||
def to_auth_payload(self) -> Dict[str, Any]:
|
||||
"""Convert to authentication payload"""
|
||||
return {
|
||||
'grant_type': self.grant_type,
|
||||
'client_id': self.client_id,
|
||||
'client_secret': self.client_secret
|
||||
}
|
||||
|
||||
@property
|
||||
def credential_type(self) -> str:
|
||||
return "client_credentials"
|
||||
@@ -0,0 +1,515 @@
|
||||
# streaming_providers/base/auth/session_manager.py
|
||||
import uuid
|
||||
import time
|
||||
from typing import Optional, Dict, Any
|
||||
from .base_auth import BaseAuthToken
|
||||
|
||||
# Import centralized logger and VFS
|
||||
from ..utils.logger import logger
|
||||
from ..utils.vfs import VFS
|
||||
|
||||
|
||||
class SessionManager:
|
||||
"""
|
||||
Manages persistent session data including tokens and device IDs
|
||||
Compatible with both Kodi and standalone environments via VFS abstraction
|
||||
Now supports country-specific sessions
|
||||
"""
|
||||
|
||||
def __init__(self, config_dir: Optional[str] = None):
|
||||
"""
|
||||
Initialize SessionManager
|
||||
|
||||
Args:
|
||||
config_dir: Optional config directory override (mainly for testing)
|
||||
"""
|
||||
# Initialize VFS with optional config directory override
|
||||
if config_dir:
|
||||
# For custom config directories, we'll use a VFS instance that treats
|
||||
# the config_dir as the base path directly
|
||||
self.vfs = VFS()
|
||||
self.vfs._base_path = config_dir
|
||||
else:
|
||||
# Use default VFS (handles Kodi vs standard filesystem automatically)
|
||||
self.vfs = VFS()
|
||||
|
||||
# Session file is always in the root of the VFS base path
|
||||
self.session_file = 'session.json'
|
||||
|
||||
# Ensure base directory exists
|
||||
self.vfs.mkdirs('')
|
||||
|
||||
logger.debug(f"SessionManager initialized with VFS base: {self.vfs.base_path}")
|
||||
logger.debug(f"Session file: {self.vfs.join_path(self.session_file)}")
|
||||
|
||||
@staticmethod
|
||||
def _get_session_path(provider: str, country: Optional[str] = None) -> tuple:
|
||||
"""
|
||||
Determine the path to session data based on country
|
||||
|
||||
Args:
|
||||
provider: Provider name
|
||||
country: Optional country code (e.g., 'de', 'at', 'ch')
|
||||
|
||||
Returns:
|
||||
Tuple of (keys_path, is_nested) where keys_path is list of keys to navigate
|
||||
"""
|
||||
if country:
|
||||
return [provider, country], True
|
||||
else:
|
||||
return [provider], False
|
||||
|
||||
def load_session(self, provider: str, country: Optional[str] = None) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Load session data for a specific provider and optional country
|
||||
|
||||
Args:
|
||||
provider: Provider name
|
||||
country: Optional country code (e.g., 'de', 'at', 'ch')
|
||||
|
||||
Returns:
|
||||
Session data dictionary or None
|
||||
"""
|
||||
country_str = f" (country: {country})" if country else ""
|
||||
|
||||
try:
|
||||
logger.debug(f"Attempting to load session data for {provider}{country_str}")
|
||||
|
||||
data = self.vfs.read_json(self.session_file)
|
||||
if not data:
|
||||
logger.info(f"Session file does not exist or is empty")
|
||||
return None
|
||||
|
||||
# Navigate to the correct session data
|
||||
keys_path, is_nested = self._get_session_path(provider, country)
|
||||
session_data = data
|
||||
|
||||
for key in keys_path:
|
||||
if not isinstance(session_data, dict) or key not in session_data:
|
||||
logger.info(f"No session data found at path: {' -> '.join(keys_path)}")
|
||||
return None
|
||||
session_data = session_data[key]
|
||||
|
||||
if session_data:
|
||||
# Log what we found (without sensitive data)
|
||||
safe_data = {}
|
||||
for key, value in session_data.items():
|
||||
if key in ['access_token', 'refresh_token', 'client_secret', 'password']:
|
||||
safe_data[key] = f"<{key}_present>" if value else f"<{key}_missing>"
|
||||
else:
|
||||
safe_data[key] = value
|
||||
logger.info(f"Loaded session data for {provider}{country_str}: {safe_data}")
|
||||
else:
|
||||
logger.info(f"No session data found for {provider}{country_str} in session file")
|
||||
|
||||
return session_data
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading session for {provider}{country_str}: {e}")
|
||||
return None
|
||||
|
||||
def save_session(self, provider: str, session_data: Dict[str, Any],
|
||||
country: Optional[str] = None) -> bool:
|
||||
"""
|
||||
Save session data for a specific provider and optional country
|
||||
|
||||
Args:
|
||||
provider: Provider name
|
||||
session_data: Session data to save
|
||||
country: Optional country code (e.g., 'de', 'at', 'ch')
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
country_str = f" (country: {country})" if country else ""
|
||||
|
||||
try:
|
||||
logger.debug(f"Attempting to save session data for {provider}{country_str}")
|
||||
|
||||
# Load existing data
|
||||
data = self.vfs.read_json(self.session_file) or {}
|
||||
logger.debug(f"Loaded existing data for providers: {list(data.keys())}")
|
||||
|
||||
# Extract and preserve token classification data if it's a token object
|
||||
if hasattr(session_data, 'auth_level'):
|
||||
# If session_data is a token-like object, convert to dict first
|
||||
if hasattr(session_data, 'to_dict'):
|
||||
session_data = session_data.to_dict()
|
||||
else:
|
||||
# Extract classification fields from object
|
||||
token_dict = {}
|
||||
for key in ['access_token', 'refresh_token', 'token_type', 'expires_in', 'issued_at',
|
||||
'refresh_expires_in']:
|
||||
if hasattr(session_data, key):
|
||||
token_dict[key] = getattr(session_data, key)
|
||||
session_data = token_dict
|
||||
|
||||
# Log what we're about to save (without sensitive data)
|
||||
safe_data = {}
|
||||
for key, value in session_data.items():
|
||||
if key in ['access_token', 'refresh_token', 'client_secret', 'password']:
|
||||
safe_data[key] = f"<{key}_present>" if value else f"<{key}_missing>"
|
||||
else:
|
||||
safe_data[key] = value
|
||||
logger.info(f"Saving session data for {provider}{country_str}: {safe_data}")
|
||||
|
||||
# Navigate and create nested structure if needed
|
||||
keys_path, is_nested = self._get_session_path(provider, country)
|
||||
|
||||
# Build nested structure
|
||||
current = data
|
||||
for i, key in enumerate(keys_path[:-1]):
|
||||
if key not in current:
|
||||
current[key] = {}
|
||||
elif not isinstance(current[key], dict):
|
||||
logger.warning(f"Overwriting non-dict value at {key}")
|
||||
current[key] = {}
|
||||
current = current[key]
|
||||
|
||||
# Set the final value
|
||||
current[keys_path[-1]] = session_data
|
||||
|
||||
# Save to file using VFS
|
||||
success = self.vfs.write_json(self.session_file, data)
|
||||
|
||||
if success:
|
||||
logger.info(f"Successfully saved session data for {provider}{country_str}")
|
||||
|
||||
# Verify by reading it back
|
||||
verify_data = self.vfs.read_json(self.session_file)
|
||||
if verify_data:
|
||||
# Navigate to verify
|
||||
verify_current = verify_data
|
||||
found = True
|
||||
for key in keys_path:
|
||||
if not isinstance(verify_current, dict) or key not in verify_current:
|
||||
found = False
|
||||
break
|
||||
verify_current = verify_current[key]
|
||||
|
||||
if found:
|
||||
logger.debug(f"Verification successful: {provider}{country_str} data found in saved file")
|
||||
else:
|
||||
logger.error(f"Verification failed: {provider}{country_str} data NOT found in saved file")
|
||||
return False
|
||||
else:
|
||||
logger.error(f"Verification failed: Could not read back session file")
|
||||
return False
|
||||
else:
|
||||
logger.error(f"Failed to write session file")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
logger.error(f"Error saving session for {provider}{country_str}: {e}")
|
||||
logger.error(f"Full traceback: {traceback.format_exc()}")
|
||||
return False
|
||||
|
||||
def save_token(self, provider: str, token: BaseAuthToken,
|
||||
country: Optional[str] = None) -> bool:
|
||||
"""
|
||||
Save authentication token for a provider
|
||||
|
||||
Args:
|
||||
provider: Provider name
|
||||
token: Authentication token to save
|
||||
country: Optional country code
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
country_str = f" (country: {country})" if country else ""
|
||||
|
||||
try:
|
||||
logger.debug(f"Saving authentication token for {provider}{country_str}")
|
||||
|
||||
# Load existing session data
|
||||
session_data = self.load_session(provider, country) or {}
|
||||
logger.debug(f"Current session data keys before token save: {list(session_data.keys())}")
|
||||
|
||||
# Update token data - include classification fields
|
||||
token_data = token.to_dict()
|
||||
|
||||
# Log token info (without sensitive data)
|
||||
safe_token_data = {}
|
||||
for key, value in token_data.items():
|
||||
if key in ['access_token', 'refresh_token']:
|
||||
safe_token_data[key] = f"<{key}_present>" if value else f"<{key}_missing>"
|
||||
elif key in ['auth_level', 'credential_type']:
|
||||
safe_token_data[key] = value # Include classification info
|
||||
else:
|
||||
safe_token_data[key] = value
|
||||
logger.info(f"Token data to save for {provider}{country_str}: {safe_token_data}")
|
||||
|
||||
session_data.update(token_data)
|
||||
logger.debug(f"Updated session data keys after token merge: {list(session_data.keys())}")
|
||||
|
||||
success = self.save_session(provider, session_data, country)
|
||||
if success:
|
||||
logger.info(f"Successfully saved authentication token for {provider}{country_str}")
|
||||
else:
|
||||
logger.error(f"Failed to save authentication token for {provider}{country_str}")
|
||||
return success
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error saving token for {provider}{country_str}: {e}")
|
||||
return False
|
||||
|
||||
def load_token_data(self, provider: str, country: Optional[str] = None) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Load token data for a provider
|
||||
|
||||
Args:
|
||||
provider: Provider name
|
||||
country: Optional country code
|
||||
|
||||
Returns:
|
||||
Dictionary with token data or None if not found/expired
|
||||
"""
|
||||
country_str = f" (country: {country})" if country else ""
|
||||
|
||||
logger.debug(f"Loading token data for {provider}{country_str}")
|
||||
|
||||
session_data = self.load_session(provider, country)
|
||||
if not session_data:
|
||||
logger.info(f"No session data available for token loading for {provider}{country_str}")
|
||||
return None
|
||||
|
||||
# Check if we have token data
|
||||
if 'access_token' not in session_data:
|
||||
logger.info(f"No access token found in session data for {provider}{country_str}")
|
||||
logger.debug(f"Available session keys: {list(session_data.keys())}")
|
||||
return None
|
||||
|
||||
# Check if token is expired (with 5 minute buffer)
|
||||
expires_in = session_data.get('expires_in', 0)
|
||||
issued_at = session_data.get('issued_at', 0)
|
||||
current_time = time.time()
|
||||
expires_at = issued_at + expires_in
|
||||
time_until_expiry = expires_at - current_time
|
||||
|
||||
logger.debug(f"Token expiry check for {provider}{country_str}: issued_at={issued_at}, "
|
||||
f"expires_in={expires_in}, current_time={current_time}, "
|
||||
f"time_until_expiry={time_until_expiry:.0f}")
|
||||
|
||||
if current_time >= (expires_at - 300): # 5 minute buffer
|
||||
logger.info(f"Token expired for {provider}{country_str} "
|
||||
f"(expired {abs(time_until_expiry):.0f}s ago)")
|
||||
return None
|
||||
|
||||
logger.info(f"Loaded valid token data for {provider}{country_str} "
|
||||
f"(expires in {time_until_expiry:.0f}s, "
|
||||
f"auth_level={session_data.get('auth_level')})")
|
||||
return session_data
|
||||
|
||||
def get_device_id(self, provider: str, country: Optional[str] = None) -> str:
|
||||
"""
|
||||
Get or generate device ID for a provider
|
||||
|
||||
Args:
|
||||
provider: Provider name
|
||||
country: Optional country code
|
||||
|
||||
Returns:
|
||||
Device ID (UUID string)
|
||||
"""
|
||||
country_str = f" (country: {country})" if country else ""
|
||||
|
||||
session_data = self.load_session(provider, country) or {}
|
||||
|
||||
device_id = session_data.get('device_id')
|
||||
if not device_id:
|
||||
# Generate new device ID
|
||||
device_id = str(uuid.uuid4())
|
||||
session_data['device_id'] = device_id
|
||||
self.save_session(provider, session_data, country)
|
||||
logger.info(f"Generated new device ID for {provider}{country_str}: {device_id}")
|
||||
else:
|
||||
logger.debug(f"Using existing device ID for {provider}{country_str}: {device_id}")
|
||||
|
||||
return device_id
|
||||
|
||||
def clear_session(self, provider: str, country: Optional[str] = None) -> bool:
|
||||
"""
|
||||
Clear session data for a provider and optional country
|
||||
|
||||
Args:
|
||||
provider: Provider name
|
||||
country: Optional country code (if None, clears entire provider)
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
country_str = f" (country: {country})" if country else ""
|
||||
|
||||
try:
|
||||
data = self.vfs.read_json(self.session_file)
|
||||
if not data:
|
||||
logger.debug(f"No session file exists, nothing to clear for {provider}{country_str}")
|
||||
return True
|
||||
|
||||
if country:
|
||||
# Clear specific country data
|
||||
if provider in data and isinstance(data[provider], dict) and country in data[provider]:
|
||||
del data[provider][country]
|
||||
logger.info(f"Cleared session data for {provider}{country_str}")
|
||||
|
||||
# If provider dict is now empty, remove it entirely
|
||||
if not data[provider]:
|
||||
del data[provider]
|
||||
logger.debug(f"Provider {provider} had no more countries, removed entirely")
|
||||
|
||||
return self.vfs.write_json(self.session_file, data)
|
||||
else:
|
||||
logger.debug(f"No session data found to clear for {provider}{country_str}")
|
||||
else:
|
||||
# Clear entire provider data (all countries)
|
||||
if provider in data:
|
||||
del data[provider]
|
||||
logger.info(f"Cleared all session data for {provider}")
|
||||
return self.vfs.write_json(self.session_file, data)
|
||||
else:
|
||||
logger.debug(f"No session data found to clear for {provider}")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error clearing session for {provider}{country_str}: {e}")
|
||||
return False
|
||||
|
||||
def clear_token(self, provider: str, country: Optional[str] = None) -> bool:
|
||||
"""
|
||||
Clear only token data but keep other session data (like device_id)
|
||||
|
||||
Args:
|
||||
provider: Provider name
|
||||
country: Optional country code
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
country_str = f" (country: {country})" if country else ""
|
||||
|
||||
try:
|
||||
session_data = self.load_session(provider, country)
|
||||
if not session_data:
|
||||
logger.debug(f"No session data found, nothing to clear for {provider}{country_str}")
|
||||
return True
|
||||
|
||||
# Remove token-related fields
|
||||
token_fields = ['access_token', 'refresh_token', 'token_type', 'expires_in',
|
||||
'issued_at', 'auth_level', 'credential_type']
|
||||
fields_removed = []
|
||||
for field in token_fields:
|
||||
if session_data.pop(field, None) is not None:
|
||||
fields_removed.append(field)
|
||||
|
||||
if fields_removed:
|
||||
logger.debug(f"Cleared token fields {fields_removed} for {provider}{country_str}")
|
||||
|
||||
return self.save_session(provider, session_data, country)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error clearing token for {provider}{country_str}: {e}")
|
||||
return False
|
||||
|
||||
def get_all_countries(self, provider: str) -> list:
|
||||
"""
|
||||
Get all countries that have session data for a provider
|
||||
|
||||
Args:
|
||||
provider: Provider name
|
||||
|
||||
Returns:
|
||||
List of country codes
|
||||
"""
|
||||
try:
|
||||
data = self.vfs.read_json(self.session_file)
|
||||
if not data or provider not in data:
|
||||
return []
|
||||
|
||||
provider_data = data[provider]
|
||||
|
||||
# Check if this is a nested (country-aware) structure
|
||||
if isinstance(provider_data, dict):
|
||||
# Check if any keys look like country codes (2-3 char strings)
|
||||
# and their values are dicts (session data)
|
||||
countries = []
|
||||
for key, value in provider_data.items():
|
||||
if isinstance(value, dict) and len(key) <= 3:
|
||||
countries.append(key)
|
||||
return countries
|
||||
|
||||
return []
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting countries for {provider}: {e}")
|
||||
return []
|
||||
|
||||
def debug_session_file(self) -> None:
|
||||
"""
|
||||
Debug method to log the current state of the session file
|
||||
"""
|
||||
try:
|
||||
logger.info(f"=== SESSION FILE DEBUG INFO ===")
|
||||
|
||||
# Get VFS debug info
|
||||
vfs_info = self.vfs.debug_info()
|
||||
for key, value in vfs_info.items():
|
||||
logger.info(f"VFS {key}: {value}")
|
||||
|
||||
session_file_path = self.vfs.join_path(self.session_file)
|
||||
logger.info(f"Session file path: {session_file_path}")
|
||||
logger.info(f"Session file exists: {self.vfs.exists(self.session_file)}")
|
||||
|
||||
if self.vfs.exists(self.session_file):
|
||||
file_size = self.vfs.get_size(self.session_file)
|
||||
logger.info(f"Session file size: {file_size} bytes")
|
||||
|
||||
if file_size and file_size > 0:
|
||||
data = self.vfs.read_json(self.session_file)
|
||||
if data:
|
||||
logger.info(f"Session file contains {len(data)} providers: {list(data.keys())}")
|
||||
|
||||
for provider, provider_data in data.items():
|
||||
if isinstance(provider_data, dict):
|
||||
# Check if nested (has countries)
|
||||
has_countries = any(
|
||||
isinstance(v, dict) and len(k) <= 3
|
||||
for k, v in provider_data.items()
|
||||
)
|
||||
|
||||
if has_countries:
|
||||
logger.info(f" {provider} (country-aware):")
|
||||
for country, session_data in provider_data.items():
|
||||
if isinstance(session_data, dict):
|
||||
safe_keys = self._get_safe_keys(session_data)
|
||||
logger.info(f" {country}: {safe_keys}")
|
||||
else:
|
||||
# Flat structure (no country)
|
||||
safe_keys = self._get_safe_keys(provider_data)
|
||||
logger.info(f" {provider} (no country): {safe_keys}")
|
||||
else:
|
||||
logger.error("Session file contains invalid JSON or is empty")
|
||||
else:
|
||||
logger.info("Session file is empty")
|
||||
else:
|
||||
logger.info("Session file does not exist yet")
|
||||
|
||||
logger.info(f"=== END SESSION FILE DEBUG ===")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error during session file debug: {e}")
|
||||
|
||||
@staticmethod
|
||||
def _get_safe_keys(session_data: Dict[str, Any]) -> list:
|
||||
"""Helper to get safe representation of session keys"""
|
||||
safe_keys = []
|
||||
for key in session_data.keys():
|
||||
if key in ['access_token', 'refresh_token']:
|
||||
safe_keys.append(f"{key}:present" if session_data[key] else f"{key}:missing")
|
||||
else:
|
||||
safe_keys.append(f"{key}:{session_data[key]}")
|
||||
return safe_keys
|
||||
@@ -0,0 +1,18 @@
|
||||
# streaming_providers/base/drm/__init__.py
|
||||
"""
|
||||
Plugin system for DRM configuration processing
|
||||
|
||||
This module contains the core abstractions for DRM plugins.
|
||||
All plugin implementations are automatically discovered by the DRMPluginManager.
|
||||
"""
|
||||
|
||||
from .drm_plugin import DRMPlugin
|
||||
from .plugin_manager import DRMPluginManager
|
||||
|
||||
# The plugin manager handles automatic discovery and registration
|
||||
# No need to manually import or register plugins here
|
||||
|
||||
__all__ = [
|
||||
"DRMPlugin",
|
||||
"DRMPluginManager",
|
||||
]
|
||||
@@ -0,0 +1,46 @@
|
||||
# streaming_providers/base/drm/drm_plugin.py
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Optional
|
||||
from ..models.drm_models import DRMConfig, DRMSystem, PSSHData
|
||||
|
||||
|
||||
class DRMPlugin(ABC):
|
||||
"""
|
||||
Abstract base class for DRM configuration plugins.
|
||||
|
||||
Plugins can register to process DRM configs for specific DRM systems
|
||||
and transform them before they are returned to the caller.
|
||||
"""
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def plugin_name(self) -> str:
|
||||
"""Return the unique plugin name"""
|
||||
pass
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def supported_drm_system(self) -> DRMSystem:
|
||||
"""Return the DRM system this plugin processes"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def process_drm_config(self,
|
||||
drm_config: DRMConfig,
|
||||
pssh_data: Optional[PSSHData],
|
||||
**kwargs) -> Optional[DRMConfig]:
|
||||
"""
|
||||
Process and transform a DRM configuration.
|
||||
|
||||
Args:
|
||||
drm_config: The DRM config to process (guaranteed to match supported_drm_system)
|
||||
pssh_data: The PSSH data for this DRM system from the manifest, or None if not available
|
||||
**kwargs: Additional context from the original method call
|
||||
|
||||
Returns:
|
||||
Transformed DRMConfig, or None if the config should be filtered out
|
||||
|
||||
Raises:
|
||||
Exception: Any exception will be caught and logged, plugin will be skipped
|
||||
"""
|
||||
pass
|
||||
@@ -0,0 +1,318 @@
|
||||
# streaming_providers/base/drm/plugin_manager.py
|
||||
from typing import Dict, List, Optional
|
||||
from ..models.drm_models import DRMConfig, DRMSystem, PSSHData
|
||||
from .drm_plugin import DRMPlugin
|
||||
from ..utils.logger import logger
|
||||
import traceback
|
||||
|
||||
|
||||
class DRMPluginManager:
|
||||
"""
|
||||
Manager for DRM configuration plugins.
|
||||
|
||||
Handles plugin registration, discovery, and processing of DRM configs with PSSH data.
|
||||
"""
|
||||
|
||||
def __init__(self, auto_discover: bool = True):
|
||||
"""Initialize with empty plugin registry and optionally auto-discover plugins"""
|
||||
self.plugins: Dict[DRMSystem, DRMPlugin] = {}
|
||||
logger.debug("DRMPluginManager: Initialized with empty plugin registry")
|
||||
|
||||
if auto_discover:
|
||||
logger.debug("DRMPluginManager: Auto-discovery enabled, discovering plugins...")
|
||||
discovered = self.discover_plugins()
|
||||
if discovered:
|
||||
logger.debug(f"DRMPluginManager: Auto-discovery completed, {len(discovered)} plugins ready")
|
||||
else:
|
||||
logger.debug("DRMPluginManager: Auto-discovery completed, no plugins found")
|
||||
|
||||
def register_plugin(self, plugin: DRMPlugin) -> None:
|
||||
"""
|
||||
Register a single plugin instance.
|
||||
|
||||
Args:
|
||||
plugin: Configured plugin instance to register
|
||||
|
||||
Raises:
|
||||
ValueError: If plugin is invalid or DRM system already has a plugin
|
||||
"""
|
||||
if not isinstance(plugin, DRMPlugin):
|
||||
logger.warning(f"DRMPluginManager: Registration failed - invalid plugin type: {type(plugin)}")
|
||||
raise ValueError("Only DRMPlugin instances can be registered")
|
||||
|
||||
drm_system = plugin.supported_drm_system
|
||||
plugin_name = plugin.plugin_name
|
||||
|
||||
if drm_system in self.plugins:
|
||||
existing_plugin = self.plugins[drm_system].plugin_name
|
||||
logger.warning(f"DRMPluginManager: Registration failed - DRM system {drm_system} already has plugin '{existing_plugin}' registered")
|
||||
raise ValueError(f"DRM system {drm_system} already has plugin '{existing_plugin}' registered")
|
||||
|
||||
self.plugins[drm_system] = plugin
|
||||
logger.debug(f"DRMPluginManager: Successfully registered plugin '{plugin_name}' for DRM system {drm_system}")
|
||||
|
||||
def discover_plugins(self) -> List[str]:
|
||||
"""
|
||||
Discover and register all available DRM plugins by scanning filesystem.
|
||||
|
||||
Scans only the plugins directory (not subdirectories) for Python files
|
||||
containing classes that inherit from DRMPlugin.
|
||||
|
||||
Returns:
|
||||
List of discovered plugin names
|
||||
"""
|
||||
import os
|
||||
import importlib.util
|
||||
import inspect
|
||||
|
||||
logger.debug("DRMPluginManager: Starting filesystem-based plugin autodiscovery")
|
||||
|
||||
# Get the directory where this plugin manager is located
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
plugins_dir = os.path.join(current_dir, "plugins") # Scan the plugins subfolder
|
||||
|
||||
# Check if plugins directory exists
|
||||
if not os.path.exists(plugins_dir):
|
||||
logger.debug(f"DRMPluginManager: Plugins directory does not exist: {plugins_dir}")
|
||||
return []
|
||||
|
||||
logger.debug(f"DRMPluginManager: Scanning plugins directory: {plugins_dir}")
|
||||
|
||||
registered = []
|
||||
failed_plugins = []
|
||||
scanned_files = []
|
||||
|
||||
# Scan only the plugins directory (no subdirectories)
|
||||
try:
|
||||
files = os.listdir(plugins_dir)
|
||||
except OSError as e:
|
||||
logger.warning(f"DRMPluginManager: Error reading plugins directory: {e}")
|
||||
return []
|
||||
|
||||
for filename in files:
|
||||
file_path = os.path.join(plugins_dir, filename)
|
||||
|
||||
# Only process Python files (not directories or other files)
|
||||
if (filename.endswith('.py') and
|
||||
not filename.startswith('__') and
|
||||
os.path.isfile(file_path)):
|
||||
|
||||
scanned_files.append(filename)
|
||||
|
||||
logger.debug(f"DRMPluginManager: Scanning file: {filename}")
|
||||
|
||||
try:
|
||||
# Create module name from filename
|
||||
module_name = os.path.splitext(filename)[0]
|
||||
|
||||
# Import the module dynamically
|
||||
spec = importlib.util.spec_from_file_location(module_name, file_path)
|
||||
if spec is None or spec.loader is None:
|
||||
logger.debug(f"DRMPluginManager: Could not create module spec for {filename}")
|
||||
continue
|
||||
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
|
||||
# Find all classes in the module that inherit from DRMPlugin
|
||||
plugin_classes = []
|
||||
for name, obj in inspect.getmembers(module, inspect.isclass):
|
||||
# Check if it's a DRMPlugin subclass (but not DRMPlugin itself)
|
||||
if (issubclass(obj, DRMPlugin) and
|
||||
obj is not DRMPlugin and
|
||||
obj.__module__ == module.__name__):
|
||||
plugin_classes.append((name, obj))
|
||||
|
||||
if not plugin_classes:
|
||||
logger.debug(f"DRMPluginManager: No DRMPlugin classes found in {filename}")
|
||||
continue
|
||||
|
||||
logger.debug(f"DRMPluginManager: Found {len(plugin_classes)} plugin class(es) in {filename}: {[name for name, _ in plugin_classes]}")
|
||||
|
||||
# Instantiate and register each plugin class found
|
||||
for class_name, plugin_class in plugin_classes:
|
||||
try:
|
||||
logger.debug(f"DRMPluginManager: Attempting to instantiate {class_name} from {filename}")
|
||||
|
||||
# Create plugin instance
|
||||
plugin = plugin_class()
|
||||
plugin_name = plugin.plugin_name
|
||||
drm_system = plugin.supported_drm_system
|
||||
|
||||
logger.debug(f"DRMPluginManager: Successfully created plugin '{plugin_name}' (class: {class_name}) supporting {drm_system}")
|
||||
|
||||
# Register the plugin
|
||||
self.register_plugin(plugin)
|
||||
registered.append(plugin_name)
|
||||
logger.debug(f"DRMPluginManager: Plugin '{plugin_name}' from {filename} successfully registered")
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Failed to instantiate {class_name} from {filename}: {str(e)}"
|
||||
failed_plugins.append((f"{filename}::{class_name}", error_msg))
|
||||
logger.warning(f"DRMPluginManager: {error_msg}")
|
||||
|
||||
except Exception as e:
|
||||
traceback_str = traceback.format_exc()
|
||||
error_msg = f"Failed to process file {filename}: {str(e)}\n{traceback_str}"
|
||||
failed_plugins.append((filename, error_msg))
|
||||
logger.warning(f"DRMPluginManager: {error_msg}")
|
||||
|
||||
# Log scanning summary
|
||||
logger.debug(f"DRMPluginManager: Filesystem scan completed - scanned {len(scanned_files)} Python files")
|
||||
if scanned_files:
|
||||
logger.debug(f"DRMPluginManager: Scanned files: {scanned_files}")
|
||||
|
||||
# Log final discovery results
|
||||
if registered:
|
||||
logger.debug(f"DRMPluginManager: Filesystem autodiscovery completed successfully - {len(registered)} plugins registered: {registered}")
|
||||
else:
|
||||
logger.debug("DRMPluginManager: Filesystem autodiscovery completed - no plugins were registered")
|
||||
|
||||
if failed_plugins:
|
||||
logger.debug(f"DRMPluginManager: {len(failed_plugins)} plugins/files failed to load:")
|
||||
for plugin_name, error in failed_plugins:
|
||||
logger.debug(f" - {plugin_name}: {error}")
|
||||
|
||||
return registered
|
||||
|
||||
def process_drm_configs(self,
|
||||
drm_configs: List[DRMConfig],
|
||||
pssh_data_list: List[PSSHData],
|
||||
**kwargs) -> List[DRMConfig]:
|
||||
"""
|
||||
Process a list of DRM configs through registered plugins using PSSH data.
|
||||
Generic plugins are processed first, and if any ClearKey config is found,
|
||||
only that config is returned.
|
||||
|
||||
Args:
|
||||
drm_configs: List of DRM configs to process
|
||||
pssh_data_list: List of PSSH data extracted from manifest
|
||||
**kwargs: Additional context from the original method call
|
||||
|
||||
Returns:
|
||||
List of processed DRM configs (may be modified, filtered, or unchanged)
|
||||
"""
|
||||
if not drm_configs:
|
||||
logger.debug("DRMPluginManager: No DRM configs to process")
|
||||
return drm_configs
|
||||
|
||||
logger.debug(f"DRMPluginManager: Processing {len(drm_configs)} DRM configs with {len(pssh_data_list)} PSSH data entries")
|
||||
|
||||
# Create a mapping of DRM system to PSSH data for quick lookup
|
||||
pssh_by_system = {}
|
||||
for pssh_data in pssh_data_list:
|
||||
if pssh_data.drm_system:
|
||||
pssh_by_system[pssh_data.drm_system] = pssh_data
|
||||
logger.debug(f"DRMPluginManager: Mapped PSSH data for DRM system: {pssh_data.drm_system}")
|
||||
|
||||
# Separate generic and specific plugins
|
||||
generic_plugins = []
|
||||
specific_plugins = []
|
||||
|
||||
for drm_system, plugin in self.plugins.items():
|
||||
if drm_system == DRMSystem.GENERIC:
|
||||
generic_plugins.append(plugin)
|
||||
logger.debug(f"DRMPluginManager: Found generic plugin '{plugin.plugin_name}'")
|
||||
else:
|
||||
specific_plugins.append((drm_system, plugin))
|
||||
|
||||
# Process generic plugins first
|
||||
processed_configs = list(drm_configs)
|
||||
|
||||
for plugin in generic_plugins:
|
||||
logger.debug(f"DRMPluginManager: Processing through generic plugin '{plugin.plugin_name}'")
|
||||
try:
|
||||
# Generic plugins process all configs at once
|
||||
temp_configs = []
|
||||
for config in processed_configs:
|
||||
pssh_data = pssh_by_system.get(config.system)
|
||||
result = plugin.process_drm_config(config, pssh_data, **kwargs)
|
||||
if result is not None:
|
||||
temp_configs.append(result)
|
||||
|
||||
# Check for ClearKey and return immediately if found
|
||||
for config in temp_configs:
|
||||
if config.system == DRMSystem.CLEARKEY:
|
||||
logger.debug(f"DRMPluginManager: ClearKey config found, returning immediately")
|
||||
return [config]
|
||||
|
||||
processed_configs = temp_configs
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"DRMPluginManager: Generic plugin '{plugin.plugin_name}' failed: {str(e)}")
|
||||
continue
|
||||
|
||||
# Process specific plugins
|
||||
final_configs = []
|
||||
for config in processed_configs:
|
||||
logger.debug(f"DRMPluginManager: Processing DRM config for system: {config.system}")
|
||||
|
||||
# Find specific plugin for this DRM system
|
||||
plugin = self.plugins.get(config.system)
|
||||
|
||||
if plugin:
|
||||
logger.debug(f"DRMPluginManager: Found plugin '{plugin.plugin_name}' for DRM system {config.system}")
|
||||
|
||||
try:
|
||||
pssh_data = pssh_by_system.get(config.system)
|
||||
if pssh_data:
|
||||
logger.debug(f"DRMPluginManager: Using PSSH data for DRM system {config.system}")
|
||||
else:
|
||||
logger.debug(f"DRMPluginManager: No PSSH data available for DRM system {config.system}")
|
||||
|
||||
processed_config = plugin.process_drm_config(config, pssh_data, **kwargs)
|
||||
if processed_config is not None:
|
||||
# Check for ClearKey and return immediately if found
|
||||
if processed_config.system == DRMSystem.CLEARKEY:
|
||||
logger.debug(f"DRMPluginManager: ClearKey config found, returning immediately")
|
||||
return [processed_config]
|
||||
|
||||
final_configs.append(processed_config)
|
||||
logger.debug(f"DRMPluginManager: Plugin '{plugin.plugin_name}' successfully processed config")
|
||||
else:
|
||||
logger.debug(f"DRMPluginManager: Plugin '{plugin.plugin_name}' filtered out config (returned None)")
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"DRMPluginManager: Plugin '{plugin.plugin_name}' failed to process config: {str(e)}")
|
||||
final_configs.append(config)
|
||||
logger.debug(f"DRMPluginManager: Using original config as fallback")
|
||||
else:
|
||||
logger.debug(f"DRMPluginManager: No plugin registered for DRM system {config.system}, passing through unchanged")
|
||||
final_configs.append(config)
|
||||
|
||||
logger.debug(f"DRMPluginManager: Completed processing - {len(final_configs)} configs returned")
|
||||
return final_configs
|
||||
|
||||
def get_plugin(self, drm_system: DRMSystem) -> Optional[DRMPlugin]:
|
||||
"""
|
||||
Get registered plugin for a DRM system.
|
||||
|
||||
Args:
|
||||
drm_system: DRM system to get plugin for
|
||||
|
||||
Returns:
|
||||
The plugin instance or None if not found
|
||||
"""
|
||||
plugin = self.plugins.get(drm_system)
|
||||
if plugin:
|
||||
logger.debug(f"DRMPluginManager: Retrieved plugin '{plugin.plugin_name}' for DRM system {drm_system}")
|
||||
else:
|
||||
logger.debug(f"DRMPluginManager: No plugin found for DRM system {drm_system}")
|
||||
return plugin
|
||||
|
||||
def list_plugins(self) -> Dict[DRMSystem, str]:
|
||||
"""
|
||||
List all registered plugins.
|
||||
|
||||
Returns:
|
||||
Dictionary mapping DRM systems to plugin names
|
||||
"""
|
||||
plugin_list = {drm_system: plugin.plugin_name for drm_system, plugin in self.plugins.items()}
|
||||
logger.debug(f"DRMPluginManager: Currently registered plugins: {plugin_list}")
|
||||
return plugin_list
|
||||
|
||||
def clear_plugins(self) -> None:
|
||||
"""Clear all registered plugins"""
|
||||
plugin_count = len(self.plugins)
|
||||
self.plugins.clear()
|
||||
logger.debug(f"DRMPluginManager: Cleared {plugin_count} registered plugins")
|
||||
@@ -0,0 +1,457 @@
|
||||
# streaming_providers/base/manager.py
|
||||
from typing import Dict, List, Optional
|
||||
from .provider import StreamingProvider
|
||||
from .models import StreamingChannel
|
||||
from .drm import DRMPluginManager
|
||||
from .utils.logger import logger
|
||||
|
||||
class ProviderManager:
|
||||
"""
|
||||
Central manager for handling multiple streaming providers.
|
||||
Handles provider registration, discovery, and channel fetching operations.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize with empty provider registry and DRM plugin manager"""
|
||||
self.providers: Dict[str, StreamingProvider] = {}
|
||||
self.drm_plugin_manager = DRMPluginManager()
|
||||
logger.info("ProviderManager: Initialized with DRM plugin manager")
|
||||
|
||||
def register_provider(self, provider: StreamingProvider) -> None:
|
||||
"""
|
||||
Register a single provider instance.
|
||||
|
||||
Args:
|
||||
provider: Configured provider instance to register
|
||||
"""
|
||||
if not isinstance(provider, StreamingProvider):
|
||||
logger.error(f"ProviderManager: Failed to register provider - invalid type: {type(provider)}")
|
||||
raise ValueError("Only StreamingProvider instances can be registered")
|
||||
|
||||
self.providers[provider.provider_name] = provider
|
||||
logger.info(f"ProviderManager: Registered provider '{provider.provider_name}'")
|
||||
|
||||
def register_providers(self, providers: List[StreamingProvider]) -> None:
|
||||
"""
|
||||
Register multiple provider instances at once.
|
||||
|
||||
Args:
|
||||
providers: List of configured provider instances
|
||||
"""
|
||||
logger.info(f"ProviderManager: Registering {len(providers)} providers")
|
||||
for provider in providers:
|
||||
self.register_provider(provider)
|
||||
|
||||
def discover_providers(self, country: str = 'DE', detected_providers: Dict[str, List[str]] = None) -> List[str]:
|
||||
"""
|
||||
Discover and register all available providers for a country.
|
||||
|
||||
Args:
|
||||
country: Country code for provider configuration (used as fallback)
|
||||
detected_providers: Optional dict mapping provider names to country lists.
|
||||
If None, falls back to discovering all AVAILABLE_PROVIDERS.
|
||||
|
||||
Returns:
|
||||
List of discovered provider names (without country suffixes for compatibility)
|
||||
"""
|
||||
from streaming_providers import AVAILABLE_PROVIDERS
|
||||
|
||||
# Backward compatibility: if no detected_providers, use original discovery logic
|
||||
if detected_providers is None:
|
||||
logger.info(f"ProviderManager: Discovering providers for country '{country}'")
|
||||
registered = []
|
||||
failed = []
|
||||
|
||||
for provider_name, provider_class in AVAILABLE_PROVIDERS.items():
|
||||
if provider_name not in self.providers:
|
||||
try:
|
||||
provider = provider_class(country=country)
|
||||
self.register_provider(provider)
|
||||
registered.append(provider_name)
|
||||
except Exception as e:
|
||||
failed.append((provider_name, str(e)))
|
||||
logger.warning(f"ProviderManager: Could not initialize provider '{provider_name}': {e}")
|
||||
|
||||
logger.info(
|
||||
f"ProviderManager: Discovery completed - {len(registered)} providers registered, {len(failed)} failed")
|
||||
return registered
|
||||
|
||||
# New multi-country logic
|
||||
logger.info(f"ProviderManager: Discovering providers with multi-country support")
|
||||
registered = []
|
||||
failed = []
|
||||
|
||||
for provider_name, countries in detected_providers.items():
|
||||
if provider_name not in AVAILABLE_PROVIDERS:
|
||||
logger.warning(f"ProviderManager: Provider '{provider_name}' not in AVAILABLE_PROVIDERS, skipping")
|
||||
continue
|
||||
|
||||
provider_class = AVAILABLE_PROVIDERS[provider_name]
|
||||
|
||||
try:
|
||||
if countries: # Multi-country provider
|
||||
for country_code in countries:
|
||||
provider_key = f"{provider_name}_{country_code}"
|
||||
if provider_key not in self.providers:
|
||||
provider = provider_class(country=country_code.lower())
|
||||
self.providers[provider_key] = provider
|
||||
logger.debug(f"ProviderManager: Registered {provider_key}")
|
||||
|
||||
# Return base provider name once for backward compatibility
|
||||
if provider_name not in registered:
|
||||
registered.append(provider_name)
|
||||
else: # Single country provider (fallback to default country)
|
||||
if provider_name not in self.providers:
|
||||
provider = provider_class(country=country.lower())
|
||||
self.providers[provider_name] = provider
|
||||
registered.append(provider_name)
|
||||
except Exception as e:
|
||||
failed.append((provider_name, str(e)))
|
||||
logger.warning(f"ProviderManager: Could not initialize provider '{provider_name}': {e}")
|
||||
|
||||
logger.info(
|
||||
f"ProviderManager: Discovery completed - {len(registered)} providers registered, {len(failed)} failed")
|
||||
return registered
|
||||
|
||||
def discover_drm_plugins(self) -> List[str]:
|
||||
"""
|
||||
Discover and register all available DRM plugins.
|
||||
|
||||
Returns:
|
||||
List of discovered plugin names
|
||||
"""
|
||||
logger.info("ProviderManager: Discovering DRM plugins")
|
||||
discovered = self.drm_plugin_manager.discover_plugins()
|
||||
logger.info(f"ProviderManager: DRM plugin discovery completed - {len(discovered)} plugins available")
|
||||
return discovered
|
||||
|
||||
def get_provider(self, provider_name: str) -> Optional[StreamingProvider]:
|
||||
"""
|
||||
Get registered provider by name.
|
||||
|
||||
Args:
|
||||
provider_name: Name of the provider to retrieve
|
||||
|
||||
Returns:
|
||||
The provider instance or None if not found
|
||||
"""
|
||||
provider = self.providers.get(provider_name)
|
||||
if not provider:
|
||||
logger.debug(f"ProviderManager: Provider '{provider_name}' not found")
|
||||
return provider
|
||||
|
||||
def get_provider_http_manager(self, provider_name: str):
|
||||
"""
|
||||
Get HTTP manager for a specific provider.
|
||||
|
||||
Args:
|
||||
provider_name: Name of the provider
|
||||
|
||||
Returns:
|
||||
HTTPManager instance if provider exists and has one, None otherwise
|
||||
"""
|
||||
provider = self.get_provider(provider_name)
|
||||
if not provider:
|
||||
logger.warning(f"ProviderManager: Provider '{provider_name}' not found")
|
||||
return None
|
||||
|
||||
http_manager = provider.http_manager
|
||||
if not http_manager:
|
||||
logger.warning(f"ProviderManager: Provider '{provider_name}' has no HTTP manager configured")
|
||||
return None
|
||||
|
||||
logger.debug(f"ProviderManager: Retrieved HTTP manager for provider '{provider_name}'")
|
||||
return http_manager
|
||||
|
||||
def needs_proxy(self, provider_name: str) -> bool:
|
||||
"""
|
||||
Check if a provider needs proxy support.
|
||||
|
||||
Args:
|
||||
provider_name: Name of the provider
|
||||
|
||||
Returns:
|
||||
True if provider has proxy configured, False otherwise
|
||||
"""
|
||||
http_manager = self.get_provider_http_manager(provider_name)
|
||||
if not http_manager:
|
||||
return False
|
||||
|
||||
has_proxy = http_manager.config.proxy_config is not None
|
||||
if has_proxy:
|
||||
logger.debug(f"ProviderManager: Provider '{provider_name}' requires proxy")
|
||||
else:
|
||||
logger.debug(f"ProviderManager: Provider '{provider_name}' does not require proxy")
|
||||
|
||||
return has_proxy
|
||||
|
||||
def get_provider_choices(self) -> Dict[int, str]:
|
||||
"""
|
||||
Get numbered provider choices for user selection.
|
||||
Includes an 'all' option as the last choice.
|
||||
|
||||
Returns:
|
||||
Mapping of choice numbers to provider names
|
||||
"""
|
||||
choices = {i+1: name for i, name in enumerate(self.providers.keys())}
|
||||
choices[len(choices)+1] = 'all'
|
||||
return choices
|
||||
|
||||
def get_selected_providers(self, choices_input: str) -> List[str]:
|
||||
"""
|
||||
Convert user input string to list of provider names.
|
||||
|
||||
Args:
|
||||
choices_input: Comma-separated string of choice numbers
|
||||
|
||||
Returns:
|
||||
List of selected provider names
|
||||
"""
|
||||
available = list(self.providers.keys())
|
||||
if not available:
|
||||
logger.warning("ProviderManager: No providers available for selection")
|
||||
return []
|
||||
|
||||
selected = []
|
||||
for choice in choices_input.split(','):
|
||||
choice = choice.strip()
|
||||
if choice.isdigit():
|
||||
idx = int(choice) - 1
|
||||
if 0 <= idx < len(available):
|
||||
selected.append(available[idx])
|
||||
elif idx == len(available): # 'all' option
|
||||
selected = available.copy()
|
||||
|
||||
result = selected or available
|
||||
logger.debug(f"ProviderManager: Selected {len(result)} providers from input '{choices_input}'")
|
||||
return result
|
||||
|
||||
def get_channels(self, provider_name: str, fetch_manifests: bool = False, **kwargs) -> List[StreamingChannel]:
|
||||
"""
|
||||
Get channels from a specific provider.
|
||||
|
||||
Args:
|
||||
provider_name: Name of the provider
|
||||
fetch_manifests: Whether to enrich channels with manifest data
|
||||
**kwargs: Additional arguments for channel fetching
|
||||
|
||||
Returns:
|
||||
List of channels from the provider
|
||||
|
||||
Raises:
|
||||
ValueError: If provider not found
|
||||
"""
|
||||
provider = self.get_provider(provider_name)
|
||||
if not provider:
|
||||
logger.error(f"ProviderManager: Cannot get channels - provider '{provider_name}' not found")
|
||||
raise ValueError(f"Provider '{provider_name}' not found")
|
||||
|
||||
logger.debug(f"ProviderManager: Fetching channels from provider '{provider_name}' (fetch_manifests={fetch_manifests})")
|
||||
channels = provider.fetch_channels(**kwargs)
|
||||
logger.info(f"ProviderManager: Retrieved {len(channels)} channels from provider '{provider_name}'")
|
||||
|
||||
if fetch_manifests and not provider.uses_dynamic_manifests:
|
||||
logger.debug(f"ProviderManager: Enriching channels with manifest data for provider '{provider_name}'")
|
||||
enriched_channels = []
|
||||
for channel in channels:
|
||||
enriched = provider.enrich_channel_data(channel, **kwargs)
|
||||
if enriched is not None:
|
||||
enriched_channels.append(enriched)
|
||||
logger.info(f"ProviderManager: Enriched {len(enriched_channels)} out of {len(channels)} channels")
|
||||
return enriched_channels
|
||||
return channels
|
||||
|
||||
def get_channel_manifest(self, provider_name: str, channel_id: str, **kwargs) -> Optional[str]:
|
||||
"""
|
||||
Get manifest URL for a specific channel from a provider.
|
||||
|
||||
Args:
|
||||
provider_name: Name of the provider
|
||||
channel_id: ID of the channel
|
||||
**kwargs: Additional arguments (e.g., country)
|
||||
|
||||
Returns:
|
||||
Manifest URL string, or None if not available
|
||||
|
||||
Raises:
|
||||
ValueError: If provider not found
|
||||
"""
|
||||
provider = self.get_provider(provider_name)
|
||||
if not provider:
|
||||
logger.error(f"ProviderManager: Cannot get manifest - provider '{provider_name}' not found")
|
||||
raise ValueError(f"Provider '{provider_name}' not found")
|
||||
|
||||
manifest_url = provider.get_manifest(channel_id, **kwargs)
|
||||
if manifest_url:
|
||||
logger.debug(f"ProviderManager: Retrieved manifest for channel '{channel_id}' from provider '{provider_name}'")
|
||||
else:
|
||||
logger.warning(f"ProviderManager: No manifest available for channel '{channel_id}' from provider '{provider_name}'")
|
||||
return manifest_url
|
||||
|
||||
def get_channel_epg(self, provider_name: str, channel_id: str, **kwargs) -> List[Dict]:
|
||||
"""
|
||||
Get EPG data for a specific channel from a provider.
|
||||
|
||||
Args:
|
||||
provider_name: Name of the provider
|
||||
channel_id: ID of the channel
|
||||
**kwargs: Additional arguments (e.g., start_time, end_time, country)
|
||||
|
||||
Returns:
|
||||
List of EPG entries
|
||||
|
||||
Raises:
|
||||
ValueError: If provider not found
|
||||
"""
|
||||
provider = self.get_provider(provider_name)
|
||||
if not provider:
|
||||
logger.error(f"ProviderManager: Cannot get EPG - provider '{provider_name}' not found")
|
||||
raise ValueError(f"Provider '{provider_name}' not found")
|
||||
|
||||
epg_data = provider.get_epg(channel_id, **kwargs)
|
||||
logger.debug(f"ProviderManager: Retrieved {len(epg_data)} EPG entries for channel '{channel_id}' from provider '{provider_name}'")
|
||||
return epg_data
|
||||
|
||||
def get_provider_epg_xmltv(self, provider_name: str, **kwargs) -> Optional[str]:
|
||||
"""
|
||||
Get complete EPG data for a provider in XMLTV format.
|
||||
|
||||
Args:
|
||||
provider_name: Name of the provider
|
||||
**kwargs: Additional arguments (e.g., country)
|
||||
|
||||
Returns:
|
||||
XMLTV formatted string, or None if not available
|
||||
|
||||
Raises:
|
||||
ValueError: If provider not found
|
||||
"""
|
||||
provider = self.get_provider(provider_name)
|
||||
if not provider:
|
||||
logger.error(f"ProviderManager: Cannot get XMLTV EPG - provider '{provider_name}' not found")
|
||||
raise ValueError(f"Provider '{provider_name}' not found")
|
||||
|
||||
xmltv_data = provider.get_epg_xmltv(**kwargs)
|
||||
if xmltv_data:
|
||||
logger.info(f"ProviderManager: Retrieved XMLTV EPG data for provider '{provider_name}'")
|
||||
else:
|
||||
logger.warning(f"ProviderManager: No XMLTV EPG data available for provider '{provider_name}'")
|
||||
return xmltv_data
|
||||
|
||||
def get_channel_drm_configs(self, provider_name: str, channel_id: str, **kwargs) -> List:
|
||||
"""
|
||||
Get DRM configurations for a specific channel from a provider.
|
||||
DRM configs are processed through registered plugins before being returned.
|
||||
|
||||
Args:
|
||||
provider_name: Name of the provider
|
||||
channel_id: ID of the channel
|
||||
**kwargs: Additional arguments (e.g., country)
|
||||
|
||||
Returns:
|
||||
List of DRM configuration objects (processed by plugins if available)
|
||||
|
||||
Raises:
|
||||
ValueError: If provider not found
|
||||
"""
|
||||
provider = self.get_provider(provider_name)
|
||||
if not provider:
|
||||
logger.error(f"ProviderManager: Cannot get DRM configs - provider '{provider_name}' not found")
|
||||
raise ValueError(f"Provider '{provider_name}' not found")
|
||||
|
||||
logger.debug(f"ProviderManager: Getting DRM configs for channel '{channel_id}' from provider '{provider_name}'")
|
||||
|
||||
# Get raw DRM configs from provider
|
||||
drm_configs = provider.get_drm_configs_by_id(channel_id, **kwargs)
|
||||
logger.debug(f"ProviderManager: Retrieved {len(drm_configs)} raw DRM configs")
|
||||
|
||||
# Get manifest URL for the channel
|
||||
manifest_url = provider.get_manifest(channel_id, **kwargs)
|
||||
|
||||
# Extract PSSH data from manifest if available
|
||||
pssh_data_list = []
|
||||
if manifest_url:
|
||||
logger.debug(f"ProviderManager: Extracting PSSH data from manifest")
|
||||
try:
|
||||
pssh_data_list = self._extract_pssh_from_manifest(manifest_url)
|
||||
logger.debug(f"ProviderManager: Extracted {len(pssh_data_list)} PSSH data entries")
|
||||
except Exception as e:
|
||||
logger.warning(f"ProviderManager: Could not extract PSSH data from manifest: {e}")
|
||||
|
||||
# Process through DRM plugins with PSSH data
|
||||
processed_configs = self.drm_plugin_manager.process_drm_configs(drm_configs, pssh_data_list, **kwargs)
|
||||
logger.info(f"ProviderManager: Processed DRM configs for channel '{channel_id}' - {len(processed_configs)} configs returned")
|
||||
|
||||
return processed_configs
|
||||
|
||||
def _extract_pssh_from_manifest(self, manifest_url: str) -> List:
|
||||
"""
|
||||
Extract PSSH data from a manifest URL.
|
||||
|
||||
Args:
|
||||
manifest_url: URL of the manifest to parse
|
||||
|
||||
Returns:
|
||||
List of PSSHData objects extracted from the manifest
|
||||
"""
|
||||
import requests
|
||||
from .utils.manifest_parser import ManifestParser
|
||||
|
||||
try:
|
||||
# Fetch the manifest content
|
||||
response = requests.get(manifest_url, timeout=10)
|
||||
response.raise_for_status()
|
||||
|
||||
# Parse and extract PSSH data
|
||||
return ManifestParser.extract_pssh_from_manifest(response.text, manifest_url)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"ProviderManager: Failed to fetch or parse manifest from {manifest_url}: {e}")
|
||||
return []
|
||||
|
||||
def get_all_channels(self, fetch_manifests: bool = True, **kwargs) -> Dict[str, List[StreamingChannel]]:
|
||||
"""
|
||||
Get channels from all registered providers.
|
||||
|
||||
Args:
|
||||
fetch_manifests: Whether to enrich channels with manifest data
|
||||
**kwargs: Additional arguments for channel fetching
|
||||
|
||||
Returns:
|
||||
Dictionary mapping provider names to their channels
|
||||
"""
|
||||
logger.info(f"ProviderManager: Fetching channels from all {len(self.providers)} providers (fetch_manifests={fetch_manifests})")
|
||||
|
||||
result = {}
|
||||
total_channels = 0
|
||||
|
||||
for name in self.providers:
|
||||
try:
|
||||
channels = self.get_channels(name, fetch_manifests, **kwargs)
|
||||
result[name] = channels
|
||||
total_channels += len(channels)
|
||||
except Exception as e:
|
||||
logger.error(f"ProviderManager: Failed to get channels from provider '{name}': {e}")
|
||||
result[name] = []
|
||||
|
||||
logger.info(f"ProviderManager: Retrieved {total_channels} total channels from all providers")
|
||||
return result
|
||||
|
||||
def list_providers(self) -> List[str]:
|
||||
"""List names of all registered providers"""
|
||||
return list(self.providers.keys())
|
||||
|
||||
def clear_providers(self) -> None:
|
||||
"""Clear all registered providers"""
|
||||
provider_count = len(self.providers)
|
||||
self.providers.clear()
|
||||
logger.info(f"ProviderManager: Cleared {provider_count} registered providers")
|
||||
|
||||
def list_drm_plugins(self) -> Dict:
|
||||
"""List all registered DRM plugins"""
|
||||
return self.drm_plugin_manager.list_plugins()
|
||||
|
||||
def clear_drm_plugins(self) -> None:
|
||||
"""Clear all registered DRM plugins"""
|
||||
logger.info("ProviderManager: Clearing all DRM plugins")
|
||||
self.drm_plugin_manager.clear_plugins()
|
||||
@@ -0,0 +1,5 @@
|
||||
# streaming_providers/base/models/__init__.py
|
||||
from .streaming_channel import StreamingChannel
|
||||
from .drm_models import DRMConfig, LicenseConfig, DRMSystem
|
||||
|
||||
__all__ = ['StreamingChannel', 'DRMConfig', 'LicenseConfig', 'DRMSystem']
|
||||
@@ -0,0 +1,175 @@
|
||||
# streaming_providers/base/models/drm_models.py
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Dict, Optional, List
|
||||
import base64
|
||||
from enum import Enum
|
||||
|
||||
class DRMSystem(str, Enum):
|
||||
WIDEVINE = "com.widevine.alpha"
|
||||
PLAYREADY = "com.microsoft.playready"
|
||||
WISEPLAY = "com.huawei.wiseplay"
|
||||
CLEARKEY = "org.w3.clearkey"
|
||||
FAIRPLAY = "com.apple.fps"
|
||||
GENERIC = "generic"
|
||||
|
||||
@property
|
||||
def system_uuid(self) -> str:
|
||||
"""Get the standard UUID for this DRM system"""
|
||||
uuid_mapping = {
|
||||
self.WIDEVINE: "edef8ba9-79d6-4ace-a3c8-27dcd51d21ed",
|
||||
self.PLAYREADY: "9a04f079-9840-4286-ab92-e65be0885f95",
|
||||
self.CLEARKEY: "e2719d58-a985-b3c9-781a-b030af78d30e",
|
||||
self.WISEPLAY: "3d5e6d35-9b9a-41e8-b843-dd3c6e72c42c",
|
||||
self.FAIRPLAY: "94ce86fb-07ff-4f43-adb8-93d2fa968ca2",
|
||||
self.GENERIC: "" # No UUID for generic plugins
|
||||
}
|
||||
return uuid_mapping.get(self, "")
|
||||
|
||||
@classmethod
|
||||
def from_uuid(cls, uuid: str) -> Optional['DRMSystem']:
|
||||
"""Get DRM system from UUID"""
|
||||
uuid_lower = uuid.lower().replace("-", "")
|
||||
uuid_mapping = {
|
||||
"edef8ba979d64acea3c827dcd51d21ed": cls.WIDEVINE,
|
||||
"9a04f07998404286ab92e65be0885f95": cls.PLAYREADY,
|
||||
"e2719d58a985b3c9781ab030af78d30e": cls.CLEARKEY,
|
||||
"3d5e6d359b9a41e8b843dd3c6e72c42c": cls.WISEPLAY
|
||||
}
|
||||
return uuid_mapping.get(uuid_lower)
|
||||
|
||||
class WrapperType(str, Enum):
|
||||
BASE64 = "base64"
|
||||
URLENC = "urlenc"
|
||||
NONE = "none"
|
||||
|
||||
class UnwrapperType(str, Enum):
|
||||
AUTO = "auto"
|
||||
BASE64 = "base64"
|
||||
JSON = "json"
|
||||
XML = "xml"
|
||||
NONE = "none"
|
||||
|
||||
@dataclass
|
||||
class PSSHData:
|
||||
"""
|
||||
Protection System Specific Header data for DRM systems
|
||||
"""
|
||||
system_id: str # UUID of the DRM system
|
||||
pssh_box: str # Base64 encoded PSSH box data
|
||||
key_ids: List[str] = field(default_factory=list) # Optional key IDs
|
||||
|
||||
@property
|
||||
def drm_system(self) -> Optional[DRMSystem]:
|
||||
"""Get the corresponding DRM system for this PSSH"""
|
||||
return DRMSystem.from_uuid(self.system_id)
|
||||
|
||||
def validate(self):
|
||||
"""Validate the PSSH data"""
|
||||
if not self.system_id:
|
||||
raise ValueError("system_id is required")
|
||||
|
||||
if not self.pssh_box:
|
||||
raise ValueError("pssh_box is required")
|
||||
|
||||
try:
|
||||
base64.b64decode(self.pssh_box)
|
||||
except Exception:
|
||||
raise ValueError("pssh_box must be valid base64")
|
||||
|
||||
# Validate key IDs if present
|
||||
for kid in self.key_ids:
|
||||
if not all(c in "0123456789abcdefABCDEF-" for c in kid):
|
||||
raise ValueError(f"Invalid key ID format: {kid}")
|
||||
|
||||
@dataclass
|
||||
class LicenseUnwrapperParams:
|
||||
path_data: Optional[str] = None
|
||||
path_data_traverse: bool = False
|
||||
path_hdcp_res: Optional[str] = None
|
||||
path_hdcp_res_traverse: bool = False
|
||||
path_hdcp_ver: Optional[str] = None
|
||||
path_hdcp_ver_traverse: bool = False
|
||||
|
||||
@dataclass
|
||||
class LicenseConfig:
|
||||
server_url: Optional[str] = None
|
||||
server_certificate: Optional[str] = None
|
||||
use_http_get_request: bool = False
|
||||
req_headers: Optional[str] = None
|
||||
req_params: Optional[str] = None
|
||||
req_data: Optional[str] = None
|
||||
wrapper: Optional[str] = None
|
||||
unwrapper: Optional[str] = None
|
||||
unwrapper_params: Optional[LicenseUnwrapperParams] = None
|
||||
keyids: Dict[str, str] = field(default_factory=dict) # For ClearKey
|
||||
|
||||
def validate(self):
|
||||
"""Validate the license configuration"""
|
||||
if self.server_certificate:
|
||||
try:
|
||||
base64.b64decode(self.server_certificate)
|
||||
except Exception:
|
||||
raise ValueError("server_certificate must be valid base64")
|
||||
|
||||
if self.req_data:
|
||||
try:
|
||||
base64.b64decode(self.req_data)
|
||||
except Exception:
|
||||
raise ValueError("req_data must be valid base64")
|
||||
|
||||
if self.keyids:
|
||||
for kid, key in self.keyids.items():
|
||||
if not all(c in "0123456789abcdefABCDEF" for c in kid):
|
||||
raise ValueError(f"Invalid KID format: {kid}")
|
||||
if not all(c in "0123456789abcdefABCDEF" for c in key):
|
||||
raise ValueError(f"Invalid KEY format: {key}")
|
||||
|
||||
@dataclass
|
||||
class DRMConfig:
|
||||
system: DRMSystem
|
||||
priority: int = 0
|
||||
license: Optional[LicenseConfig] = None
|
||||
|
||||
def validate(self):
|
||||
"""Validate the DRM configuration"""
|
||||
if self.license:
|
||||
self.license.validate()
|
||||
|
||||
def to_dict(self) -> Dict:
|
||||
"""Convert to dictionary format expected by players"""
|
||||
result = {
|
||||
str(self.system.value): { # Use .value to get the actual string
|
||||
"priority": self.priority
|
||||
}
|
||||
}
|
||||
|
||||
if self.license:
|
||||
license_dict = {}
|
||||
if self.license.server_url:
|
||||
license_dict["server_url"] = self.license.server_url
|
||||
if self.license.server_certificate:
|
||||
license_dict["server_certificate"] = self.license.server_certificate
|
||||
if self.license.use_http_get_request:
|
||||
license_dict["use_http_get_request"] = self.license.use_http_get_request
|
||||
if self.license.req_headers:
|
||||
license_dict["req_headers"] = self.license.req_headers
|
||||
if self.license.req_params:
|
||||
license_dict["req_params"] = self.license.req_params
|
||||
if self.license.req_data:
|
||||
license_dict["req_data"] = self.license.req_data
|
||||
if self.license.wrapper:
|
||||
license_dict["wrapper"] = self.license.wrapper
|
||||
if self.license.unwrapper:
|
||||
license_dict["unwrapper"] = self.license.unwrapper
|
||||
if self.license.unwrapper_params:
|
||||
license_dict["unwrapper_params"] = {
|
||||
k: v for k, v in vars(self.license.unwrapper_params).items()
|
||||
if v is not None
|
||||
}
|
||||
if self.license.keyids:
|
||||
license_dict["keyids"] = self.license.keyids
|
||||
|
||||
if license_dict:
|
||||
result[str(self.system.value)]["license"] = license_dict
|
||||
|
||||
return result
|
||||
@@ -0,0 +1,280 @@
|
||||
# streaming_providers/base/models/proxy_models.py
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Dict, Optional, Any
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class ProxyType(Enum):
|
||||
"""Supported proxy types"""
|
||||
HTTP = "http"
|
||||
HTTPS = "https"
|
||||
SOCKS4 = "socks4"
|
||||
SOCKS5 = "socks5"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProxyScope:
|
||||
"""
|
||||
Define which network operations should use proxy
|
||||
Allows granular control per provider
|
||||
"""
|
||||
api_calls: bool = True # GraphQL, REST API calls
|
||||
authentication: bool = True # Auth endpoints
|
||||
manifests: bool = True # Manifest/playlist downloads
|
||||
license: bool = True # DRM license requests
|
||||
all: bool = True # Master switch - overrides all others
|
||||
|
||||
def should_use_proxy_for(self, operation: str) -> bool:
|
||||
"""Check if proxy should be used for specific operation"""
|
||||
if not self.all:
|
||||
return False
|
||||
|
||||
operation_map = {
|
||||
'api': self.api_calls,
|
||||
'auth': self.authentication,
|
||||
'manifest': self.manifests,
|
||||
'license': self.license
|
||||
}
|
||||
|
||||
return operation_map.get(operation, True)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProxyAuth:
|
||||
"""Proxy authentication details"""
|
||||
username: str
|
||||
password: str
|
||||
|
||||
def to_auth_string(self) -> str:
|
||||
"""Convert to authentication string for proxy URL"""
|
||||
return f"{self.username}:{self.password}"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProxyConfig:
|
||||
"""
|
||||
Comprehensive proxy configuration
|
||||
Supports different proxy types and authentication
|
||||
"""
|
||||
# Basic proxy settings
|
||||
host: str
|
||||
port: int
|
||||
proxy_type: ProxyType = ProxyType.HTTP
|
||||
|
||||
# Authentication (optional)
|
||||
auth: Optional[ProxyAuth] = None
|
||||
|
||||
# Scope control
|
||||
scope: ProxyScope = field(default_factory=ProxyScope)
|
||||
|
||||
# Advanced settings
|
||||
timeout: int = 30
|
||||
verify_ssl: bool = True
|
||||
|
||||
# Provider-specific overrides
|
||||
provider_specific: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def to_proxy_dict(self) -> Dict[str, str]:
|
||||
"""
|
||||
Convert to requests-compatible proxy dictionary
|
||||
|
||||
Returns:
|
||||
Dict in format {'http': 'proxy_url', 'https': 'proxy_url'}
|
||||
"""
|
||||
# Build proxy URL
|
||||
auth_part = ""
|
||||
if self.auth:
|
||||
auth_part = f"{self.auth.to_auth_string()}@"
|
||||
|
||||
proxy_url = f"{self.proxy_type.value}://{auth_part}{self.host}:{self.port}"
|
||||
|
||||
# Return both HTTP and HTTPS proxy settings
|
||||
return {
|
||||
'http': proxy_url,
|
||||
'https': proxy_url
|
||||
}
|
||||
|
||||
def to_proxy_url(self) -> str:
|
||||
"""Get single proxy URL string"""
|
||||
auth_part = ""
|
||||
if self.auth:
|
||||
auth_part = f"{self.auth.to_auth_string()}@"
|
||||
|
||||
return f"{self.proxy_type.value}://{auth_part}{self.host}:{self.port}"
|
||||
|
||||
def validate(self) -> bool:
|
||||
"""Validate proxy configuration"""
|
||||
if not self.host or not self.port:
|
||||
return False
|
||||
if self.port < 1 or self.port > 65535:
|
||||
return False
|
||||
if self.timeout < 1:
|
||||
return False
|
||||
return True
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary for serialization"""
|
||||
result = {
|
||||
'host': self.host,
|
||||
'port': self.port,
|
||||
'proxy_type': self.proxy_type.value,
|
||||
'timeout': self.timeout,
|
||||
'verify_ssl': self.verify_ssl,
|
||||
'scope': {
|
||||
'api_calls': self.scope.api_calls,
|
||||
'authentication': self.scope.authentication,
|
||||
'manifests': self.scope.manifests,
|
||||
'license': self.scope.license,
|
||||
'all': self.scope.all
|
||||
},
|
||||
'provider_specific': self.provider_specific
|
||||
}
|
||||
|
||||
if self.auth:
|
||||
result['auth'] = {
|
||||
'username': self.auth.username,
|
||||
'password': self.auth.password
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'ProxyConfig':
|
||||
"""Create ProxyConfig from dictionary"""
|
||||
auth = None
|
||||
if 'auth' in data and data['auth']:
|
||||
auth = ProxyAuth(
|
||||
username=data['auth']['username'],
|
||||
password=data['auth']['password']
|
||||
)
|
||||
|
||||
scope_data = data.get('scope', {})
|
||||
scope = ProxyScope(
|
||||
api_calls=scope_data.get('api_calls', True),
|
||||
authentication=scope_data.get('authentication', True),
|
||||
manifests=scope_data.get('manifests', True),
|
||||
license=scope_data.get('license', True),
|
||||
all=scope_data.get('all', True)
|
||||
)
|
||||
|
||||
return cls(
|
||||
host=data['host'],
|
||||
port=data['port'],
|
||||
proxy_type=ProxyType(data.get('proxy_type', 'http')),
|
||||
auth=auth,
|
||||
scope=scope,
|
||||
timeout=data.get('timeout', 30),
|
||||
verify_ssl=data.get('verify_ssl', True),
|
||||
provider_specific=data.get('provider_specific', {})
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_url(cls, proxy_url: str, scope: Optional[ProxyScope] = None) -> 'ProxyConfig':
|
||||
"""
|
||||
Create ProxyConfig from proxy URL string
|
||||
|
||||
Args:
|
||||
proxy_url: Proxy URL like "http://user:pass@proxy.example.com:8080"
|
||||
scope: Optional scope configuration
|
||||
|
||||
Returns:
|
||||
ProxyConfig instance
|
||||
"""
|
||||
import urllib.parse
|
||||
|
||||
parsed = urllib.parse.urlparse(proxy_url)
|
||||
|
||||
if not parsed.hostname or not parsed.port:
|
||||
raise ValueError(f"Invalid proxy URL: {proxy_url}")
|
||||
|
||||
auth = None
|
||||
if parsed.username and parsed.password:
|
||||
auth = ProxyAuth(
|
||||
username=parsed.username,
|
||||
password=parsed.password
|
||||
)
|
||||
|
||||
proxy_type = ProxyType.HTTP
|
||||
if parsed.scheme:
|
||||
try:
|
||||
proxy_type = ProxyType(parsed.scheme.lower())
|
||||
except ValueError:
|
||||
# Default to HTTP if scheme not recognized
|
||||
pass
|
||||
|
||||
return cls(
|
||||
host=parsed.hostname,
|
||||
port=parsed.port,
|
||||
proxy_type=proxy_type,
|
||||
auth=auth,
|
||||
scope=scope or ProxyScope()
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class RequestConfig:
|
||||
"""
|
||||
Configuration for HTTP requests including proxy settings
|
||||
Used by HTTPManager for consistent request handling
|
||||
"""
|
||||
# Proxy settings
|
||||
proxy_config: Optional[ProxyConfig] = None
|
||||
|
||||
# Request settings
|
||||
timeout: int = 30
|
||||
verify_ssl: bool = True
|
||||
max_retries: int = 3
|
||||
retry_delay: float = 1.0
|
||||
|
||||
# Headers
|
||||
default_headers: Dict[str, str] = field(default_factory=dict)
|
||||
user_agent: str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36"
|
||||
|
||||
# Provider-specific settings
|
||||
provider: str = ""
|
||||
|
||||
def get_request_kwargs(self, operation: str = "api") -> Dict[str, Any]:
|
||||
"""
|
||||
Get kwargs for requests library call
|
||||
|
||||
Args:
|
||||
operation: Type of operation (api, auth, manifest, license)
|
||||
|
||||
Returns:
|
||||
Dictionary of kwargs for requests
|
||||
"""
|
||||
kwargs = {
|
||||
'timeout': self.timeout,
|
||||
'verify': self.verify_ssl,
|
||||
'headers': self._get_headers()
|
||||
}
|
||||
|
||||
# Add proxy if configured and enabled for this operation
|
||||
if self.proxy_config and self.proxy_config.scope.should_use_proxy_for(operation):
|
||||
kwargs['proxies'] = self.proxy_config.to_proxy_dict()
|
||||
|
||||
return kwargs
|
||||
|
||||
def _get_headers(self) -> Dict[str, str]:
|
||||
"""Build headers with user agent"""
|
||||
headers = self.default_headers.copy()
|
||||
if 'User-Agent' not in headers:
|
||||
headers['User-Agent'] = self.user_agent
|
||||
return headers
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary"""
|
||||
result = {
|
||||
'timeout': self.timeout,
|
||||
'verify_ssl': self.verify_ssl,
|
||||
'max_retries': self.max_retries,
|
||||
'retry_delay': self.retry_delay,
|
||||
'default_headers': self.default_headers,
|
||||
'user_agent': self.user_agent,
|
||||
'provider': self.provider
|
||||
}
|
||||
|
||||
if self.proxy_config:
|
||||
result['proxy_config'] = self.proxy_config.to_dict()
|
||||
|
||||
return result
|
||||
@@ -0,0 +1,98 @@
|
||||
# streaming_providers/base/models.py
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict, Optional
|
||||
from .drm_models import DRMConfig
|
||||
|
||||
@dataclass
|
||||
class StreamingChannel:
|
||||
"""
|
||||
Universal channel representation for all providers
|
||||
"""
|
||||
# Core identification
|
||||
name: str
|
||||
channel_id: str
|
||||
provider: str # 'joyn', 'zdf', 'ard', etc.
|
||||
|
||||
# Visual elements
|
||||
logo_url: Optional[str] = None
|
||||
|
||||
# Streaming configuration
|
||||
mode: str = "live" # "live" or "vod"
|
||||
session_manifest: bool = False
|
||||
manifest: Optional[str] = None
|
||||
manifest_script: Optional[str] = None
|
||||
|
||||
# DRM/CDM settings
|
||||
cdm_type: Optional[str] = None
|
||||
use_cdm: bool = True
|
||||
cdm: Optional[str] = None
|
||||
cdm_mode: str = 'external'
|
||||
|
||||
# DRM Configuration
|
||||
drm_config: Optional[DRMConfig] = None
|
||||
|
||||
# Video settings
|
||||
video: str = 'best'
|
||||
on_demand: bool = True
|
||||
speed_up: bool = True
|
||||
|
||||
# Additional metadata
|
||||
content_type: str = 'LIVE'
|
||||
description: Optional[str] = None
|
||||
genre: Optional[str] = None
|
||||
language: str = 'de'
|
||||
country: str = 'DE'
|
||||
|
||||
# Streaming URLs
|
||||
license_url: Optional[str] = None
|
||||
certificate_url: Optional[str] = None
|
||||
streaming_format: Optional[str] = None
|
||||
|
||||
def to_dict(self) -> Dict:
|
||||
"""Convert to dictionary format"""
|
||||
result = {
|
||||
'Name': self.name,
|
||||
'Id': self.channel_id,
|
||||
'Provider': self.provider,
|
||||
'LogoUrl': self.logo_url,
|
||||
'Mode': self.mode,
|
||||
'SessionManifest': self.session_manifest,
|
||||
'Manifest': self.manifest,
|
||||
'ManifestScript': self.manifest_script,
|
||||
'CdmType': self.cdm_type,
|
||||
'UseCdm': self.use_cdm,
|
||||
'Cdm': self.cdm,
|
||||
'CdmMode': self.cdm_mode,
|
||||
'Video': self.video,
|
||||
'OnDemand': self.on_demand,
|
||||
'SpeedUp': self.speed_up,
|
||||
'ContentType': self.content_type,
|
||||
'Country': self.country,
|
||||
'Language': self.language,
|
||||
'StreamingFormat': self.streaming_format
|
||||
}
|
||||
|
||||
# Add DRM config if present
|
||||
if self.drm_config:
|
||||
result['DrmConfig'] = self.drm_config.to_dict()
|
||||
|
||||
return result
|
||||
|
||||
def set_static_manifest(self, manifest_url: str) -> None:
|
||||
"""
|
||||
Set a static manifest URL (scenario 1: provider gives manifest directly)
|
||||
"""
|
||||
self.manifest = manifest_url
|
||||
self.session_manifest = False
|
||||
self.manifest_script = None
|
||||
|
||||
def set_dynamic_manifest(self, manifest_script_params: str) -> None:
|
||||
"""
|
||||
Set dynamic manifest parameters (scenario 3: manifest needs to be fetched at request time)
|
||||
|
||||
Args:
|
||||
manifest_script_params: Parameters needed to fetch manifest (e.g., channel_id, api_endpoint)
|
||||
"""
|
||||
self.manifest = None
|
||||
self.session_manifest = True
|
||||
self.manifest_script = manifest_script_params
|
||||
@@ -0,0 +1,10 @@
|
||||
# streaming_providers/base/network/__init__.py
|
||||
from .http_manager import HTTPManager, HTTPManagerFactory
|
||||
from .proxy_manager import ProxyConfigManager
|
||||
|
||||
# Only export what consumers should use
|
||||
__all__ = [
|
||||
'HTTPManager',
|
||||
'HTTPManagerFactory',
|
||||
'ProxyConfigManager'
|
||||
]
|
||||
@@ -0,0 +1,353 @@
|
||||
# streaming_providers/base/network/http_manager.py
|
||||
import requests
|
||||
import time
|
||||
import json
|
||||
from typing import Dict, Any, Optional
|
||||
from requests.adapters import HTTPAdapter
|
||||
from urllib3.util.retry import Retry
|
||||
|
||||
from ..models.proxy_models import RequestConfig, ProxyConfig
|
||||
from ..utils.logger import logger
|
||||
|
||||
|
||||
class HTTPManager:
|
||||
"""
|
||||
Centralized HTTP request manager with proxy support
|
||||
|
||||
Handles all HTTP requests for streaming providers with:
|
||||
- Proxy configuration per operation type
|
||||
- Retry logic
|
||||
- Error handling
|
||||
- Request/response logging
|
||||
- Provider-specific configurations
|
||||
"""
|
||||
|
||||
def __init__(self, config: Optional[RequestConfig] = None):
|
||||
"""
|
||||
Initialize HTTP manager
|
||||
|
||||
Args:
|
||||
config: Request configuration including proxy settings
|
||||
"""
|
||||
self.config = config or RequestConfig()
|
||||
self._session = None
|
||||
self._setup_session()
|
||||
|
||||
def _setup_session(self) -> None:
|
||||
"""Setup requests session with retry strategy"""
|
||||
self._session = requests.Session()
|
||||
|
||||
# Setup retry strategy
|
||||
retry_strategy = Retry(
|
||||
total=self.config.max_retries,
|
||||
backoff_factor=self.config.retry_delay,
|
||||
status_forcelist=[429, 500, 502, 503, 504],
|
||||
allowed_methods=["HEAD", "GET", "OPTIONS", "POST", "PUT", "DELETE"]
|
||||
)
|
||||
|
||||
adapter = HTTPAdapter(max_retries=retry_strategy)
|
||||
self._session.mount("http://", adapter)
|
||||
self._session.mount("https://", adapter)
|
||||
|
||||
def update_config(self, config: RequestConfig) -> None:
|
||||
"""
|
||||
Update request configuration
|
||||
|
||||
Args:
|
||||
config: New request configuration
|
||||
"""
|
||||
self.config = config
|
||||
self._setup_session()
|
||||
|
||||
def update_proxy(self, proxy_config: Optional[ProxyConfig]) -> None:
|
||||
"""
|
||||
Update just the proxy configuration
|
||||
|
||||
Args:
|
||||
proxy_config: New proxy configuration (None to disable proxy)
|
||||
"""
|
||||
self.config.proxy_config = proxy_config
|
||||
|
||||
def get(self, url: str, operation: str = "api", **kwargs) -> requests.Response:
|
||||
"""
|
||||
Perform GET request with proxy support
|
||||
|
||||
Args:
|
||||
url: Request URL
|
||||
operation: Operation type (api, auth, manifest, license) for proxy scoping
|
||||
**kwargs: Additional arguments for requests
|
||||
|
||||
Returns:
|
||||
requests.Response object
|
||||
|
||||
Raises:
|
||||
requests.exceptions.RequestException: On request failure
|
||||
"""
|
||||
return self._make_request("GET", url, operation, **kwargs)
|
||||
|
||||
def post(self, url: str, operation: str = "api", data: Any = None,
|
||||
json_data: Any = None, **kwargs) -> requests.Response:
|
||||
"""
|
||||
Perform POST request with proxy support
|
||||
|
||||
Args:
|
||||
url: Request URL
|
||||
operation: Operation type for proxy scoping
|
||||
data: Request data (form data or raw)
|
||||
json_data: JSON data to send
|
||||
**kwargs: Additional arguments for requests
|
||||
|
||||
Returns:
|
||||
requests.Response object
|
||||
"""
|
||||
if json_data is not None:
|
||||
kwargs['json'] = json_data
|
||||
elif data is not None:
|
||||
kwargs['data'] = data
|
||||
|
||||
return self._make_request("POST", url, operation, **kwargs)
|
||||
|
||||
def put(self, url: str, operation: str = "api", **kwargs) -> requests.Response:
|
||||
"""Perform PUT request with proxy support"""
|
||||
return self._make_request("PUT", url, operation, **kwargs)
|
||||
|
||||
def delete(self, url: str, operation: str = "api", **kwargs) -> requests.Response:
|
||||
"""Perform DELETE request with proxy support"""
|
||||
return self._make_request("DELETE", url, operation, **kwargs)
|
||||
|
||||
def _make_request(self, method: str, url: str, operation: str, **kwargs) -> requests.Response:
|
||||
"""
|
||||
Make HTTP request with full configuration support
|
||||
"""
|
||||
# Get base request configuration
|
||||
request_kwargs = self.config.get_request_kwargs(operation)
|
||||
|
||||
# Merge with any additional kwargs (allows overrides)
|
||||
request_kwargs.update(kwargs)
|
||||
|
||||
# Log request details (excluding sensitive data)
|
||||
self._log_request(method, url, operation, request_kwargs)
|
||||
|
||||
try:
|
||||
# Make the request
|
||||
response = self._session.request(method, url, **request_kwargs)
|
||||
|
||||
# Log response
|
||||
self._log_response(response)
|
||||
|
||||
# Check for HTTP errors (will raise for 4xx/5xx)
|
||||
response.raise_for_status()
|
||||
|
||||
return response
|
||||
|
||||
except requests.exceptions.ProxyError as e:
|
||||
logger.error(
|
||||
f"{self.config.provider}: Proxy error for {operation} request to {url}: {e}"
|
||||
)
|
||||
raise
|
||||
|
||||
except requests.exceptions.Timeout as e:
|
||||
logger.error(
|
||||
f"{self.config.provider}: Timeout ({request_kwargs.get('timeout', 'unknown')}s) "
|
||||
f"for {operation} request to {url}: {e}"
|
||||
)
|
||||
raise
|
||||
|
||||
except requests.exceptions.ConnectionError as e:
|
||||
logger.error(
|
||||
f"{self.config.provider}: Connection error for {operation} request to {url}: {e}"
|
||||
)
|
||||
raise
|
||||
|
||||
except requests.exceptions.HTTPError as e:
|
||||
# Additional context for HTTP errors
|
||||
status = e.response.status_code if e.response else 'unknown'
|
||||
logger.error(
|
||||
f"{self.config.provider}: HTTP {status} error for {operation} request to {url}: {e}"
|
||||
)
|
||||
raise
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error(
|
||||
f"{self.config.provider}: Request error for {operation} request to {url}: {e}"
|
||||
)
|
||||
raise
|
||||
|
||||
def _log_request(self, method: str, url: str, operation: str, kwargs: Dict[str, Any]) -> None:
|
||||
"""Log request details with comprehensive proxy information"""
|
||||
|
||||
# Build proxy information string
|
||||
proxy_info = ""
|
||||
if self.config.proxy_config:
|
||||
if self.config.proxy_config.scope.should_use_proxy_for(operation):
|
||||
# Proxy is configured and will be used
|
||||
proxy_host = f"{self.config.proxy_config.host}:{self.config.proxy_config.port}"
|
||||
proxy_type = self.config.proxy_config.proxy_type.value
|
||||
has_auth = "authenticated" if self.config.proxy_config.auth else "no-auth"
|
||||
proxy_info = f" [proxy: {proxy_type}://{proxy_host} ({has_auth})]"
|
||||
else:
|
||||
# Proxy is configured but not used for this operation
|
||||
proxy_info = f" [proxy: disabled for operation '{operation}']"
|
||||
else:
|
||||
# No proxy configured
|
||||
proxy_info = " [proxy: none]"
|
||||
|
||||
# Get timeout info
|
||||
timeout = kwargs.get('timeout', self.config.timeout)
|
||||
|
||||
# Truncate URL for readability if very long
|
||||
display_url = url if len(url) <= 100 else f"{url[:80]}...{url[-17:]}"
|
||||
|
||||
logger.debug(
|
||||
f"{self.config.provider}: {method} {operation} -> {display_url}"
|
||||
f"{proxy_info} [timeout: {timeout}s]"
|
||||
)
|
||||
|
||||
def _log_response(self, response: requests.Response) -> None:
|
||||
"""Log response details with timing information"""
|
||||
|
||||
# Get response time if available
|
||||
elapsed = ""
|
||||
if hasattr(response, 'elapsed'):
|
||||
elapsed_ms = int(response.elapsed.total_seconds() * 1000)
|
||||
elapsed = f" [{elapsed_ms}ms]"
|
||||
|
||||
# Content type for context
|
||||
content_type = response.headers.get('Content-Type', 'unknown')
|
||||
|
||||
# Size info
|
||||
size = len(response.content)
|
||||
size_display = f"{size} bytes"
|
||||
if size > 1024 * 1024: # > 1MB
|
||||
size_display = f"{size / (1024 * 1024):.2f} MB"
|
||||
elif size > 1024: # > 1KB
|
||||
size_display = f"{size / 1024:.2f} KB"
|
||||
|
||||
logger.debug(
|
||||
f"{self.config.provider}: Response {response.status_code} "
|
||||
f"({size_display}, {content_type}){elapsed}"
|
||||
)
|
||||
|
||||
def test_connection(self, test_url: str = "https://httpbin.org/ip",
|
||||
operation: str = "api") -> Dict[str, Any]:
|
||||
"""
|
||||
Test network connection and proxy configuration
|
||||
|
||||
Args:
|
||||
test_url: URL to test connection with
|
||||
operation: Operation type for proxy scoping
|
||||
|
||||
Returns:
|
||||
Dictionary with test results
|
||||
"""
|
||||
result = {
|
||||
'success': False,
|
||||
'proxy_used': False,
|
||||
'response_time': 0.0,
|
||||
'error': None,
|
||||
'ip_info': None
|
||||
}
|
||||
|
||||
try:
|
||||
start_time = time.time()
|
||||
response = self.get(test_url, operation=operation)
|
||||
end_time = time.time()
|
||||
|
||||
result['success'] = True
|
||||
result['response_time'] = end_time - start_time
|
||||
result['proxy_used'] = bool(
|
||||
self.config.proxy_config and
|
||||
self.config.proxy_config.scope.should_use_proxy_for(operation)
|
||||
)
|
||||
|
||||
# Try to parse IP info if using httpbin
|
||||
try:
|
||||
result['ip_info'] = response.json()
|
||||
except (json.JSONDecodeError, AttributeError):
|
||||
result['ip_info'] = {'response': response.text[:100]}
|
||||
|
||||
except Exception as e:
|
||||
result['error'] = str(e)
|
||||
logger.error(f"Connection test failed: {e}")
|
||||
|
||||
return result
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close the session"""
|
||||
if self._session:
|
||||
self._session.close()
|
||||
|
||||
def __enter__(self):
|
||||
"""Context manager entry"""
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
"""Context manager exit"""
|
||||
self.close()
|
||||
|
||||
|
||||
class HTTPManagerFactory:
|
||||
"""
|
||||
Factory for creating HTTP managers with provider-specific configurations
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def create_for_provider(provider_name: str,
|
||||
proxy_config: Optional[ProxyConfig] = None,
|
||||
**config_kwargs) -> HTTPManager:
|
||||
"""
|
||||
Create HTTP manager configured for specific provider
|
||||
|
||||
Args:
|
||||
provider_name: Name of the provider
|
||||
proxy_config: Proxy configuration
|
||||
**config_kwargs: Additional RequestConfig parameters
|
||||
|
||||
Returns:
|
||||
Configured HTTPManager instance
|
||||
"""
|
||||
# Provider-specific defaults
|
||||
provider_defaults = {
|
||||
'joyn': {
|
||||
'user_agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36',
|
||||
'timeout': 30,
|
||||
'max_retries': 3
|
||||
},
|
||||
'zdf': {
|
||||
'user_agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
||||
'timeout': 25,
|
||||
'max_retries': 2
|
||||
},
|
||||
# Add more providers as needed
|
||||
}
|
||||
|
||||
# Get provider-specific defaults
|
||||
defaults = provider_defaults.get(provider_name, {})
|
||||
defaults.update(config_kwargs)
|
||||
defaults['provider'] = provider_name
|
||||
|
||||
# Create request config
|
||||
config = RequestConfig(
|
||||
proxy_config=proxy_config,
|
||||
**defaults
|
||||
)
|
||||
|
||||
return HTTPManager(config)
|
||||
|
||||
@staticmethod
|
||||
def create_with_proxy_url(provider_name: str, proxy_url: str, **kwargs) -> HTTPManager:
|
||||
"""
|
||||
Create HTTP manager with proxy from URL string
|
||||
|
||||
Args:
|
||||
provider_name: Name of the provider
|
||||
proxy_url: Proxy URL (e.g., "http://proxy.example.com:8080")
|
||||
**kwargs: Additional configuration
|
||||
|
||||
Returns:
|
||||
Configured HTTPManager instance
|
||||
"""
|
||||
proxy_config = ProxyConfig.from_url(proxy_url)
|
||||
return HTTPManagerFactory.create_for_provider(
|
||||
provider_name, proxy_config, **kwargs
|
||||
)
|
||||
@@ -0,0 +1,558 @@
|
||||
# streaming_providers/base/network/proxy_manager.py
|
||||
import json
|
||||
import time
|
||||
from typing import Dict, Optional, List, Any
|
||||
from pathlib import Path
|
||||
|
||||
from ..models.proxy_models import ProxyConfig, ProxyScope
|
||||
from ..utils.logger import logger
|
||||
|
||||
|
||||
class ProxyConfigManager:
|
||||
"""
|
||||
Manages proxy configurations with file-based persistence
|
||||
Supports per-provider proxy settings with global fallbacks
|
||||
Now supports country-specific proxy configurations
|
||||
"""
|
||||
|
||||
def __init__(self, config_dir: Optional[str] = None):
|
||||
"""
|
||||
Initialize proxy configuration manager with VFS support
|
||||
"""
|
||||
# Initialize VFS with config directory support
|
||||
from ..utils.vfs import VFS
|
||||
self.vfs = VFS(config_dir=config_dir)
|
||||
|
||||
# Keep legacy attributes for backward compatibility but use VFS primarily
|
||||
if config_dir:
|
||||
self.config_dir = Path(config_dir)
|
||||
else:
|
||||
self.config_dir = Path.home() / '.streaming_providers' / 'config'
|
||||
|
||||
self.proxy_config_file = 'proxy_config.json' # Use relative path for VFS
|
||||
|
||||
# Cache for loaded configurations
|
||||
self._config_cache: Dict[str, ProxyConfig] = {}
|
||||
self._global_config: Optional[ProxyConfig] = None
|
||||
|
||||
# Load existing configurations
|
||||
self._load_configurations()
|
||||
|
||||
@staticmethod
|
||||
def _get_proxy_path(provider_name: str, country: Optional[str] = None) -> tuple:
|
||||
"""
|
||||
Determine the path to proxy data based on country
|
||||
|
||||
Args:
|
||||
provider_name: Provider name
|
||||
country: Optional country code (e.g., 'de', 'at', 'ch')
|
||||
|
||||
Returns:
|
||||
Tuple of (cache_key, is_nested)
|
||||
"""
|
||||
if country:
|
||||
return f"{provider_name}_{country}", True
|
||||
else:
|
||||
return provider_name, False
|
||||
|
||||
def _load_configurations(self) -> None:
|
||||
"""Load proxy configurations from file using VFS"""
|
||||
try:
|
||||
vfs_data = self.vfs.read_json(self.proxy_config_file)
|
||||
|
||||
if vfs_data is None:
|
||||
logger.debug("No proxy configuration file found, starting with empty config")
|
||||
return
|
||||
|
||||
# Load global configuration
|
||||
if 'global' in vfs_data:
|
||||
self._global_config = ProxyConfig.from_dict(vfs_data['global'])
|
||||
logger.debug("Loaded global proxy configuration")
|
||||
|
||||
# Load provider-specific configurations
|
||||
providers = vfs_data.get('providers', {})
|
||||
for provider_name, provider_data in providers.items():
|
||||
try:
|
||||
# Check if this is a nested (country-aware) structure
|
||||
if isinstance(provider_data, dict) and any(
|
||||
isinstance(v, dict) and len(k) <= 3
|
||||
for k, v in provider_data.items()
|
||||
):
|
||||
# Country-aware structure
|
||||
for country, config_data in provider_data.items():
|
||||
if isinstance(config_data, dict) and len(country) <= 3:
|
||||
cache_key = f"{provider_name}_{country}"
|
||||
self._config_cache[cache_key] = ProxyConfig.from_dict(config_data)
|
||||
logger.debug(f"Loaded proxy config for {provider_name} ({country})")
|
||||
else:
|
||||
# Flat structure (no country)
|
||||
self._config_cache[provider_name] = ProxyConfig.from_dict(provider_data)
|
||||
logger.debug(f"Loaded proxy configuration for provider: {provider_name}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading proxy config for {provider_name}: {e}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading proxy configurations: {e}")
|
||||
|
||||
def _save_configurations(self) -> bool:
|
||||
"""Save proxy configurations to file using VFS"""
|
||||
try:
|
||||
data: Dict[str, Any] = {
|
||||
'providers': {},
|
||||
'metadata': {
|
||||
'version': '1.1', # Bumped for country support
|
||||
'description': 'Streaming provider proxy configurations'
|
||||
}
|
||||
}
|
||||
|
||||
# Save global configuration
|
||||
if self._global_config:
|
||||
data['global'] = self._global_config.to_dict()
|
||||
|
||||
# Save provider-specific configurations
|
||||
# Group by provider to maintain nested structure
|
||||
provider_configs: Dict[str, Dict] = {}
|
||||
|
||||
for cache_key, config in self._config_cache.items():
|
||||
if '_' in cache_key and len(cache_key.split('_')[-1]) <= 3:
|
||||
# Country-aware key (e.g., "joyn_de")
|
||||
parts = cache_key.rsplit('_', 1)
|
||||
provider_name = parts[0]
|
||||
country = parts[1]
|
||||
|
||||
if provider_name not in provider_configs:
|
||||
provider_configs[provider_name] = {}
|
||||
provider_configs[provider_name][country] = config.to_dict()
|
||||
else:
|
||||
# Non-country key
|
||||
provider_configs[cache_key] = config.to_dict()
|
||||
|
||||
data['providers'] = provider_configs
|
||||
|
||||
# Save using VFS
|
||||
success = self.vfs.write_json(self.proxy_config_file, data)
|
||||
|
||||
if success:
|
||||
logger.debug("Saved proxy configurations")
|
||||
else:
|
||||
logger.error("Failed to save proxy configurations")
|
||||
|
||||
return success
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error saving proxy configurations: {e}")
|
||||
return False
|
||||
|
||||
def get_proxy_config(self, provider_name: str, country: Optional[str] = None) -> Optional[ProxyConfig]:
|
||||
"""
|
||||
Get proxy configuration for a provider and optional country
|
||||
|
||||
Args:
|
||||
provider_name: Provider name
|
||||
country: Optional country code
|
||||
|
||||
Returns:
|
||||
ProxyConfig or None
|
||||
"""
|
||||
cache_key, _ = self._get_proxy_path(provider_name, country)
|
||||
|
||||
# Check provider-specific config first
|
||||
if cache_key in self._config_cache:
|
||||
config = self._config_cache[cache_key]
|
||||
country_str = f" ({country})" if country else ""
|
||||
logger.debug(
|
||||
f"Using provider-specific proxy for {provider_name}{country_str}: "
|
||||
f"{config.proxy_type.value}://{config.host}:{config.port}"
|
||||
)
|
||||
return config
|
||||
|
||||
# If country specified but not found, try without country
|
||||
if country and provider_name in self._config_cache:
|
||||
config = self._config_cache[provider_name]
|
||||
logger.debug(
|
||||
f"Using non-country proxy for {provider_name} ({country}): "
|
||||
f"{config.proxy_type.value}://{config.host}:{config.port}"
|
||||
)
|
||||
return config
|
||||
|
||||
# Fall back to global config
|
||||
if self._global_config:
|
||||
country_str = f" ({country})" if country else ""
|
||||
logger.debug(
|
||||
f"Using global proxy for {provider_name}{country_str}: "
|
||||
f"{self._global_config.proxy_type.value}://{self._global_config.host}:{self._global_config.port}"
|
||||
)
|
||||
return self._global_config
|
||||
|
||||
country_str = f" ({country})" if country else ""
|
||||
logger.debug(f"No proxy configuration found for {provider_name}{country_str}")
|
||||
return None
|
||||
|
||||
def set_proxy_config(self, provider_name: str, proxy_config: ProxyConfig,
|
||||
country: Optional[str] = None) -> bool:
|
||||
"""
|
||||
Set proxy configuration for a provider
|
||||
|
||||
Args:
|
||||
provider_name: Name of the provider (use 'global' for global config)
|
||||
proxy_config: Proxy configuration to set
|
||||
country: Optional country code
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
if not proxy_config.validate():
|
||||
logger.error(f"Invalid proxy configuration for {provider_name}")
|
||||
return False
|
||||
|
||||
try:
|
||||
if provider_name == 'global':
|
||||
self._global_config = proxy_config
|
||||
logger.info("Set global proxy configuration")
|
||||
else:
|
||||
cache_key, _ = self._get_proxy_path(provider_name, country)
|
||||
self._config_cache[cache_key] = proxy_config
|
||||
country_str = f" ({country})" if country else ""
|
||||
logger.info(f"Set proxy configuration for provider: {provider_name}{country_str}")
|
||||
|
||||
return self._save_configurations()
|
||||
|
||||
except Exception as e:
|
||||
country_str = f" ({country})" if country else ""
|
||||
logger.error(f"Error setting proxy configuration for {provider_name}{country_str}: {e}")
|
||||
return False
|
||||
|
||||
def remove_proxy_config(self, provider_name: str, country: Optional[str] = None) -> bool:
|
||||
"""
|
||||
Remove proxy configuration for a provider
|
||||
|
||||
Args:
|
||||
provider_name: Name of the provider (use 'global' for global config)
|
||||
country: Optional country code (if None, removes all countries for provider)
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
if provider_name == 'global':
|
||||
self._global_config = None
|
||||
logger.info("Removed global proxy configuration")
|
||||
else:
|
||||
if country:
|
||||
# Remove specific country
|
||||
cache_key, _ = self._get_proxy_path(provider_name, country)
|
||||
if cache_key in self._config_cache:
|
||||
del self._config_cache[cache_key]
|
||||
logger.info(f"Removed proxy configuration for {provider_name} ({country})")
|
||||
else:
|
||||
logger.warning(f"No proxy configuration found for {provider_name} ({country})")
|
||||
return True
|
||||
else:
|
||||
# Remove all countries for provider
|
||||
keys_to_remove = [
|
||||
key for key in self._config_cache.keys()
|
||||
if key == provider_name or key.startswith(f"{provider_name}_")
|
||||
]
|
||||
|
||||
if keys_to_remove:
|
||||
for key in keys_to_remove:
|
||||
del self._config_cache[key]
|
||||
logger.info(f"Removed all proxy configurations for {provider_name}")
|
||||
else:
|
||||
logger.warning(f"No proxy configuration found for {provider_name}")
|
||||
return True
|
||||
|
||||
return self._save_configurations()
|
||||
|
||||
except Exception as e:
|
||||
country_str = f" ({country})" if country else ""
|
||||
logger.error(f"Error removing proxy configuration for {provider_name}{country_str}: {e}")
|
||||
return False
|
||||
|
||||
def get_all_countries(self, provider_name: str) -> List[str]:
|
||||
"""
|
||||
Get all countries that have proxy configs for a provider
|
||||
|
||||
Args:
|
||||
provider_name: Provider name
|
||||
|
||||
Returns:
|
||||
List of country codes
|
||||
"""
|
||||
countries = []
|
||||
prefix = f"{provider_name}_"
|
||||
|
||||
for cache_key in self._config_cache.keys():
|
||||
if cache_key.startswith(prefix):
|
||||
country = cache_key[len(prefix):]
|
||||
if len(country) <= 3: # Validate it looks like a country code
|
||||
countries.append(country)
|
||||
|
||||
return countries
|
||||
|
||||
def list_proxy_configs(self) -> List[str]:
|
||||
"""
|
||||
Get list of providers with proxy configurations
|
||||
|
||||
Returns:
|
||||
List of provider names (including country-specific ones)
|
||||
"""
|
||||
providers = list(self._config_cache.keys())
|
||||
if self._global_config:
|
||||
providers.append('global')
|
||||
return providers
|
||||
|
||||
def test_proxy_config(self, provider_name: str, country: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""
|
||||
Test proxy configuration for a provider
|
||||
|
||||
Args:
|
||||
provider_name: Name of the provider
|
||||
country: Optional country code
|
||||
|
||||
Returns:
|
||||
Dictionary with test results
|
||||
"""
|
||||
proxy_config = self.get_proxy_config(provider_name, country)
|
||||
if not proxy_config:
|
||||
return {
|
||||
'success': False,
|
||||
'error': 'No proxy configuration found',
|
||||
'provider': provider_name,
|
||||
'country': country
|
||||
}
|
||||
|
||||
# Test basic connectivity through proxy
|
||||
from .http_manager import HTTPManager, RequestConfig
|
||||
|
||||
config = RequestConfig(
|
||||
proxy_config=proxy_config,
|
||||
provider=provider_name
|
||||
)
|
||||
|
||||
manager = HTTPManager(config)
|
||||
result = manager.test_connection()
|
||||
result['provider'] = provider_name
|
||||
result['country'] = country
|
||||
result['proxy_config'] = proxy_config.to_dict()
|
||||
|
||||
manager.close()
|
||||
return result
|
||||
|
||||
def get_proxy_info(self, provider_name: str, country: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""
|
||||
Get detailed information about proxy configuration
|
||||
|
||||
Args:
|
||||
provider_name: Name of the provider
|
||||
country: Optional country code
|
||||
|
||||
Returns:
|
||||
Dictionary with proxy information
|
||||
"""
|
||||
proxy_config = self.get_proxy_config(provider_name, country)
|
||||
cache_key, _ = self._get_proxy_path(provider_name, country)
|
||||
|
||||
info = {
|
||||
'provider': provider_name,
|
||||
'country': country,
|
||||
'has_proxy': proxy_config is not None,
|
||||
'config_source': None,
|
||||
'proxy_details': None
|
||||
}
|
||||
|
||||
if proxy_config:
|
||||
# Determine config source
|
||||
if cache_key in self._config_cache:
|
||||
info['config_source'] = 'provider_country_specific' if country else 'provider_specific'
|
||||
elif country and provider_name in self._config_cache:
|
||||
info['config_source'] = 'provider_fallback'
|
||||
elif self._global_config:
|
||||
info['config_source'] = 'global'
|
||||
|
||||
# Add proxy details (without sensitive auth info)
|
||||
info['proxy_details'] = {
|
||||
'host': proxy_config.host,
|
||||
'port': proxy_config.port,
|
||||
'proxy_type': proxy_config.proxy_type.value,
|
||||
'has_auth': proxy_config.auth is not None,
|
||||
'scope': proxy_config.scope.__dict__,
|
||||
'timeout': proxy_config.timeout,
|
||||
'verify_ssl': proxy_config.verify_ssl
|
||||
}
|
||||
|
||||
return info
|
||||
|
||||
def export_config(self, export_path: Optional[str] = None) -> str:
|
||||
"""
|
||||
Export proxy configurations to a file
|
||||
|
||||
Args:
|
||||
export_path: Optional path to export to, defaults to backup file
|
||||
|
||||
Returns:
|
||||
Path to exported file
|
||||
"""
|
||||
if not export_path:
|
||||
export_path = str(self.config_dir / f'proxy_config_backup_{int(time.time())}.json')
|
||||
|
||||
try:
|
||||
# Create export data with country-aware structure
|
||||
providers_export = {}
|
||||
|
||||
for cache_key, config in self._config_cache.items():
|
||||
if '_' in cache_key and len(cache_key.split('_')[-1]) <= 3:
|
||||
# Country-aware key
|
||||
parts = cache_key.rsplit('_', 1)
|
||||
provider_name = parts[0]
|
||||
country = parts[1]
|
||||
|
||||
if provider_name not in providers_export:
|
||||
providers_export[provider_name] = {}
|
||||
providers_export[provider_name][country] = config.to_dict()
|
||||
else:
|
||||
# Non-country key
|
||||
providers_export[cache_key] = config.to_dict()
|
||||
|
||||
export_data = {
|
||||
'metadata': {
|
||||
'exported_at': time.time(),
|
||||
'version': '1.1',
|
||||
'source': 'streaming_providers_proxy_manager'
|
||||
},
|
||||
'global': self._global_config.to_dict() if self._global_config else None,
|
||||
'providers': providers_export
|
||||
}
|
||||
|
||||
with open(export_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(export_data, f, indent=2, ensure_ascii=False)
|
||||
|
||||
logger.info(f"Exported proxy configurations to {export_path}")
|
||||
return export_path
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error exporting proxy configurations: {e}")
|
||||
raise
|
||||
|
||||
def import_config(self, import_path: str, merge: bool = True) -> bool:
|
||||
"""
|
||||
Import proxy configurations from a file
|
||||
|
||||
Args:
|
||||
import_path: Path to import file
|
||||
merge: If True, merge with existing configs; if False, replace all
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
with open(import_path, 'r', encoding='utf-8') as f:
|
||||
import_data = json.load(f)
|
||||
|
||||
if not merge:
|
||||
# Clear existing configurations
|
||||
self._config_cache.clear()
|
||||
self._global_config = None
|
||||
|
||||
# Import global configuration
|
||||
if 'global' in import_data and import_data['global']:
|
||||
self._global_config = ProxyConfig.from_dict(import_data['global'])
|
||||
|
||||
# Import provider configurations
|
||||
providers = import_data.get('providers', {})
|
||||
for provider_name, provider_data in providers.items():
|
||||
try:
|
||||
# Check if nested (country-aware)
|
||||
if isinstance(provider_data, dict) and any(
|
||||
isinstance(v, dict) and len(k) <= 3
|
||||
for k, v in provider_data.items()
|
||||
):
|
||||
# Country-aware structure
|
||||
for country, config_data in provider_data.items():
|
||||
if isinstance(config_data, dict) and len(country) <= 3:
|
||||
cache_key = f"{provider_name}_{country}"
|
||||
self._config_cache[cache_key] = ProxyConfig.from_dict(config_data)
|
||||
else:
|
||||
# Flat structure
|
||||
self._config_cache[provider_name] = ProxyConfig.from_dict(provider_data)
|
||||
except Exception as e:
|
||||
logger.error(f"Error importing config for {provider_name}: {e}")
|
||||
|
||||
# Save the imported configurations
|
||||
success = self._save_configurations()
|
||||
if success:
|
||||
logger.info(f"Successfully imported proxy configurations from {import_path}")
|
||||
return success
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error importing proxy configurations: {e}")
|
||||
return False
|
||||
|
||||
def create_proxy_from_url(self, provider_name: str, proxy_url: str,
|
||||
scope: Optional[ProxyScope] = None,
|
||||
country: Optional[str] = None) -> bool:
|
||||
"""
|
||||
Create and set proxy configuration from URL
|
||||
|
||||
Args:
|
||||
provider_name: Name of the provider
|
||||
proxy_url: Proxy URL (e.g., "http://user:pass@proxy.example.com:8080")
|
||||
scope: Optional scope configuration
|
||||
country: Optional country code
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
proxy_config = ProxyConfig.from_url(proxy_url, scope)
|
||||
return self.set_proxy_config(provider_name, proxy_config, country)
|
||||
except Exception as e:
|
||||
country_str = f" ({country})" if country else ""
|
||||
logger.error(f"Error creating proxy config from URL for {provider_name}{country_str}: {e}")
|
||||
return False
|
||||
|
||||
def bulk_set_proxy(self, proxy_config: ProxyConfig,
|
||||
provider_names: List[str],
|
||||
country: Optional[str] = None) -> Dict[str, bool]:
|
||||
"""
|
||||
Set the same proxy configuration for multiple providers
|
||||
|
||||
Args:
|
||||
proxy_config: Proxy configuration to set
|
||||
provider_names: List of provider names
|
||||
country: Optional country code to apply to all providers
|
||||
|
||||
Returns:
|
||||
Dictionary mapping provider names to success status
|
||||
"""
|
||||
results = {}
|
||||
for provider_name in provider_names:
|
||||
results[provider_name] = self.set_proxy_config(provider_name, proxy_config, country)
|
||||
return results
|
||||
|
||||
def get_all_proxy_info(self) -> Dict[str, Dict[str, Any]]:
|
||||
"""
|
||||
Get proxy information for all configured providers
|
||||
|
||||
Returns:
|
||||
Dictionary mapping provider names to their proxy info
|
||||
"""
|
||||
info = {}
|
||||
|
||||
# Add global config info
|
||||
if self._global_config:
|
||||
info['global'] = self.get_proxy_info('global')
|
||||
|
||||
# Add provider-specific configs
|
||||
for cache_key in self._config_cache.keys():
|
||||
if '_' in cache_key and len(cache_key.split('_')[-1]) <= 3:
|
||||
# Country-aware key
|
||||
parts = cache_key.rsplit('_', 1)
|
||||
provider_name = parts[0]
|
||||
country = parts[1]
|
||||
info[cache_key] = self.get_proxy_info(provider_name, country)
|
||||
else:
|
||||
# Non-country key
|
||||
info[cache_key] = self.get_proxy_info(cache_key)
|
||||
|
||||
return info
|
||||
@@ -0,0 +1,172 @@
|
||||
# streaming_providers/base/provider.py
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Dict, List, Optional
|
||||
import json
|
||||
from datetime import datetime
|
||||
|
||||
from .models.streaming_channel import StreamingChannel
|
||||
from .models.drm_models import DRMConfig
|
||||
|
||||
class StreamingProvider(ABC):
|
||||
"""
|
||||
Abstract base class for streaming providers
|
||||
"""
|
||||
|
||||
def __init__(self, country: str = 'DE'):
|
||||
self.country = country
|
||||
self.channels: List[StreamingChannel] = []
|
||||
self._http_manager = None
|
||||
|
||||
@property
|
||||
def http_manager(self):
|
||||
"""
|
||||
Return the provider's HTTP manager instance
|
||||
|
||||
Returns:
|
||||
HTTPManager instance if available, None otherwise
|
||||
"""
|
||||
return getattr(self, '_http_manager', None)
|
||||
|
||||
@http_manager.setter
|
||||
def http_manager(self, value):
|
||||
"""
|
||||
Set the provider's HTTP manager instance
|
||||
|
||||
Args:
|
||||
value: HTTPManager instance to set
|
||||
"""
|
||||
self._http_manager = value
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def provider_name(self) -> str:
|
||||
"""Return the provider name (e.g., 'joyn', 'zdf', 'ard')"""
|
||||
pass
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def uses_dynamic_manifests(self) -> bool:
|
||||
"""
|
||||
Return True if provider uses truly dynamic manifests (timestamps, session-dependent)
|
||||
Return False if provider uses static manifests or manifests that can be fetched once
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def fetch_channels(self, **kwargs) -> List[StreamingChannel]:
|
||||
"""
|
||||
Fetch channels from the provider
|
||||
|
||||
Returns:
|
||||
List of StreamingChannel objects (may have empty manifests)
|
||||
"""
|
||||
pass
|
||||
|
||||
def get_drm_configs(self, channel: StreamingChannel, **kwargs) -> List[DRMConfig]:
|
||||
"""
|
||||
Get all DRM configurations for a channel
|
||||
|
||||
Args:
|
||||
channel: Channel to get DRM configs for
|
||||
**kwargs: Additional parameters
|
||||
|
||||
Returns:
|
||||
List of DRMConfig objects (can be empty if no DRM is used)
|
||||
"""
|
||||
return []
|
||||
|
||||
def get_drm_configs_by_id(self, channel_id: str, **kwargs) -> List[DRMConfig]:
|
||||
"""
|
||||
Get all DRM configurations for a channel by ID
|
||||
|
||||
Args:
|
||||
channel_id: ID of the channel to get DRM configs for
|
||||
**kwargs: Additional parameters (e.g., country)
|
||||
|
||||
Returns:
|
||||
List of DRMConfig objects (can be empty if no DRM is used)
|
||||
"""
|
||||
return []
|
||||
|
||||
def get_epg(self, channel_id: str,
|
||||
start_time: Optional[datetime] = None,
|
||||
end_time: Optional[datetime] = None,
|
||||
**kwargs) -> List[Dict]:
|
||||
"""
|
||||
Get EPG data for a channel
|
||||
|
||||
Args:
|
||||
channel_id: Channel ID to get EPG for
|
||||
start_time: Optional start time for EPG window
|
||||
end_time: Optional end time for EPG window
|
||||
**kwargs: Additional parameters
|
||||
|
||||
Returns:
|
||||
List of EPG entries (each containing start/end times, title, description, etc.)
|
||||
"""
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
def get_epg_xmltv(self, **kwargs) -> Optional[str]:
|
||||
"""
|
||||
Get complete EPG data for this provider in XMLTV format.
|
||||
|
||||
This method should be implemented by concrete providers to return
|
||||
the entire EPG as a properly formatted XMLTV string.
|
||||
|
||||
Args:
|
||||
**kwargs: Additional parameters (e.g., country, date range)
|
||||
|
||||
Returns:
|
||||
XMLTV formatted string, or None if EPG is not available
|
||||
"""
|
||||
return None # Default implementation - providers can override
|
||||
|
||||
@abstractmethod
|
||||
def enrich_channel_data(self, channel: StreamingChannel, **kwargs) -> Optional[StreamingChannel]:
|
||||
"""
|
||||
Enrich channel with additional data including manifest URL and other info
|
||||
|
||||
Args:
|
||||
channel: Channel to enrich
|
||||
**kwargs: Additional parameters
|
||||
|
||||
Returns:
|
||||
The enriched StreamingChannel with manifest and additional info, or None if failed
|
||||
"""
|
||||
return None
|
||||
|
||||
@abstractmethod
|
||||
def get_manifest(self, channel_id: str, **kwargs) -> Optional[str]:
|
||||
"""
|
||||
Get manifest URL for a specific channel by ID
|
||||
|
||||
Args:
|
||||
channel_id: ID of the channel to get manifest for
|
||||
**kwargs: Additional parameters (e.g., country)
|
||||
|
||||
Returns:
|
||||
Manifest URL string, or None if not available
|
||||
"""
|
||||
return None
|
||||
|
||||
def get_dynamic_manifest_params(self, channel: StreamingChannel, **kwargs) -> Optional[str]:
|
||||
"""
|
||||
Optional: Get dynamic manifest parameters for a channel
|
||||
"""
|
||||
return None
|
||||
|
||||
def to_output_format(self, channels: List[StreamingChannel] = None) -> Dict:
|
||||
"""Convert channels to output format"""
|
||||
if channels is None:
|
||||
channels = self.channels
|
||||
|
||||
return {
|
||||
'Provider': self.provider_name,
|
||||
'Country': self.country,
|
||||
'Channels': [channel.to_dict() for channel in channels]
|
||||
}
|
||||
|
||||
def to_json(self, channels: List[StreamingChannel] = None, indent: int = 2) -> str:
|
||||
"""Convert to JSON string"""
|
||||
return json.dumps(self.to_output_format(channels), indent=indent, ensure_ascii=False)
|
||||
@@ -0,0 +1,11 @@
|
||||
# streaming_providers/base/settings/__init__.py
|
||||
from .kodi_settings_bridge import KodiSettingsBridge
|
||||
from .settings_manager import UnifiedSettingsManager
|
||||
from .models.settings_models import SettingValue, SettingType, ValidationRule
|
||||
from .models.provider_settings import ProviderSettingsSchema, StandardProviderSettings
|
||||
|
||||
__all__ = [
|
||||
'KodiSettingsBridge', 'UnifiedSettingsManager',
|
||||
'SettingValue', 'SettingType', 'ValidationRule',
|
||||
'ProviderSettingsSchema', 'StandardProviderSettings'
|
||||
]
|
||||
@@ -0,0 +1,460 @@
|
||||
# streaming_providers/base/settings/kodi_settings_bridge.py
|
||||
from typing import Dict, List, Optional, Set, Tuple
|
||||
import xml.etree.ElementTree as ElementTree
|
||||
|
||||
from ..auth.credentials import BaseCredentials, UserPasswordCredentials, ClientCredentials
|
||||
from ..models.proxy_models import ProxyConfig
|
||||
from ..utils.logger import logger
|
||||
|
||||
try:
|
||||
import xbmcaddon
|
||||
|
||||
KODI_AVAILABLE = True
|
||||
except ImportError:
|
||||
KODI_AVAILABLE = False
|
||||
|
||||
|
||||
class KodiSettingsBridge:
|
||||
"""Bridge between Kodi addon settings and internal configuration system"""
|
||||
|
||||
# Markers that identify credential settings
|
||||
CREDENTIAL_MARKERS = {'_username', '_password', '_client_id', '_client_secret'}
|
||||
|
||||
def __init__(self, addon_id: Optional[str] = None, config_dir: Optional[str] = None):
|
||||
"""Initialize Kodi settings bridge"""
|
||||
self.addon = None
|
||||
self.addon_id = addon_id
|
||||
|
||||
# Initialize VFS with config directory support
|
||||
from ..utils.vfs import VFS
|
||||
self.vfs = VFS(config_dir=config_dir)
|
||||
|
||||
if KODI_AVAILABLE:
|
||||
try:
|
||||
if addon_id:
|
||||
self.addon = xbmcaddon.Addon(addon_id)
|
||||
else:
|
||||
self.addon = xbmcaddon.Addon()
|
||||
self.addon_id = self.addon.getAddonInfo('id')
|
||||
logger.info(f"Kodi settings bridge initialized for addon: {self.addon_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to initialize Kodi addon: {e}")
|
||||
self.addon = None
|
||||
|
||||
def is_kodi_environment(self) -> bool:
|
||||
"""Check if currently running in Kodi environment"""
|
||||
return KODI_AVAILABLE and self.addon is not None
|
||||
|
||||
def get_addon_info(self) -> Dict[str, str]:
|
||||
"""Get information about current Kodi addon (ID, version, etc.)"""
|
||||
if not self.is_kodi_environment():
|
||||
return {"error": "Not in Kodi environment"}
|
||||
|
||||
return {
|
||||
'id': self.addon.getAddonInfo('id'),
|
||||
'name': self.addon.getAddonInfo('name'),
|
||||
'version': self.addon.getAddonInfo('version'),
|
||||
'author': self.addon.getAddonInfo('author'),
|
||||
'path': self.addon.getAddonInfo('path')
|
||||
}
|
||||
|
||||
# ============= Dynamic Discovery =============
|
||||
|
||||
def _get_all_setting_ids(self) -> List[str]:
|
||||
"""
|
||||
Get all setting IDs from settings.xml by parsing the file.
|
||||
|
||||
Returns:
|
||||
List of all setting IDs found in settings.xml
|
||||
"""
|
||||
if not self.is_kodi_environment():
|
||||
return []
|
||||
|
||||
try:
|
||||
# Read settings.xml from VFS base path (addon profile directory)
|
||||
xml_content = self.vfs.read_text('settings.xml')
|
||||
if not xml_content:
|
||||
logger.warning("settings.xml not found or empty")
|
||||
return []
|
||||
if not xml_content:
|
||||
logger.warning("settings.xml is empty")
|
||||
return []
|
||||
|
||||
root = ElementTree.fromstring(xml_content)
|
||||
|
||||
# Extract all setting IDs
|
||||
setting_ids = []
|
||||
for setting in root.findall('.//setting'):
|
||||
setting_id = setting.get('id')
|
||||
if setting_id:
|
||||
setting_ids.append(setting_id)
|
||||
|
||||
logger.info(f"Found {len(setting_ids)} settings in settings.xml")
|
||||
logger.debug(f"Setting IDs: {setting_ids}")
|
||||
|
||||
return setting_ids
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error reading settings.xml: {e}")
|
||||
return []
|
||||
|
||||
def _parse_provider_country(self, setting_id: str) -> Optional[Tuple[str, Optional[str]]]:
|
||||
"""
|
||||
Parse a setting ID to extract provider and optional country.
|
||||
|
||||
Pattern:
|
||||
- provider_marker → (provider, None)
|
||||
- provider_country_marker → (provider, country)
|
||||
- anything else → None
|
||||
|
||||
Args:
|
||||
setting_id: Setting ID like "joyn_username" or "joyn_de_username"
|
||||
|
||||
Returns:
|
||||
Tuple of (provider, country) or None if pattern doesn't match
|
||||
"""
|
||||
# Check if ends with a credential marker
|
||||
marker = None
|
||||
for m in self.CREDENTIAL_MARKERS:
|
||||
if setting_id.endswith(m):
|
||||
marker = m
|
||||
break
|
||||
|
||||
if not marker:
|
||||
return None
|
||||
|
||||
# Remove the marker
|
||||
prefix = setting_id[:-len(marker)]
|
||||
|
||||
# Split by underscore
|
||||
parts = prefix.split('_')
|
||||
|
||||
if len(parts) == 1:
|
||||
# provider only
|
||||
return parts[0], None
|
||||
elif len(parts) == 2:
|
||||
# provider_country
|
||||
return parts[0], parts[1]
|
||||
else:
|
||||
# Too many parts, doesn't match our pattern
|
||||
return None
|
||||
|
||||
def discover_all_providers(self) -> Dict[str, List[str]]:
|
||||
"""
|
||||
Scan all Kodi settings and discover providers with their countries dynamically.
|
||||
|
||||
Returns:
|
||||
Dict mapping provider names to list of countries.
|
||||
Empty list means provider without country.
|
||||
Example: {'joyn': ['de', 'at'], 'rtlplus': ['de'], 'zattoo': []}
|
||||
"""
|
||||
if not self.is_kodi_environment():
|
||||
return {}
|
||||
|
||||
discovered: Dict[str, Set[Optional[str]]] = {}
|
||||
|
||||
# Get all setting IDs
|
||||
setting_ids = self._get_all_setting_ids()
|
||||
|
||||
for setting_id in setting_ids:
|
||||
result = self._parse_provider_country(setting_id)
|
||||
if result:
|
||||
provider, country = result
|
||||
if provider not in discovered:
|
||||
discovered[provider] = set()
|
||||
discovered[provider].add(country)
|
||||
|
||||
# Convert sets to lists, with None values converted to empty list
|
||||
result = {}
|
||||
for provider, countries in discovered.items():
|
||||
country_list = []
|
||||
for country in countries:
|
||||
if country is None:
|
||||
# Provider without country - represent as empty list for that provider
|
||||
if not country_list: # Only add if we haven't already
|
||||
result[provider] = []
|
||||
else:
|
||||
country_list.append(country)
|
||||
|
||||
if country_list:
|
||||
result[provider] = sorted(country_list)
|
||||
elif provider not in result:
|
||||
result[provider] = []
|
||||
|
||||
logger.info(f"Discovered providers: {result}")
|
||||
return result
|
||||
|
||||
def detect_all_providers_from_kodi(self) -> Dict[str, List[str]]:
|
||||
"""
|
||||
Alias for discover_all_providers() for backward compatibility.
|
||||
"""
|
||||
return self.discover_all_providers()
|
||||
|
||||
def detect_countries_for_provider(self, provider: str) -> List[str]:
|
||||
"""
|
||||
Discover which countries are configured for a specific provider.
|
||||
|
||||
Args:
|
||||
provider: Provider name (e.g., 'joyn')
|
||||
|
||||
Returns:
|
||||
List of detected country codes (e.g., ['de', 'at'])
|
||||
"""
|
||||
all_providers = self.discover_all_providers()
|
||||
countries = all_providers.get(provider, [])
|
||||
|
||||
# Filter out empty list (which means provider without country)
|
||||
if not countries:
|
||||
return []
|
||||
|
||||
return countries
|
||||
|
||||
def get_all_countries_for_provider(self, provider: str, available_countries: List[str]) -> List[str]:
|
||||
"""
|
||||
Check which countries from a given list have credentials configured for a provider.
|
||||
|
||||
Args:
|
||||
provider: Provider name
|
||||
available_countries: List of country codes to check
|
||||
|
||||
Returns:
|
||||
List of country codes that have credentials in Kodi
|
||||
"""
|
||||
discovered_countries = self.detect_countries_for_provider(provider)
|
||||
return [c for c in available_countries if c in discovered_countries]
|
||||
|
||||
# ============= Credential Operations =============
|
||||
|
||||
def read_credentials_from_kodi(self, provider: str, country: Optional[str] = None) -> Optional[BaseCredentials]:
|
||||
"""
|
||||
Read authentication credentials from Kodi settings for a provider.
|
||||
Uses convention: {provider}_{country}_username, {provider}_{country}_password, etc.
|
||||
If country is None, tries without country suffix for backward compatibility.
|
||||
"""
|
||||
if not self.is_kodi_environment():
|
||||
return None
|
||||
|
||||
country_suffix = f"_{country}" if country else ""
|
||||
|
||||
try:
|
||||
# Try convention-based setting names
|
||||
username = self.addon.getSetting(f'{provider}{country_suffix}_username')
|
||||
password = self.addon.getSetting(f'{provider}{country_suffix}_password')
|
||||
client_id = self.addon.getSetting(f'{provider}{country_suffix}_client_id')
|
||||
client_secret = self.addon.getSetting(f'{provider}{country_suffix}_client_secret')
|
||||
|
||||
logger.debug(f"Kodi settings for {provider}{country_suffix}:")
|
||||
logger.debug(f" username: '{username}' (empty={not username})")
|
||||
logger.debug(f" password: {'***' if password else '(empty)'}")
|
||||
logger.debug(f" client_id: '{client_id}' (empty={not client_id})")
|
||||
logger.debug(f" client_secret: {'***' if client_secret else '(empty)'}")
|
||||
|
||||
# Determine credential type based on available values
|
||||
if username and password:
|
||||
logger.info(f"Found username/password credentials for {provider}{country_suffix} in Kodi")
|
||||
return UserPasswordCredentials(
|
||||
username=username.strip(),
|
||||
password=password.strip(),
|
||||
client_id=client_id.strip() if client_id else None
|
||||
)
|
||||
elif client_id and client_secret:
|
||||
logger.info(f"Found client credentials for {provider}{country_suffix} in Kodi")
|
||||
return ClientCredentials(
|
||||
client_id=client_id.strip(),
|
||||
client_secret=client_secret.strip()
|
||||
)
|
||||
|
||||
logger.debug(f"No valid credentials found in Kodi for {provider}{country_suffix}")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error reading credentials from Kodi for {provider}: {e}")
|
||||
return None
|
||||
|
||||
def write_credentials_to_kodi(self, provider: str, credentials: BaseCredentials,
|
||||
country: Optional[str] = None) -> bool:
|
||||
"""Write authentication credentials to Kodi settings"""
|
||||
if not self.is_kodi_environment():
|
||||
return False
|
||||
|
||||
country_suffix = f"_{country}" if country else ""
|
||||
|
||||
try:
|
||||
if isinstance(credentials, UserPasswordCredentials):
|
||||
self.addon.setSetting(f'{provider}{country_suffix}_username', credentials.username)
|
||||
self.addon.setSetting(f'{provider}{country_suffix}_password', credentials.password)
|
||||
if credentials.client_id:
|
||||
self.addon.setSetting(f'{provider}{country_suffix}_client_id', credentials.client_id)
|
||||
logger.info(f"Wrote username/password credentials to Kodi for {provider}{country_suffix}")
|
||||
return True
|
||||
|
||||
elif isinstance(credentials, ClientCredentials):
|
||||
self.addon.setSetting(f'{provider}{country_suffix}_client_id', credentials.client_id)
|
||||
self.addon.setSetting(f'{provider}{country_suffix}_client_secret', credentials.client_secret)
|
||||
logger.info(f"Wrote client credentials to Kodi for {provider}{country_suffix}")
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error writing credentials to Kodi for {provider}: {e}")
|
||||
return False
|
||||
|
||||
def sync_credentials_to_file(self, provider: str, credential_manager,
|
||||
country: Optional[str] = None) -> bool:
|
||||
"""Sync provider credentials from Kodi settings to credential file"""
|
||||
credentials = self.read_credentials_from_kodi(provider, country)
|
||||
if not credentials:
|
||||
logger.debug(f"No credentials to sync for {provider}")
|
||||
return False
|
||||
|
||||
# Check if credentials are different from file
|
||||
file_credentials = credential_manager.load_credentials(provider, country)
|
||||
if self._credentials_equal(credentials, file_credentials):
|
||||
logger.debug(f"Credentials already in sync for {provider}")
|
||||
return True # Already in sync
|
||||
|
||||
success = credential_manager.save_credentials(provider, credentials, country)
|
||||
if success:
|
||||
logger.info(f"Synced credentials from Kodi to file for {provider}")
|
||||
return success
|
||||
|
||||
# ============= Proxy Operations =============
|
||||
|
||||
def read_proxy_config_from_kodi(self, provider: str, country: Optional[str] = None) -> Optional[ProxyConfig]:
|
||||
"""
|
||||
Read proxy configuration from Kodi settings for a provider.
|
||||
Uses convention: {provider}_{country}_proxy_enabled, {provider}_{country}_proxy_host, etc.
|
||||
"""
|
||||
if not self.is_kodi_environment():
|
||||
return None
|
||||
|
||||
country_suffix = f"_{country}" if country else ""
|
||||
|
||||
try:
|
||||
# Check if proxy is enabled
|
||||
proxy_enabled = self.addon.getSetting(f'{provider}{country_suffix}_proxy_enabled')
|
||||
logger.debug(f"Proxy enabled setting for {provider}{country_suffix}: '{proxy_enabled}'")
|
||||
|
||||
if not proxy_enabled or proxy_enabled.lower() not in ['true', '1', 'yes']:
|
||||
logger.debug(f"Proxy not enabled for {provider}{country_suffix}")
|
||||
return None
|
||||
|
||||
proxy_host = self.addon.getSetting(f'{provider}{country_suffix}_proxy_host')
|
||||
proxy_port_str = self.addon.getSetting(f'{provider}{country_suffix}_proxy_port')
|
||||
|
||||
logger.debug(f"Proxy settings for {provider}{country_suffix}:")
|
||||
logger.debug(f" host: '{proxy_host}'")
|
||||
logger.debug(f" port: '{proxy_port_str}'")
|
||||
|
||||
if not proxy_host or not proxy_port_str:
|
||||
logger.debug(f"Proxy host or port missing for {provider}{country_suffix}")
|
||||
return None
|
||||
|
||||
try:
|
||||
proxy_port = int(proxy_port_str)
|
||||
except ValueError:
|
||||
logger.error(f"Invalid proxy port '{proxy_port_str}' for {provider}{country_suffix}")
|
||||
return None
|
||||
|
||||
# Create proxy config
|
||||
proxy_config = ProxyConfig(host=proxy_host.strip(), port=proxy_port)
|
||||
|
||||
logger.info(f"Found proxy config for {provider}{country_suffix} in Kodi: {proxy_host}:{proxy_port}")
|
||||
return proxy_config
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error reading proxy config from Kodi for {provider}: {e}")
|
||||
return None
|
||||
|
||||
def write_proxy_config_to_kodi(self, provider: str, proxy_config: ProxyConfig,
|
||||
country: Optional[str] = None) -> bool:
|
||||
"""Write proxy configuration to Kodi settings"""
|
||||
if not self.is_kodi_environment():
|
||||
return False
|
||||
|
||||
country_suffix = f"_{country}" if country else ""
|
||||
|
||||
try:
|
||||
self.addon.setSetting(f'{provider}{country_suffix}_proxy_enabled', 'true')
|
||||
self.addon.setSetting(f'{provider}{country_suffix}_proxy_host', proxy_config.host)
|
||||
self.addon.setSetting(f'{provider}{country_suffix}_proxy_port', str(proxy_config.port))
|
||||
|
||||
logger.info(f"Wrote proxy config to Kodi for {provider}{country_suffix}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error writing proxy config to Kodi for {provider}: {e}")
|
||||
return False
|
||||
|
||||
def sync_proxy_config_to_file(self, provider: str, proxy_manager,
|
||||
country: Optional[str] = None) -> bool:
|
||||
"""Sync provider proxy config from Kodi settings to proxy config file"""
|
||||
proxy_config = self.read_proxy_config_from_kodi(provider, country)
|
||||
if not proxy_config:
|
||||
logger.debug(f"No proxy config to sync for {provider}")
|
||||
return False
|
||||
|
||||
# Check if proxy config is different from file
|
||||
file_proxy = proxy_manager.get_proxy_config(provider, country)
|
||||
if self._proxy_configs_equal(proxy_config, file_proxy):
|
||||
logger.debug(f"Proxy config already in sync for {provider}")
|
||||
return True # Already in sync
|
||||
|
||||
success = proxy_manager.set_proxy_config(provider, proxy_config, country)
|
||||
if success:
|
||||
logger.info(f"Synced proxy config from Kodi to file for {provider}")
|
||||
return success
|
||||
|
||||
# ============= Comparison Helpers =============
|
||||
|
||||
@staticmethod
|
||||
def _credentials_equal(cred1: Optional[BaseCredentials], cred2: Optional[BaseCredentials]) -> bool:
|
||||
"""Compare two credentials for equality"""
|
||||
if cred1 is None and cred2 is None:
|
||||
return True
|
||||
if cred1 is None or cred2 is None:
|
||||
return False
|
||||
if type(cred1) != type(cred2):
|
||||
return False
|
||||
|
||||
if isinstance(cred1, UserPasswordCredentials) and isinstance(cred2, UserPasswordCredentials):
|
||||
return (cred1.username == cred2.username and
|
||||
cred1.password == cred2.password and
|
||||
cred1.client_id == cred2.client_id)
|
||||
elif isinstance(cred1, ClientCredentials) and isinstance(cred2, ClientCredentials):
|
||||
return (cred1.client_id == cred2.client_id and
|
||||
cred1.client_secret == cred2.client_secret)
|
||||
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _proxy_configs_equal(proxy1: Optional[ProxyConfig], proxy2: Optional[ProxyConfig]) -> bool:
|
||||
"""Compare two proxy configurations for equality"""
|
||||
if proxy1 is None and proxy2 is None:
|
||||
return True
|
||||
if proxy1 is None or proxy2 is None:
|
||||
return False
|
||||
|
||||
# Compare basic properties
|
||||
if (proxy1.host != proxy2.host or
|
||||
proxy1.port != proxy2.port or
|
||||
proxy1.proxy_type != proxy2.proxy_type):
|
||||
return False
|
||||
|
||||
# Compare authentication
|
||||
if (proxy1.auth is None) != (proxy2.auth is None):
|
||||
return False
|
||||
if proxy1.auth and proxy2.auth:
|
||||
if (proxy1.auth.username != proxy2.auth.username or
|
||||
proxy1.auth.password != proxy2.auth.password):
|
||||
return False
|
||||
|
||||
# Compare scope
|
||||
if (proxy1.scope.api_calls != proxy2.scope.api_calls or
|
||||
proxy1.scope.authentication != proxy2.scope.authentication or
|
||||
proxy1.scope.manifests != proxy2.scope.manifests or
|
||||
proxy1.scope.license != proxy2.scope.license):
|
||||
return False
|
||||
|
||||
return True
|
||||
@@ -0,0 +1,42 @@
|
||||
# streaming_providers/base/settings/models/__init__.py
|
||||
from .settings_models import (
|
||||
SettingType,
|
||||
ValidationRule,
|
||||
StandardValidationRules,
|
||||
SettingValue,
|
||||
SettingValueBuilder,
|
||||
string_setting,
|
||||
password_setting,
|
||||
integer_setting,
|
||||
boolean_setting,
|
||||
select_setting,
|
||||
url_setting,
|
||||
port_setting,
|
||||
ip_setting
|
||||
)
|
||||
|
||||
from .provider_settings import (
|
||||
ProviderSettingsSchema,
|
||||
StandardProviderSettings
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# From settings_models.py
|
||||
'SettingType',
|
||||
'ValidationRule',
|
||||
'StandardValidationRules',
|
||||
'SettingValue',
|
||||
'SettingValueBuilder',
|
||||
'string_setting',
|
||||
'password_setting',
|
||||
'integer_setting',
|
||||
'boolean_setting',
|
||||
'select_setting',
|
||||
'url_setting',
|
||||
'port_setting',
|
||||
'ip_setting',
|
||||
|
||||
# From provider_settings.py
|
||||
'ProviderSettingsSchema',
|
||||
'StandardProviderSettings'
|
||||
]
|
||||
@@ -0,0 +1,603 @@
|
||||
# streaming_providers/base/settings/models/provider_settings.py
|
||||
from typing import Dict, List, Optional, Any, Set
|
||||
from dataclasses import dataclass, field
|
||||
from .settings_models import (
|
||||
SettingValue,
|
||||
string_setting, password_setting, integer_setting, boolean_setting,
|
||||
select_setting, port_setting
|
||||
)
|
||||
from ...utils.logger import logger
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProviderSettingsSchema:
|
||||
"""Schema definition for a provider's settings"""
|
||||
provider_name: str
|
||||
_settings: Dict[str, SettingValue] = field(default_factory=dict)
|
||||
_categories: Dict[str, Set[str]] = field(default_factory=dict)
|
||||
_kodi_mapping: Dict[str, str] = field(default_factory=dict)
|
||||
|
||||
def __post_init__(self):
|
||||
"""Initialize schema with default settings"""
|
||||
self._build_default_schema()
|
||||
|
||||
def _build_default_schema(self) -> None:
|
||||
"""Build the complete settings schema for this provider"""
|
||||
# Build all setting categories
|
||||
credential_settings = self.define_credential_settings()
|
||||
proxy_settings = self.define_proxy_settings()
|
||||
video_settings = self.define_video_settings()
|
||||
drm_settings = self.define_drm_settings()
|
||||
network_settings = self.define_network_settings()
|
||||
|
||||
# Merge all settings
|
||||
self._settings.update(credential_settings)
|
||||
self._settings.update(proxy_settings)
|
||||
self._settings.update(video_settings)
|
||||
self._settings.update(drm_settings)
|
||||
self._settings.update(network_settings)
|
||||
|
||||
# Build category mappings
|
||||
self._categories = {
|
||||
'credentials': set(credential_settings.keys()),
|
||||
'proxy': set(proxy_settings.keys()),
|
||||
'video': set(video_settings.keys()),
|
||||
'drm': set(drm_settings.keys()),
|
||||
'network': set(network_settings.keys())
|
||||
}
|
||||
|
||||
# Build Kodi mapping
|
||||
self._build_kodi_mapping()
|
||||
|
||||
def _build_kodi_mapping(self) -> None:
|
||||
"""Build mapping from internal setting names to Kodi setting IDs"""
|
||||
for setting_name, setting in self._settings.items():
|
||||
if setting.kodi_setting_id:
|
||||
self._kodi_mapping[setting_name] = setting.kodi_setting_id
|
||||
|
||||
def define_credential_settings(self) -> Dict[str, SettingValue]:
|
||||
"""Define credential-related settings for this provider"""
|
||||
settings = {}
|
||||
|
||||
# Common credential settings - can be overridden in subclasses
|
||||
settings['username'] = (
|
||||
string_setting()
|
||||
.required()
|
||||
.min_length(1)
|
||||
.display_name("Username")
|
||||
.description("Account username or email address")
|
||||
.kodi_setting(f"{self.provider_name}_username")
|
||||
.build()
|
||||
)
|
||||
|
||||
settings['password'] = (
|
||||
password_setting()
|
||||
.display_name("Password")
|
||||
.description("Account password")
|
||||
.kodi_setting(f"{self.provider_name}_password")
|
||||
.build()
|
||||
)
|
||||
|
||||
# Optional client ID for OAuth providers
|
||||
settings['client_id'] = (
|
||||
string_setting()
|
||||
.display_name("Client ID")
|
||||
.description("OAuth client identifier (if required)")
|
||||
.kodi_setting(f"{self.provider_name}_client_id")
|
||||
.build()
|
||||
)
|
||||
|
||||
# Optional client secret for OAuth providers
|
||||
settings['client_secret'] = (
|
||||
password_setting(required=False)
|
||||
.display_name("Client Secret")
|
||||
.description("OAuth client secret (if required)")
|
||||
.kodi_setting(f"{self.provider_name}_client_secret")
|
||||
.build()
|
||||
)
|
||||
|
||||
return settings
|
||||
|
||||
def define_proxy_settings(self) -> Dict[str, SettingValue]:
|
||||
"""Define proxy-related settings for this provider"""
|
||||
settings = {}
|
||||
|
||||
# Enable proxy for this provider
|
||||
settings['proxy_enabled'] = (
|
||||
boolean_setting(False)
|
||||
.display_name("Enable Proxy")
|
||||
.description("Use proxy for this provider")
|
||||
.kodi_setting(f"{self.provider_name}_proxy_enabled")
|
||||
.build()
|
||||
)
|
||||
|
||||
# Proxy host
|
||||
settings['proxy_host'] = (
|
||||
string_setting()
|
||||
.display_name("Proxy Host")
|
||||
.description("Proxy server hostname or IP address")
|
||||
.kodi_setting(f"{self.provider_name}_proxy_host")
|
||||
.build()
|
||||
)
|
||||
|
||||
# Proxy port
|
||||
settings['proxy_port'] = (
|
||||
port_setting(8080)
|
||||
.display_name("Proxy Port")
|
||||
.description("Proxy server port")
|
||||
.kodi_setting(f"{self.provider_name}_proxy_port")
|
||||
.build()
|
||||
)
|
||||
|
||||
# Proxy type
|
||||
settings['proxy_type'] = (
|
||||
select_setting(['http', 'https', 'socks4', 'socks5'], 'http')
|
||||
.display_name("Proxy Type")
|
||||
.description("Type of proxy server")
|
||||
.kodi_setting(f"{self.provider_name}_proxy_type")
|
||||
.build()
|
||||
)
|
||||
|
||||
# Proxy authentication
|
||||
settings['proxy_auth_enabled'] = (
|
||||
boolean_setting(False)
|
||||
.display_name("Proxy Authentication")
|
||||
.description("Proxy server requires authentication")
|
||||
.kodi_setting(f"{self.provider_name}_proxy_auth_enabled")
|
||||
.build()
|
||||
)
|
||||
|
||||
# Proxy username
|
||||
settings['proxy_username'] = (
|
||||
string_setting()
|
||||
.display_name("Proxy Username")
|
||||
.description("Username for proxy authentication")
|
||||
.kodi_setting(f"{self.provider_name}_proxy_username")
|
||||
.build()
|
||||
)
|
||||
|
||||
# Proxy password
|
||||
settings['proxy_password'] = (
|
||||
password_setting(required=False)
|
||||
.display_name("Proxy Password")
|
||||
.description("Password for proxy authentication")
|
||||
.kodi_setting(f"{self.provider_name}_proxy_password")
|
||||
.build()
|
||||
)
|
||||
|
||||
# Proxy scope settings
|
||||
settings['proxy_scope_api'] = (
|
||||
boolean_setting(True)
|
||||
.display_name("Proxy API Calls")
|
||||
.description("Use proxy for API requests")
|
||||
.kodi_setting(f"{self.provider_name}_proxy_scope_api")
|
||||
.build()
|
||||
)
|
||||
|
||||
settings['proxy_scope_auth'] = (
|
||||
boolean_setting(True)
|
||||
.display_name("Proxy Authentication")
|
||||
.description("Use proxy for authentication requests")
|
||||
.kodi_setting(f"{self.provider_name}_proxy_scope_auth")
|
||||
.build()
|
||||
)
|
||||
|
||||
settings['proxy_scope_manifest'] = (
|
||||
boolean_setting(True)
|
||||
.display_name("Proxy Manifests")
|
||||
.description("Use proxy for manifest downloads")
|
||||
.kodi_setting(f"{self.provider_name}_proxy_scope_manifest")
|
||||
.build()
|
||||
)
|
||||
|
||||
settings['proxy_scope_license'] = (
|
||||
boolean_setting(True)
|
||||
.display_name("Proxy DRM Licenses")
|
||||
.description("Use proxy for DRM license requests")
|
||||
.kodi_setting(f"{self.provider_name}_proxy_scope_license")
|
||||
.build()
|
||||
)
|
||||
|
||||
return settings
|
||||
|
||||
def define_video_settings(self) -> Dict[str, SettingValue]:
|
||||
"""Define video quality/format settings for this provider"""
|
||||
settings = {}
|
||||
|
||||
# Video quality preference
|
||||
settings['video_quality'] = (
|
||||
select_setting(['best', 'worst', '720p', '1080p', '4k'], 'best')
|
||||
.display_name("Video Quality")
|
||||
.description("Preferred video quality")
|
||||
.kodi_setting(f"{self.provider_name}_video_quality")
|
||||
.build()
|
||||
)
|
||||
|
||||
# Audio language preference
|
||||
settings['audio_language'] = (
|
||||
select_setting(['de', 'en', 'original'], 'de')
|
||||
.display_name("Audio Language")
|
||||
.description("Preferred audio language")
|
||||
.kodi_setting(f"{self.provider_name}_audio_language")
|
||||
.build()
|
||||
)
|
||||
|
||||
# Subtitle language preference
|
||||
settings['subtitle_language'] = (
|
||||
select_setting(['none', 'de', 'en', 'auto'], 'none')
|
||||
.display_name("Subtitle Language")
|
||||
.description("Preferred subtitle language")
|
||||
.kodi_setting(f"{self.provider_name}_subtitle_language")
|
||||
.build()
|
||||
)
|
||||
|
||||
# Speed up streams
|
||||
settings['speed_up'] = (
|
||||
boolean_setting(True)
|
||||
.display_name("Speed Up Playback")
|
||||
.description("Enable faster stream startup")
|
||||
.kodi_setting(f"{self.provider_name}_speed_up")
|
||||
.build()
|
||||
)
|
||||
|
||||
return settings
|
||||
|
||||
def define_drm_settings(self) -> Dict[str, SettingValue]:
|
||||
"""Define DRM/CDM related settings for this provider"""
|
||||
settings = {}
|
||||
|
||||
# CDM usage
|
||||
settings['use_cdm'] = (
|
||||
boolean_setting(True)
|
||||
.display_name("Use CDM")
|
||||
.description("Enable Content Decryption Module for DRM")
|
||||
.kodi_setting(f"{self.provider_name}_use_cdm")
|
||||
.build()
|
||||
)
|
||||
|
||||
# CDM type
|
||||
settings['cdm_type'] = (
|
||||
select_setting(['widevine', 'playready', 'clearkey'], 'widevine')
|
||||
.display_name("CDM Type")
|
||||
.description("Type of Content Decryption Module")
|
||||
.kodi_setting(f"{self.provider_name}_cdm_type")
|
||||
.build()
|
||||
)
|
||||
|
||||
# CDM mode
|
||||
settings['cdm_mode'] = (
|
||||
select_setting(['external', 'internal', 'auto'], 'external')
|
||||
.display_name("CDM Mode")
|
||||
.description("How to handle CDM operations")
|
||||
.kodi_setting(f"{self.provider_name}_cdm_mode")
|
||||
.build()
|
||||
)
|
||||
|
||||
return settings
|
||||
|
||||
def define_network_settings(self) -> Dict[str, SettingValue]:
|
||||
"""Define network timeout/retry settings for this provider"""
|
||||
settings = {}
|
||||
|
||||
# Request timeout
|
||||
settings['request_timeout'] = (
|
||||
integer_setting(30, 5, 120)
|
||||
.display_name("Request Timeout")
|
||||
.description("Timeout for network requests in seconds")
|
||||
.kodi_setting(f"{self.provider_name}_request_timeout")
|
||||
.build()
|
||||
)
|
||||
|
||||
# Max retries
|
||||
settings['max_retries'] = (
|
||||
integer_setting(3, 0, 10)
|
||||
.display_name("Max Retries")
|
||||
.description("Maximum number of retry attempts")
|
||||
.kodi_setting(f"{self.provider_name}_max_retries")
|
||||
.build()
|
||||
)
|
||||
|
||||
# Retry delay
|
||||
settings['retry_delay'] = (
|
||||
integer_setting(1, 0, 10)
|
||||
.display_name("Retry Delay")
|
||||
.description("Delay between retry attempts in seconds")
|
||||
.kodi_setting(f"{self.provider_name}_retry_delay")
|
||||
.build()
|
||||
)
|
||||
|
||||
# Verify SSL
|
||||
settings['verify_ssl'] = (
|
||||
boolean_setting(True)
|
||||
.display_name("Verify SSL")
|
||||
.description("Verify SSL certificates for HTTPS requests")
|
||||
.kodi_setting(f"{self.provider_name}_verify_ssl")
|
||||
.build()
|
||||
)
|
||||
|
||||
return settings
|
||||
|
||||
def get_all_settings(self) -> Dict[str, SettingValue]:
|
||||
"""Get complete settings schema for this provider"""
|
||||
return self._settings.copy()
|
||||
|
||||
def get_setting(self, setting_name: str) -> Optional[SettingValue]:
|
||||
"""Get a specific setting by name"""
|
||||
return self._settings.get(setting_name)
|
||||
|
||||
def get_settings_by_category(self, category: str) -> Dict[str, SettingValue]:
|
||||
"""Get all settings in a specific category"""
|
||||
if category not in self._categories:
|
||||
return {}
|
||||
|
||||
category_settings = {}
|
||||
for setting_name in self._categories[category]:
|
||||
if setting_name in self._settings:
|
||||
category_settings[setting_name] = self._settings[setting_name]
|
||||
|
||||
return category_settings
|
||||
|
||||
def get_categories(self) -> List[str]:
|
||||
"""Get list of all setting categories"""
|
||||
return list(self._categories.keys())
|
||||
|
||||
def get_kodi_setting_mapping(self) -> Dict[str, str]:
|
||||
"""Get mapping from internal setting names to Kodi setting IDs"""
|
||||
return self._kodi_mapping.copy()
|
||||
|
||||
def get_reverse_kodi_mapping(self) -> Dict[str, str]:
|
||||
"""Get mapping from Kodi setting IDs to internal setting names"""
|
||||
return {v: k for k, v in self._kodi_mapping.items()}
|
||||
|
||||
def validate_all_settings(self) -> Dict[str, tuple[bool, List[str]]]:
|
||||
"""Validate all settings in the schema"""
|
||||
results = {}
|
||||
for setting_name, setting in self._settings.items():
|
||||
results[setting_name] = setting.validate()
|
||||
return results
|
||||
|
||||
def get_required_settings(self) -> List[str]:
|
||||
"""Get list of setting names that are required"""
|
||||
required = []
|
||||
for setting_name, setting in self._settings.items():
|
||||
# Check if setting has a "not_empty" validation rule
|
||||
has_required_rule = any(rule.name == "not_empty" for rule in setting.validation_rules)
|
||||
if has_required_rule:
|
||||
required.append(setting_name)
|
||||
return required
|
||||
|
||||
def get_incomplete_settings(self) -> List[str]:
|
||||
"""Get list of required settings that don't have values"""
|
||||
incomplete = []
|
||||
required_settings = self.get_required_settings()
|
||||
|
||||
for setting_name in required_settings:
|
||||
setting = self._settings[setting_name]
|
||||
if not setting.has_value():
|
||||
incomplete.append(setting_name)
|
||||
|
||||
return incomplete
|
||||
|
||||
def is_configuration_complete(self) -> bool:
|
||||
"""Check if all required settings have values"""
|
||||
return len(self.get_incomplete_settings()) == 0
|
||||
|
||||
def get_configuration_completeness(self) -> Dict[str, Any]:
|
||||
"""Get detailed information about configuration completeness"""
|
||||
required_settings = self.get_required_settings()
|
||||
incomplete_settings = self.get_incomplete_settings()
|
||||
|
||||
completeness_by_category = {}
|
||||
for category in self.get_categories():
|
||||
category_settings = self.get_settings_by_category(category)
|
||||
category_required = [name for name in category_settings.keys() if name in required_settings]
|
||||
category_incomplete = [name for name in category_settings.keys() if name in incomplete_settings]
|
||||
|
||||
completeness_by_category[category] = {
|
||||
'total_settings': len(category_settings),
|
||||
'required_settings': len(category_required),
|
||||
'incomplete_settings': len(category_incomplete),
|
||||
'is_complete': len(category_incomplete) == 0,
|
||||
'completion_percentage': (
|
||||
100.0 if len(category_required) == 0
|
||||
else ((len(category_required) - len(category_incomplete)) / len(category_required) * 100)
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
'is_complete': self.is_configuration_complete(),
|
||||
'total_settings': len(self._settings),
|
||||
'required_settings': len(required_settings),
|
||||
'incomplete_settings': len(incomplete_settings),
|
||||
'completion_percentage': (
|
||||
100.0 if len(required_settings) == 0
|
||||
else ((len(required_settings) - len(incomplete_settings)) / len(required_settings) * 100)
|
||||
),
|
||||
'categories': completeness_by_category,
|
||||
'incomplete_setting_names': incomplete_settings
|
||||
}
|
||||
|
||||
def add_custom_setting(self, setting_name: str, setting: SettingValue, category: str = 'custom') -> None:
|
||||
"""Add a custom setting to the schema"""
|
||||
self._settings[setting_name] = setting
|
||||
|
||||
if category not in self._categories:
|
||||
self._categories[category] = set()
|
||||
self._categories[category].add(setting_name)
|
||||
|
||||
if setting.kodi_setting_id:
|
||||
self._kodi_mapping[setting_name] = setting.kodi_setting_id
|
||||
|
||||
def remove_setting(self, setting_name: str) -> bool:
|
||||
"""Remove a setting from the schema"""
|
||||
if setting_name not in self._settings:
|
||||
return False
|
||||
|
||||
# Remove from settings
|
||||
del self._settings[setting_name]
|
||||
|
||||
# Remove from categories
|
||||
for category_settings in self._categories.values():
|
||||
category_settings.discard(setting_name)
|
||||
|
||||
# Remove from Kodi mapping
|
||||
if setting_name in self._kodi_mapping:
|
||||
del self._kodi_mapping[setting_name]
|
||||
|
||||
return True
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert schema to dictionary representation"""
|
||||
return {
|
||||
'provider_name': self.provider_name,
|
||||
'settings': {
|
||||
name: setting.to_dict()
|
||||
for name, setting in self._settings.items()
|
||||
},
|
||||
'categories': {
|
||||
category: list(settings)
|
||||
for category, settings in self._categories.items()
|
||||
},
|
||||
'kodi_mapping': self._kodi_mapping.copy(),
|
||||
'configuration_status': self.get_configuration_completeness()
|
||||
}
|
||||
|
||||
|
||||
class StandardProviderSettings:
|
||||
"""Standard settings schemas for common providers"""
|
||||
|
||||
_registered_schemas: Dict[str, ProviderSettingsSchema] = {}
|
||||
|
||||
@classmethod
|
||||
def get_rtlplus_schema(cls) -> ProviderSettingsSchema:
|
||||
"""Get RTL Plus settings schema"""
|
||||
if 'rtlplus' not in cls._registered_schemas:
|
||||
schema = ProviderSettingsSchema('rtlplus')
|
||||
|
||||
# RTL Plus specific customizations
|
||||
# Override default credential settings for RTL Plus specifics
|
||||
schema._settings['username'].description = "RTL Plus account email address"
|
||||
|
||||
# Add RTL Plus specific settings
|
||||
schema.add_custom_setting(
|
||||
'device_id',
|
||||
string_setting()
|
||||
.display_name("Device ID")
|
||||
.description("Unique device identifier for RTL Plus")
|
||||
.kodi_setting("rtlplus_device_id")
|
||||
.build(),
|
||||
'credentials'
|
||||
)
|
||||
|
||||
cls._registered_schemas['rtlplus'] = schema
|
||||
|
||||
return cls._registered_schemas['rtlplus']
|
||||
|
||||
@classmethod
|
||||
def get_joyn_schema(cls) -> ProviderSettingsSchema:
|
||||
"""Get Joyn settings schema"""
|
||||
if 'joyn' not in cls._registered_schemas:
|
||||
schema = ProviderSettingsSchema('joyn')
|
||||
|
||||
# Joyn doesn't require authentication for basic content
|
||||
schema._settings['username'].validation_rules = [
|
||||
rule for rule in schema._settings['username'].validation_rules
|
||||
if rule.name != "not_empty"
|
||||
]
|
||||
schema._settings['password'].validation_rules = [
|
||||
rule for rule in schema._settings['password'].validation_rules
|
||||
if rule.name != "not_empty"
|
||||
]
|
||||
|
||||
cls._registered_schemas['joyn'] = schema
|
||||
|
||||
return cls._registered_schemas['joyn']
|
||||
|
||||
@classmethod
|
||||
def get_zdf_schema(cls) -> ProviderSettingsSchema:
|
||||
"""Get ZDF settings schema"""
|
||||
if 'zdf' not in cls._registered_schemas:
|
||||
schema = ProviderSettingsSchema('zdf')
|
||||
|
||||
# ZDF is free, no authentication required
|
||||
schema.remove_setting('username')
|
||||
schema.remove_setting('password')
|
||||
schema.remove_setting('client_id')
|
||||
schema.remove_setting('client_secret')
|
||||
|
||||
# Add ZDF specific settings
|
||||
schema.add_custom_setting(
|
||||
'geo_location',
|
||||
select_setting(['DE', 'AT', 'CH'], 'DE')
|
||||
.display_name("Geographic Location")
|
||||
.description("Your geographic location for content filtering")
|
||||
.kodi_setting("zdf_geo_location")
|
||||
.build(),
|
||||
'network'
|
||||
)
|
||||
|
||||
cls._registered_schemas['zdf'] = schema
|
||||
|
||||
return cls._registered_schemas['zdf']
|
||||
|
||||
@classmethod
|
||||
def get_ard_schema(cls) -> ProviderSettingsSchema:
|
||||
"""Get ARD settings schema"""
|
||||
if 'ard' not in cls._registered_schemas:
|
||||
schema = ProviderSettingsSchema('ard')
|
||||
|
||||
# ARD is free, no authentication required
|
||||
schema.remove_setting('username')
|
||||
schema.remove_setting('password')
|
||||
schema.remove_setting('client_id')
|
||||
schema.remove_setting('client_secret')
|
||||
|
||||
cls._registered_schemas['ard'] = schema
|
||||
|
||||
return cls._registered_schemas['ard']
|
||||
|
||||
@classmethod
|
||||
def register_provider_schema(cls, provider_name: str, schema: ProviderSettingsSchema) -> None:
|
||||
"""Register a custom provider schema"""
|
||||
cls._registered_schemas[provider_name] = schema
|
||||
logger.info(f"Registered custom settings schema for provider: {provider_name}")
|
||||
|
||||
@classmethod
|
||||
def get_provider_schema(cls, provider_name: str) -> Optional[ProviderSettingsSchema]:
|
||||
"""Get schema for a provider, creating default if not found"""
|
||||
# Check if we have a specific schema method
|
||||
method_name = f'get_{provider_name}_schema'
|
||||
if hasattr(cls, method_name):
|
||||
return getattr(cls, method_name)()
|
||||
|
||||
# Check registered schemas
|
||||
if provider_name in cls._registered_schemas:
|
||||
return cls._registered_schemas[provider_name]
|
||||
|
||||
# Create default schema
|
||||
logger.info(f"Creating default settings schema for provider: {provider_name}")
|
||||
schema = ProviderSettingsSchema(provider_name)
|
||||
cls._registered_schemas[provider_name] = schema
|
||||
return schema
|
||||
|
||||
@classmethod
|
||||
def list_registered_providers(cls) -> List[str]:
|
||||
"""Get list of all registered provider schemas"""
|
||||
# Include both registered schemas and built-in methods
|
||||
builtin_providers = []
|
||||
for attr_name in dir(cls):
|
||||
if attr_name.startswith('get_') and attr_name.endswith('_schema') and attr_name != 'get_provider_schema':
|
||||
provider_name = attr_name[4:-7] # Remove 'get_' and '_schema'
|
||||
builtin_providers.append(provider_name)
|
||||
|
||||
all_providers = list(set(builtin_providers + list(cls._registered_schemas.keys())))
|
||||
return sorted(all_providers)
|
||||
|
||||
@classmethod
|
||||
def unregister_provider_schema(cls, provider_name: str) -> bool:
|
||||
"""Unregister a provider schema"""
|
||||
if provider_name in cls._registered_schemas:
|
||||
del cls._registered_schemas[provider_name]
|
||||
logger.info(f"Unregistered settings schema for provider: {provider_name}")
|
||||
return True
|
||||
return False
|
||||
@@ -0,0 +1,566 @@
|
||||
# streaming_providers/base/settings/models/settings_models.py
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Optional, Callable, Union, List, Dict
|
||||
from enum import Enum
|
||||
import re
|
||||
import ipaddress
|
||||
from urllib.parse import urlparse
|
||||
|
||||
|
||||
class SettingType(Enum):
|
||||
"""Supported setting value types"""
|
||||
STRING = "string"
|
||||
INTEGER = "integer"
|
||||
BOOLEAN = "boolean"
|
||||
FLOAT = "float"
|
||||
SELECT = "select" # dropdown with predefined options
|
||||
PASSWORD = "password" # masked string input
|
||||
URL = "url" # URL with validation
|
||||
IP_ADDRESS = "ip_address" # IP address validation
|
||||
PORT = "port" # Port number (1-65535)
|
||||
EMAIL = "email" # Email address validation
|
||||
|
||||
|
||||
@dataclass
|
||||
class ValidationRule:
|
||||
"""Validation rule for a setting value"""
|
||||
name: str
|
||||
validator: Callable[[Any], bool]
|
||||
error_message: str
|
||||
|
||||
def validate(self, value: Any) -> bool:
|
||||
"""Validate a setting value against this rule"""
|
||||
try:
|
||||
return self.validator(value)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def get_error_message(self) -> str:
|
||||
"""Get human-readable error message for validation failure"""
|
||||
return self.error_message
|
||||
|
||||
|
||||
class StandardValidationRules:
|
||||
"""Standard validation rules for common use cases"""
|
||||
|
||||
@staticmethod
|
||||
def not_empty(error_msg: str = "Value cannot be empty") -> ValidationRule:
|
||||
"""Rule to ensure value is not empty"""
|
||||
return ValidationRule(
|
||||
name="not_empty",
|
||||
validator=lambda x: x is not None and str(x).strip() != "",
|
||||
error_message=error_msg
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def min_length(min_len: int, error_msg: Optional[str] = None) -> ValidationRule:
|
||||
"""Rule to ensure string has minimum length"""
|
||||
if error_msg is None:
|
||||
error_msg = f"Value must be at least {min_len} characters long"
|
||||
return ValidationRule(
|
||||
name="min_length",
|
||||
validator=lambda x: isinstance(x, str) and len(x) >= min_len,
|
||||
error_message=error_msg
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def max_length(max_len: int, error_msg: Optional[str] = None) -> ValidationRule:
|
||||
"""Rule to ensure string has maximum length"""
|
||||
if error_msg is None:
|
||||
error_msg = f"Value must be at most {max_len} characters long"
|
||||
return ValidationRule(
|
||||
name="max_length",
|
||||
validator=lambda x: isinstance(x, str) and len(x) <= max_len,
|
||||
error_message=error_msg
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def numeric_range(min_val: Union[int, float], max_val: Union[int, float],
|
||||
error_msg: Optional[str] = None) -> ValidationRule:
|
||||
"""Rule to ensure numeric value is within range"""
|
||||
if error_msg is None:
|
||||
error_msg = f"Value must be between {min_val} and {max_val}"
|
||||
return ValidationRule(
|
||||
name="numeric_range",
|
||||
validator=lambda x: isinstance(x, (int, float)) and min_val <= x <= max_val,
|
||||
error_message=error_msg
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def regex_pattern(pattern: str, error_msg: str) -> ValidationRule:
|
||||
"""Rule to validate against regex pattern"""
|
||||
compiled_pattern = re.compile(pattern)
|
||||
return ValidationRule(
|
||||
name="regex_pattern",
|
||||
validator=lambda x: isinstance(x, str) and bool(compiled_pattern.match(x)),
|
||||
error_message=error_msg
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def valid_email(error_msg: str = "Invalid email address format") -> ValidationRule:
|
||||
"""Rule to validate email address"""
|
||||
email_pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
|
||||
return StandardValidationRules.regex_pattern(email_pattern, error_msg)
|
||||
|
||||
@staticmethod
|
||||
def valid_url(error_msg: str = "Invalid URL format") -> ValidationRule:
|
||||
"""Rule to validate URL format"""
|
||||
|
||||
def validate_url(value: str) -> bool:
|
||||
if not isinstance(value, str):
|
||||
return False
|
||||
try:
|
||||
result = urlparse(value)
|
||||
return all([result.scheme, result.netloc])
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
return ValidationRule(
|
||||
name="valid_url",
|
||||
validator=validate_url,
|
||||
error_message=error_msg
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def valid_ip_address(error_msg: str = "Invalid IP address format") -> ValidationRule:
|
||||
"""Rule to validate IP address (IPv4 or IPv6)"""
|
||||
|
||||
def validate_ip(value: str) -> bool:
|
||||
if not isinstance(value, str):
|
||||
return False
|
||||
try:
|
||||
ipaddress.ip_address(value)
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
return ValidationRule(
|
||||
name="valid_ip_address",
|
||||
validator=validate_ip,
|
||||
error_message=error_msg
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def valid_port(error_msg: str = "Port must be between 1 and 65535") -> ValidationRule:
|
||||
"""Rule to validate port number"""
|
||||
return ValidationRule(
|
||||
name="valid_port",
|
||||
validator=lambda x: isinstance(x, int) and 1 <= x <= 65535,
|
||||
error_message=error_msg
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def in_choices(choices: List[Any], error_msg: Optional[str] = None) -> ValidationRule:
|
||||
"""Rule to ensure value is in predefined choices"""
|
||||
if error_msg is None:
|
||||
error_msg = f"Value must be one of: {', '.join(map(str, choices))}"
|
||||
return ValidationRule(
|
||||
name="in_choices",
|
||||
validator=lambda x: x in choices,
|
||||
error_message=error_msg
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SettingValue:
|
||||
"""Container for a setting value with metadata and validation"""
|
||||
setting_type: SettingType
|
||||
default_value: Any = None
|
||||
current_value: Any = None
|
||||
choices: Optional[List[Any]] = None # For SELECT type
|
||||
validation_rules: List[ValidationRule] = field(default_factory=list)
|
||||
description: Optional[str] = None
|
||||
display_name: Optional[str] = None
|
||||
is_sensitive: bool = False # For passwords, secrets, etc.
|
||||
kodi_setting_id: Optional[str] = None # Mapping to Kodi setting ID
|
||||
|
||||
def __post_init__(self):
|
||||
"""Post-initialization setup"""
|
||||
# Set current value to default if not provided
|
||||
if self.current_value is None:
|
||||
self.current_value = self.default_value
|
||||
|
||||
# Add type-specific validation rules
|
||||
self._add_type_validation()
|
||||
|
||||
# Add choices validation for SELECT type
|
||||
if self.setting_type == SettingType.SELECT and self.choices:
|
||||
self.add_validation_rule(
|
||||
StandardValidationRules.in_choices(self.choices)
|
||||
)
|
||||
|
||||
def is_required(self) -> bool:
|
||||
"""Check if this setting is required (has not_empty validation rule)"""
|
||||
return any(rule.name == "not_empty" for rule in self.validation_rules)
|
||||
|
||||
def _add_type_validation(self):
|
||||
"""Add validation rules based on setting type"""
|
||||
if self.setting_type == SettingType.INTEGER:
|
||||
self.add_validation_rule(ValidationRule(
|
||||
name="is_integer",
|
||||
validator=lambda x: isinstance(x, int) or (isinstance(x, str) and x.isdigit()),
|
||||
error_message="Value must be an integer"
|
||||
))
|
||||
|
||||
elif self.setting_type == SettingType.FLOAT:
|
||||
self.add_validation_rule(ValidationRule(
|
||||
name="is_float",
|
||||
validator=lambda x: isinstance(x, (int, float)) or self._is_valid_float_string(x),
|
||||
error_message="Value must be a number"
|
||||
))
|
||||
|
||||
elif self.setting_type == SettingType.BOOLEAN:
|
||||
self.add_validation_rule(ValidationRule(
|
||||
name="is_boolean",
|
||||
validator=lambda x: isinstance(x, bool) or str(x).lower() in ['true', 'false', '1', '0'],
|
||||
error_message="Value must be true or false"
|
||||
))
|
||||
|
||||
elif self.setting_type == SettingType.URL:
|
||||
self.add_validation_rule(StandardValidationRules.valid_url())
|
||||
|
||||
elif self.setting_type == SettingType.IP_ADDRESS:
|
||||
self.add_validation_rule(StandardValidationRules.valid_ip_address())
|
||||
|
||||
elif self.setting_type == SettingType.PORT:
|
||||
self.add_validation_rule(StandardValidationRules.valid_port())
|
||||
|
||||
elif self.setting_type == SettingType.EMAIL:
|
||||
self.add_validation_rule(StandardValidationRules.valid_email())
|
||||
|
||||
def _is_valid_float_string(self, value: Any) -> bool:
|
||||
"""Check if string can be converted to float"""
|
||||
if not isinstance(value, str):
|
||||
return False
|
||||
try:
|
||||
float(value)
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
def set_value(self, value: Any) -> bool:
|
||||
"""
|
||||
Set the setting value with validation and type conversion
|
||||
|
||||
Args:
|
||||
value: New value to set
|
||||
|
||||
Returns:
|
||||
True if value was set successfully, False if validation failed
|
||||
"""
|
||||
# Convert value to appropriate type
|
||||
converted_value = self._convert_value(value)
|
||||
if converted_value is None:
|
||||
return False
|
||||
|
||||
# Validate the converted value
|
||||
if not self._validate_value(converted_value):
|
||||
return False
|
||||
|
||||
self.current_value = converted_value
|
||||
return True
|
||||
|
||||
def _convert_value(self, value: Any) -> Any:
|
||||
"""Convert value to the appropriate type"""
|
||||
if value is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
if self.setting_type == SettingType.STRING or self.setting_type == SettingType.PASSWORD:
|
||||
return str(value)
|
||||
|
||||
elif self.setting_type == SettingType.INTEGER or self.setting_type == SettingType.PORT:
|
||||
if isinstance(value, int):
|
||||
return value
|
||||
elif isinstance(value, str) and value.isdigit():
|
||||
return int(value)
|
||||
else:
|
||||
return None
|
||||
|
||||
elif self.setting_type == SettingType.FLOAT:
|
||||
if isinstance(value, (int, float)):
|
||||
return float(value)
|
||||
elif isinstance(value, str):
|
||||
return float(value)
|
||||
else:
|
||||
return None
|
||||
|
||||
elif self.setting_type == SettingType.BOOLEAN:
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
elif isinstance(value, str):
|
||||
return value.lower() in ['true', '1', 'yes', 'on']
|
||||
elif isinstance(value, int):
|
||||
return bool(value)
|
||||
else:
|
||||
return None
|
||||
|
||||
elif self.setting_type in [SettingType.SELECT, SettingType.URL,
|
||||
SettingType.IP_ADDRESS, SettingType.EMAIL]:
|
||||
return str(value)
|
||||
|
||||
else:
|
||||
return value
|
||||
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
def _validate_value(self, value: Any) -> bool:
|
||||
"""Validate value against all validation rules"""
|
||||
for rule in self.validation_rules:
|
||||
if not rule.validate(value):
|
||||
return False
|
||||
return True
|
||||
|
||||
def get_value(self) -> Any:
|
||||
"""Get the current setting value"""
|
||||
return self.current_value
|
||||
|
||||
def get_display_value(self) -> str:
|
||||
"""Get value formatted for display (masks sensitive values)"""
|
||||
if self.is_sensitive and self.current_value:
|
||||
return "*" * min(len(str(self.current_value)), 8)
|
||||
return str(self.current_value) if self.current_value is not None else ""
|
||||
|
||||
def add_validation_rule(self, rule: ValidationRule) -> None:
|
||||
"""Add a validation rule to this setting"""
|
||||
# Check if rule with same name already exists
|
||||
existing_names = {r.name for r in self.validation_rules}
|
||||
if rule.name not in existing_names:
|
||||
self.validation_rules.append(rule)
|
||||
|
||||
def remove_validation_rule(self, rule_name: str) -> bool:
|
||||
"""Remove a validation rule by name"""
|
||||
original_length = len(self.validation_rules)
|
||||
self.validation_rules = [r for r in self.validation_rules if r.name != rule_name]
|
||||
return len(self.validation_rules) < original_length
|
||||
|
||||
def validate(self) -> tuple[bool, List[str]]:
|
||||
"""
|
||||
Validate current value against all rules
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid, list_of_error_messages)
|
||||
"""
|
||||
if self.current_value is None:
|
||||
# Check if this setting is required (has not_empty rule)
|
||||
has_required_rule = any(rule.name == "not_empty" for rule in self.validation_rules)
|
||||
if has_required_rule:
|
||||
return False, ["Value is required"]
|
||||
else:
|
||||
return True, []
|
||||
|
||||
errors = []
|
||||
for rule in self.validation_rules:
|
||||
if not rule.validate(self.current_value):
|
||||
errors.append(rule.get_error_message())
|
||||
|
||||
return len(errors) == 0, errors
|
||||
|
||||
def reset_to_default(self) -> None:
|
||||
"""Reset value to default"""
|
||||
self.current_value = self.default_value
|
||||
|
||||
def has_value(self) -> bool:
|
||||
"""Check if setting has a non-None value"""
|
||||
return self.current_value is not None
|
||||
|
||||
def is_modified(self) -> bool:
|
||||
"""Check if current value differs from default"""
|
||||
return self.current_value != self.default_value
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary representation"""
|
||||
is_valid, errors = self.validate()
|
||||
|
||||
return {
|
||||
'setting_type': self.setting_type.value,
|
||||
'current_value': self.current_value,
|
||||
'default_value': self.default_value,
|
||||
'choices': self.choices,
|
||||
'description': self.description,
|
||||
'display_name': self.display_name,
|
||||
'is_sensitive': self.is_sensitive,
|
||||
'kodi_setting_id': self.kodi_setting_id,
|
||||
'is_valid': is_valid,
|
||||
'validation_errors': errors,
|
||||
'has_value': self.has_value(),
|
||||
'is_modified': self.is_modified(),
|
||||
'display_value': self.get_display_value()
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'SettingValue':
|
||||
"""Create SettingValue from dictionary representation"""
|
||||
setting = cls(
|
||||
setting_type=SettingType(data['setting_type']),
|
||||
default_value=data.get('default_value'),
|
||||
current_value=data.get('current_value'),
|
||||
choices=data.get('choices'),
|
||||
description=data.get('description'),
|
||||
display_name=data.get('display_name'),
|
||||
is_sensitive=data.get('is_sensitive', False),
|
||||
kodi_setting_id=data.get('kodi_setting_id')
|
||||
)
|
||||
return setting
|
||||
|
||||
def clone(self) -> 'SettingValue':
|
||||
"""Create a copy of this setting"""
|
||||
return SettingValue(
|
||||
setting_type=self.setting_type,
|
||||
default_value=self.default_value,
|
||||
current_value=self.current_value,
|
||||
choices=self.choices.copy() if self.choices else None,
|
||||
validation_rules=self.validation_rules.copy(),
|
||||
description=self.description,
|
||||
display_name=self.display_name,
|
||||
is_sensitive=self.is_sensitive,
|
||||
kodi_setting_id=self.kodi_setting_id
|
||||
)
|
||||
|
||||
|
||||
class SettingValueBuilder:
|
||||
"""Builder pattern for creating SettingValue instances"""
|
||||
|
||||
def __init__(self, setting_type: SettingType):
|
||||
self._setting_type = setting_type
|
||||
self._default_value = None
|
||||
self._choices = None
|
||||
self._validation_rules = []
|
||||
self._description = None
|
||||
self._display_name = None
|
||||
self._is_sensitive = False
|
||||
self._kodi_setting_id = None
|
||||
|
||||
def default(self, value: Any) -> 'SettingValueBuilder':
|
||||
"""Set default value"""
|
||||
self._default_value = value
|
||||
return self
|
||||
|
||||
def choices(self, choices: List[Any]) -> 'SettingValueBuilder':
|
||||
"""Set choices for SELECT type"""
|
||||
self._choices = choices
|
||||
return self
|
||||
|
||||
def description(self, desc: str) -> 'SettingValueBuilder':
|
||||
"""Set description"""
|
||||
self._description = desc
|
||||
return self
|
||||
|
||||
def display_name(self, name: str) -> 'SettingValueBuilder':
|
||||
"""Set display name"""
|
||||
self._display_name = name
|
||||
return self
|
||||
|
||||
def sensitive(self, is_sensitive: bool = True) -> 'SettingValueBuilder':
|
||||
"""Mark as sensitive (for passwords, etc.)"""
|
||||
self._is_sensitive = is_sensitive
|
||||
return self
|
||||
|
||||
def kodi_setting(self, setting_id: str) -> 'SettingValueBuilder':
|
||||
"""Set Kodi setting ID mapping"""
|
||||
self._kodi_setting_id = setting_id
|
||||
return self
|
||||
|
||||
def required(self) -> 'SettingValueBuilder':
|
||||
"""Mark as required (not empty)"""
|
||||
self._validation_rules.append(StandardValidationRules.not_empty())
|
||||
return self
|
||||
|
||||
def min_length(self, length: int) -> 'SettingValueBuilder':
|
||||
"""Add minimum length validation"""
|
||||
self._validation_rules.append(StandardValidationRules.min_length(length))
|
||||
return self
|
||||
|
||||
def max_length(self, length: int) -> 'SettingValueBuilder':
|
||||
"""Add maximum length validation"""
|
||||
self._validation_rules.append(StandardValidationRules.max_length(length))
|
||||
return self
|
||||
|
||||
def numeric_range(self, min_val: Union[int, float], max_val: Union[int, float]) -> 'SettingValueBuilder':
|
||||
"""Add numeric range validation"""
|
||||
self._validation_rules.append(StandardValidationRules.numeric_range(min_val, max_val))
|
||||
return self
|
||||
|
||||
def custom_validation(self, rule: ValidationRule) -> 'SettingValueBuilder':
|
||||
"""Add custom validation rule"""
|
||||
self._validation_rules.append(rule)
|
||||
return self
|
||||
|
||||
def build(self) -> SettingValue:
|
||||
"""Build the SettingValue instance"""
|
||||
setting = SettingValue(
|
||||
setting_type=self._setting_type,
|
||||
default_value=self._default_value,
|
||||
choices=self._choices,
|
||||
description=self._description,
|
||||
display_name=self._display_name,
|
||||
is_sensitive=self._is_sensitive,
|
||||
kodi_setting_id=self._kodi_setting_id
|
||||
)
|
||||
|
||||
# Add custom validation rules
|
||||
for rule in self._validation_rules:
|
||||
setting.add_validation_rule(rule)
|
||||
|
||||
return setting
|
||||
|
||||
|
||||
# Convenience factory functions
|
||||
def string_setting(default: str = "", required: bool = False) -> SettingValueBuilder:
|
||||
"""Create a string setting builder"""
|
||||
builder = SettingValueBuilder(SettingType.STRING).default(default)
|
||||
if required:
|
||||
builder.required()
|
||||
return builder
|
||||
|
||||
|
||||
def password_setting(required: bool = True) -> SettingValueBuilder:
|
||||
"""Create a password setting builder"""
|
||||
builder = SettingValueBuilder(SettingType.PASSWORD).sensitive(True)
|
||||
if required:
|
||||
builder.required()
|
||||
return builder
|
||||
|
||||
|
||||
def integer_setting(default: int = 0, min_val: Optional[int] = None,
|
||||
max_val: Optional[int] = None) -> SettingValueBuilder:
|
||||
"""Create an integer setting builder"""
|
||||
builder = SettingValueBuilder(SettingType.INTEGER).default(default)
|
||||
if min_val is not None and max_val is not None:
|
||||
builder.numeric_range(min_val, max_val)
|
||||
return builder
|
||||
|
||||
|
||||
def boolean_setting(default: bool = False) -> SettingValueBuilder:
|
||||
"""Create a boolean setting builder"""
|
||||
return SettingValueBuilder(SettingType.BOOLEAN).default(default)
|
||||
|
||||
|
||||
def select_setting(choices: List[Any], default: Any = None) -> SettingValueBuilder:
|
||||
"""Create a select setting builder"""
|
||||
builder = SettingValueBuilder(SettingType.SELECT).choices(choices)
|
||||
if default is not None:
|
||||
builder.default(default)
|
||||
return builder
|
||||
|
||||
|
||||
def url_setting(required: bool = False) -> SettingValueBuilder:
|
||||
"""Create a URL setting builder"""
|
||||
builder = SettingValueBuilder(SettingType.URL)
|
||||
if required:
|
||||
builder.required()
|
||||
return builder
|
||||
|
||||
|
||||
def port_setting(default: int = 8080) -> SettingValueBuilder:
|
||||
"""Create a port setting builder"""
|
||||
return SettingValueBuilder(SettingType.PORT).default(default)
|
||||
|
||||
|
||||
def ip_setting(required: bool = False) -> SettingValueBuilder:
|
||||
"""Create an IP address setting builder"""
|
||||
builder = SettingValueBuilder(SettingType.IP_ADDRESS)
|
||||
if required:
|
||||
builder.required()
|
||||
return builder
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,16 @@
|
||||
# streaming_providers/base/utils/__init__.py
|
||||
|
||||
from .logger import logger, XBMCLogger
|
||||
from .manifest_parser import ManifestParser
|
||||
from .vfs import VFS
|
||||
from .mpd_rewriter import MPDRewriter
|
||||
from .mpd_cache import MPDCacheManager
|
||||
|
||||
__all__ = [
|
||||
'logger',
|
||||
'XBMCLogger',
|
||||
'ManifestParser',
|
||||
'VFS',
|
||||
'MPDRewriter',
|
||||
'MPDCacheManager'
|
||||
]
|
||||
@@ -0,0 +1,63 @@
|
||||
# streaming_providers/base/utils/logger.py
|
||||
import xbmc
|
||||
|
||||
|
||||
class XBMCLogger:
|
||||
"""Centralized logging using XBMC's logging system."""
|
||||
|
||||
def __init__(self, addon_name: str, addon_version: str):
|
||||
self.addon_name = addon_name
|
||||
self.addon_version = addon_version
|
||||
self.prefix = f"[{addon_name} v{addon_version}]"
|
||||
|
||||
def log(self, message: str, level: int = xbmc.LOGINFO) -> None:
|
||||
"""Main logging method.
|
||||
|
||||
Args:
|
||||
message: The message to log
|
||||
level: One of xbmc.LOGDEBUG, LOGINFO, LOGWARNING, LOGERROR, LOGFATAL
|
||||
"""
|
||||
xbmc.log(f"{self.prefix} {message}", level)
|
||||
|
||||
def debug(self, message: str) -> None:
|
||||
self.log(message, xbmc.LOGDEBUG)
|
||||
|
||||
def info(self, message: str) -> None:
|
||||
self.log(message, xbmc.LOGINFO)
|
||||
|
||||
def warning(self, message: str) -> None:
|
||||
self.log(message, xbmc.LOGWARNING)
|
||||
|
||||
def error(self, message: str) -> None:
|
||||
self.log(message, xbmc.LOGERROR)
|
||||
|
||||
def critical(self, message: str) -> None:
|
||||
self.log(message, xbmc.LOGFATAL)
|
||||
|
||||
def log_auth_event(self, provider: str, event: str, details: str = "") -> None:
|
||||
"""Specialized logging method for authentication events."""
|
||||
message = f"AUTH [{provider}] {event}"
|
||||
if details:
|
||||
message += f" - {details}"
|
||||
self.info(message)
|
||||
|
||||
def log_credential_event(self, provider: str, event: str, details: str = "") -> None:
|
||||
"""Specialized logging method for credential management events."""
|
||||
message = f"CRED [{provider}] {event}"
|
||||
if details:
|
||||
message += f" - {details}"
|
||||
self.info(message)
|
||||
|
||||
def log_session_event(self, provider: str, event: str, details: str = "") -> None:
|
||||
"""Specialized logging method for session management events."""
|
||||
message = f"SESSION [{provider}] {event}"
|
||||
if details:
|
||||
message += f" - {details}"
|
||||
self.debug(message)
|
||||
|
||||
|
||||
# Initialize with your addon info
|
||||
logger = XBMCLogger(
|
||||
addon_name="Ultimate Backend",
|
||||
addon_version="1.0.0" # You could get this from your addon.xml
|
||||
)
|
||||
@@ -0,0 +1,116 @@
|
||||
import re
|
||||
import base64
|
||||
from ..models.drm_models import PSSHData
|
||||
from .logger import logger
|
||||
|
||||
class ManifestParser:
|
||||
@staticmethod
|
||||
def extract_pssh_from_manifest(manifest_content: str, manifest_url: str = "", return_collection: bool = True):
|
||||
logger.debug("Starting PSSH extraction from manifest")
|
||||
|
||||
# Check if this looks like a DASH manifest
|
||||
is_dash = ('<MPD' in manifest_content) or ('mpd' in manifest_content.lower())
|
||||
logger.debug(f"Manifest appears to be DASH format: {is_dash}")
|
||||
|
||||
if not is_dash:
|
||||
logger.debug("Not a DASH manifest, skipping PSSH extraction")
|
||||
return []
|
||||
|
||||
try:
|
||||
pssh_list = ManifestParser._extract_with_regex(manifest_content)
|
||||
logger.debug(f"Found {len(pssh_list)} potential PSSH entries")
|
||||
|
||||
valid_pssh = []
|
||||
for pssh in pssh_list:
|
||||
if pssh.pssh_box and pssh.system_id:
|
||||
valid_pssh.append(pssh)
|
||||
logger.debug(f"Valid PSSH found - System ID: {pssh.system_id}")
|
||||
else:
|
||||
logger.debug("Invalid PSSH entry skipped")
|
||||
|
||||
return valid_pssh
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in PSSH extraction: {str(e)}")
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
def _extract_with_regex(mpd_content: str):
|
||||
"""Simplified PSSH extraction using regular expressions with debug logging"""
|
||||
logger.debug("Starting regex PSSH extraction")
|
||||
|
||||
pssh_dict = {} # Use dict to automatically handle deduplication
|
||||
global_key_ids = [] # Collect all KIDs from the manifest
|
||||
|
||||
# Regex patterns
|
||||
pssh_pattern = r'<(?:cenc:)?pssh[^>]*>([^<]+)</(?:cenc:)?pssh>'
|
||||
default_kid_pattern = r'(?:cenc:)?default_KID="([^"]+)"'
|
||||
system_id_pattern = r'schemeIdUri="urn:uuid:([^"]+)"'
|
||||
|
||||
logger.debug("Searching for ContentProtection blocks")
|
||||
cp_blocks = re.findall(r'<ContentProtection[^>]*>.*?</ContentProtection>', mpd_content, re.DOTALL)
|
||||
logger.debug(f"Found {len(cp_blocks)} ContentProtection blocks")
|
||||
|
||||
# First pass: collect all default KIDs from the entire manifest
|
||||
for block in cp_blocks:
|
||||
kid_match = re.search(default_kid_pattern, block)
|
||||
if kid_match:
|
||||
clean_kid = kid_match.group(1).replace("-", "").lower()
|
||||
if clean_kid not in global_key_ids:
|
||||
global_key_ids.append(clean_kid)
|
||||
logger.debug(f"Found global default KID: {clean_kid}")
|
||||
|
||||
# Second pass: extract PSSH data
|
||||
for i, block in enumerate(cp_blocks, 1):
|
||||
try:
|
||||
logger.debug(f"Processing block {i}/{len(cp_blocks)}")
|
||||
system_id = None
|
||||
|
||||
# Extract system ID from schemeIdUri
|
||||
scheme_match = re.search(system_id_pattern, block)
|
||||
if scheme_match:
|
||||
system_id = scheme_match.group(1).lower()
|
||||
logger.debug(f"Found system ID in schemeIdUri: {system_id}")
|
||||
|
||||
# Extract PSSH data
|
||||
pssh_matches = re.findall(pssh_pattern, block)
|
||||
logger.debug(f"Found {len(pssh_matches)} PSSH elements in block")
|
||||
|
||||
for pssh_b64 in pssh_matches:
|
||||
try:
|
||||
logger.debug(f"Processing PSSH (first 30 chars): {pssh_b64[:30]}...")
|
||||
pssh_data = base64.b64decode(pssh_b64)
|
||||
|
||||
if len(pssh_data) >= 28:
|
||||
# Extract system ID from PSSH if not found in schemeIdUri
|
||||
if not system_id:
|
||||
system_id_bytes = pssh_data[12:28]
|
||||
system_id = '-'.join([
|
||||
system_id_bytes[0:4].hex(),
|
||||
system_id_bytes[4:6].hex(),
|
||||
system_id_bytes[6:8].hex(),
|
||||
system_id_bytes[8:10].hex(),
|
||||
system_id_bytes[10:16].hex()
|
||||
])
|
||||
logger.debug(f"Extracted system ID from PSSH: {system_id}")
|
||||
|
||||
# Use PSSH box as key for deduplication
|
||||
if pssh_b64 not in pssh_dict:
|
||||
pssh_dict[pssh_b64] = PSSHData(
|
||||
system_id=system_id,
|
||||
pssh_box=pssh_b64,
|
||||
key_ids=global_key_ids.copy() # Add all global KIDs to each PSSH
|
||||
)
|
||||
logger.debug("Successfully added new PSSH entry")
|
||||
else:
|
||||
logger.debug("PSSH already exists, skipping duplicate")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error decoding PSSH: {str(e)}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing ContentProtection block: {str(e)}")
|
||||
|
||||
pssh_list = list(pssh_dict.values())
|
||||
logger.debug(f"Completed PSSH extraction, found {len(pssh_list)} unique entries")
|
||||
return pssh_list
|
||||
@@ -0,0 +1,238 @@
|
||||
# streaming_providers/base/utils/mpd_cache.py
|
||||
import time
|
||||
from typing import Optional
|
||||
from .vfs import VFS
|
||||
from .logger import logger
|
||||
|
||||
|
||||
class MPDCacheManager:
|
||||
"""
|
||||
Manages caching of rewritten MPD manifests with TTL support
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize MPD cache manager with VFS"""
|
||||
self.vfs = VFS(addon_subdir="mpd_cache")
|
||||
logger.debug(f"MPD cache initialized at: {self.vfs.base_path}")
|
||||
|
||||
def _get_cache_key(self, provider: str, channel_id: str) -> str:
|
||||
"""Generate cache key for provider/channel"""
|
||||
return f"{provider}_{channel_id}"
|
||||
|
||||
def _get_manifest_filename(self, cache_key: str) -> str:
|
||||
"""Get filename for cached manifest"""
|
||||
return f"{cache_key}.xml"
|
||||
|
||||
def _get_meta_filename(self, cache_key: str) -> str:
|
||||
"""Get filename for cache metadata"""
|
||||
return f"{cache_key}.meta"
|
||||
|
||||
def get(self, provider: str, channel_id: str) -> Optional[str]:
|
||||
"""
|
||||
Get cached MPD manifest if valid
|
||||
|
||||
Args:
|
||||
provider: Provider name
|
||||
channel_id: Channel ID
|
||||
|
||||
Returns:
|
||||
Cached MPD content if valid and not expired, None otherwise
|
||||
"""
|
||||
cache_key = self._get_cache_key(provider, channel_id)
|
||||
meta_file = self._get_meta_filename(cache_key)
|
||||
manifest_file = self._get_manifest_filename(cache_key)
|
||||
|
||||
try:
|
||||
# Read metadata first (small file)
|
||||
meta = self.vfs.read_json(meta_file)
|
||||
if not meta:
|
||||
logger.debug(f"No cache metadata found for {cache_key}")
|
||||
return None
|
||||
|
||||
# Check expiry
|
||||
expiry = meta.get('expiry', 0)
|
||||
now = int(time.time())
|
||||
|
||||
if now >= expiry:
|
||||
logger.debug(f"Cache expired for {cache_key} (expired {now - expiry}s ago)")
|
||||
# Clean up expired cache
|
||||
self.vfs.delete(manifest_file)
|
||||
self.vfs.delete(meta_file)
|
||||
return None
|
||||
|
||||
# Cache is valid, read manifest
|
||||
manifest_content = self.vfs.read_text(manifest_file)
|
||||
if manifest_content:
|
||||
logger.info(f"Cache hit for {cache_key} (expires in {expiry - now}s)")
|
||||
return manifest_content
|
||||
else:
|
||||
logger.warning(f"Cache metadata exists but manifest file missing for {cache_key}")
|
||||
self.vfs.delete(meta_file)
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error reading cache for {cache_key}: {e}")
|
||||
return None
|
||||
|
||||
def set(self, provider: str, channel_id: str, mpd_content: str,
|
||||
ttl: int, original_url: Optional[str] = None) -> bool:
|
||||
"""
|
||||
Store MPD manifest in cache with TTL
|
||||
|
||||
Args:
|
||||
provider: Provider name
|
||||
channel_id: Channel ID
|
||||
mpd_content: Rewritten MPD content
|
||||
ttl: Time to live in seconds
|
||||
original_url: Original manifest URL (for debugging)
|
||||
|
||||
Returns:
|
||||
True if successfully cached, False otherwise
|
||||
"""
|
||||
cache_key = self._get_cache_key(provider, channel_id)
|
||||
meta_file = self._get_meta_filename(cache_key)
|
||||
manifest_file = self._get_manifest_filename(cache_key)
|
||||
|
||||
try:
|
||||
# Calculate expiry timestamp
|
||||
expiry = int(time.time()) + ttl
|
||||
|
||||
# Create metadata
|
||||
meta = {
|
||||
'expiry': expiry,
|
||||
'ttl': ttl,
|
||||
'cached_at': int(time.time()),
|
||||
'provider': provider,
|
||||
'channel_id': channel_id
|
||||
}
|
||||
|
||||
if original_url:
|
||||
meta['original_url'] = original_url
|
||||
|
||||
# Write manifest
|
||||
if not self.vfs.write_text(manifest_file, mpd_content):
|
||||
logger.error(f"Failed to write manifest cache for {cache_key}")
|
||||
return False
|
||||
|
||||
# Write metadata
|
||||
if not self.vfs.write_json(meta_file, meta):
|
||||
logger.error(f"Failed to write metadata cache for {cache_key}")
|
||||
# Clean up manifest if metadata write failed
|
||||
self.vfs.delete(manifest_file)
|
||||
return False
|
||||
|
||||
logger.info(f"Cached MPD for {cache_key} with TTL={ttl}s (expires at {expiry})")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error caching MPD for {cache_key}: {e}")
|
||||
return False
|
||||
|
||||
def delete(self, provider: str, channel_id: str) -> bool:
|
||||
"""
|
||||
Delete cached MPD for a channel
|
||||
|
||||
Args:
|
||||
provider: Provider name
|
||||
channel_id: Channel ID
|
||||
|
||||
Returns:
|
||||
True if deleted or didn't exist, False on error
|
||||
"""
|
||||
cache_key = self._get_cache_key(provider, channel_id)
|
||||
meta_file = self._get_meta_filename(cache_key)
|
||||
manifest_file = self._get_manifest_filename(cache_key)
|
||||
|
||||
try:
|
||||
self.vfs.delete(manifest_file)
|
||||
self.vfs.delete(meta_file)
|
||||
logger.debug(f"Deleted cache for {cache_key}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting cache for {cache_key}: {e}")
|
||||
return False
|
||||
|
||||
def clear_all(self) -> bool:
|
||||
"""
|
||||
Clear all cached MPD files
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
files = self.vfs.listdir()
|
||||
deleted = 0
|
||||
|
||||
for file in files:
|
||||
if file.endswith('.xml') or file.endswith('.meta'):
|
||||
if self.vfs.delete(file):
|
||||
deleted += 1
|
||||
|
||||
logger.info(f"Cleared {deleted} cached MPD files")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error clearing MPD cache: {e}")
|
||||
return False
|
||||
|
||||
def clear_expired(self) -> int:
|
||||
"""
|
||||
Clear all expired cached MPD files
|
||||
|
||||
Returns:
|
||||
Number of expired entries cleared
|
||||
"""
|
||||
try:
|
||||
files = self.vfs.listdir()
|
||||
now = int(time.time())
|
||||
cleared = 0
|
||||
|
||||
# Find all meta files
|
||||
meta_files = [f for f in files if f.endswith('.meta')]
|
||||
|
||||
for meta_file in meta_files:
|
||||
try:
|
||||
meta = self.vfs.read_json(meta_file)
|
||||
if meta and meta.get('expiry', 0) < now:
|
||||
# Expired, delete both meta and manifest
|
||||
cache_key = meta_file.replace('.meta', '')
|
||||
self.vfs.delete(f"{cache_key}.xml")
|
||||
self.vfs.delete(meta_file)
|
||||
cleared += 1
|
||||
logger.debug(f"Cleared expired cache: {cache_key}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Error checking {meta_file}: {e}")
|
||||
|
||||
if cleared > 0:
|
||||
logger.info(f"Cleared {cleared} expired MPD cache entries")
|
||||
|
||||
return cleared
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error clearing expired caches: {e}")
|
||||
return 0
|
||||
|
||||
def get_cache_info(self, provider: str, channel_id: str) -> Optional[dict]:
|
||||
"""
|
||||
Get cache information without reading the full manifest
|
||||
|
||||
Args:
|
||||
provider: Provider name
|
||||
channel_id: Channel ID
|
||||
|
||||
Returns:
|
||||
Cache metadata dict or None if not cached
|
||||
"""
|
||||
cache_key = self._get_cache_key(provider, channel_id)
|
||||
meta_file = self._get_meta_filename(cache_key)
|
||||
|
||||
try:
|
||||
meta = self.vfs.read_json(meta_file)
|
||||
if meta:
|
||||
now = int(time.time())
|
||||
meta['expired'] = now >= meta.get('expiry', 0)
|
||||
meta['remaining_ttl'] = max(0, meta.get('expiry', 0) - now)
|
||||
return meta
|
||||
except Exception as e:
|
||||
logger.debug(f"Error getting cache info for {cache_key}: {e}")
|
||||
return None
|
||||
@@ -0,0 +1,362 @@
|
||||
# streaming_providers/base/utils/mpd_rewriter.py
|
||||
import xml.etree.ElementTree as ET
|
||||
import base64
|
||||
from typing import Optional, Tuple
|
||||
from urllib.parse import urljoin, urlparse
|
||||
from .logger import logger
|
||||
|
||||
|
||||
class MPDRewriter:
|
||||
"""
|
||||
Utility for rewriting MPD (MPEG-DASH) manifest URLs to point to proxy endpoints
|
||||
|
||||
Strategy:
|
||||
- Remove all BaseURL elements
|
||||
- Convert all relative URLs to absolute URLs
|
||||
- Rewrite all absolute URLs to proxy endpoint
|
||||
- Keep template variables visible for client-side substitution
|
||||
"""
|
||||
|
||||
# MPD namespace
|
||||
MPD_NAMESPACE = {'mpd': 'urn:mpeg:dash:schema:mpd:2011'}
|
||||
|
||||
def __init__(self, proxy_base_url: str, provider_name: str):
|
||||
"""
|
||||
Initialize MPD rewriter
|
||||
|
||||
Args:
|
||||
proxy_base_url: Base URL of the proxy service (e.g., http://localhost:7777)
|
||||
provider_name: Name of the provider for proxy routing
|
||||
"""
|
||||
self.proxy_base_url = proxy_base_url.rstrip('/')
|
||||
self.provider_name = provider_name
|
||||
|
||||
@staticmethod
|
||||
def encode_url(url: str) -> str:
|
||||
"""Encode URL to base64 for use in proxy endpoint"""
|
||||
return base64.urlsafe_b64encode(url.encode('utf-8')).decode('utf-8')
|
||||
|
||||
@staticmethod
|
||||
def decode_url(encoded: str) -> str:
|
||||
"""Decode base64 URL from proxy endpoint"""
|
||||
return base64.urlsafe_b64decode(encoded.encode('utf-8')).decode('utf-8')
|
||||
|
||||
def build_proxy_url(self, original_url: str, template_pattern: Optional[str] = None) -> str:
|
||||
"""
|
||||
Build proxy URL for an original media URL
|
||||
|
||||
Args:
|
||||
original_url: Original URL to be proxied (base path for templates)
|
||||
template_pattern: Optional template pattern to append (e.g., "segment-$Number$.m4s")
|
||||
|
||||
Returns:
|
||||
Proxy URL
|
||||
"""
|
||||
encoded = self.encode_url(original_url)
|
||||
proxy_url = f"{self.proxy_base_url}/api/proxy/{self.provider_name}/{encoded}"
|
||||
|
||||
# Append template pattern if provided (keeps variables visible for client)
|
||||
if template_pattern:
|
||||
proxy_url += f"/{template_pattern}"
|
||||
|
||||
return proxy_url
|
||||
|
||||
@staticmethod
|
||||
def split_template_url(url: str) -> Tuple[str, Optional[str]]:
|
||||
"""
|
||||
Split a URL with template variables into base path and template pattern
|
||||
|
||||
Args:
|
||||
url: URL potentially containing template variables (e.g., $Number$)
|
||||
|
||||
Returns:
|
||||
Tuple of (base_path, template_pattern)
|
||||
- base_path: URL up to the last slash before any template variable
|
||||
- template_pattern: Path with template variables, or None if no templates
|
||||
"""
|
||||
if '$' not in url:
|
||||
return url, None
|
||||
|
||||
# Find the position of the first template variable
|
||||
first_template_pos = url.find('$')
|
||||
|
||||
# Find the last slash BEFORE the first template variable
|
||||
# This handles cases like:
|
||||
# - https://cdn.com/path/segment-$Number$.m4s
|
||||
# - https://cdn.com/path/$RepresentationID$/init.mp4
|
||||
last_slash_before_template = url.rfind('/', 0, first_template_pos)
|
||||
|
||||
if last_slash_before_template == -1:
|
||||
# No slash found before template, entire URL is template (unusual but handle it)
|
||||
return '', url
|
||||
|
||||
base_path = url[:last_slash_before_template]
|
||||
template_pattern = url[last_slash_before_template + 1:]
|
||||
|
||||
return base_path, template_pattern
|
||||
|
||||
def rewrite_mpd(self, mpd_content: str, manifest_url: str) -> str:
|
||||
"""
|
||||
Rewrite MPD content to use proxy URLs
|
||||
|
||||
Strategy:
|
||||
1. Parse MPD XML
|
||||
2. Extract and remove all BaseURL elements
|
||||
3. Resolve all relative URLs to absolute using BaseURLs and manifest URL
|
||||
4. Rewrite all absolute URLs to proxy endpoints
|
||||
5. Keep template variables visible for client substitution
|
||||
|
||||
Args:
|
||||
mpd_content: Original MPD XML content
|
||||
manifest_url: URL where the manifest was fetched from (for relative URL resolution)
|
||||
|
||||
Returns:
|
||||
Rewritten MPD XML content
|
||||
"""
|
||||
try:
|
||||
# Parse XML
|
||||
root = ET.fromstring(mpd_content)
|
||||
|
||||
# Register namespace to preserve it in output
|
||||
ET.register_namespace('', self.MPD_NAMESPACE['mpd'])
|
||||
|
||||
# Get base URL for relative resolution
|
||||
base_url = self._extract_base_url(root, manifest_url)
|
||||
|
||||
# Remove all BaseURL elements (Option 3 strategy)
|
||||
self._remove_base_urls(root)
|
||||
|
||||
# Rewrite all URLs in the MPD
|
||||
self._rewrite_urls_recursive(root, base_url)
|
||||
|
||||
# Convert back to string
|
||||
rewritten = ET.tostring(root, encoding='unicode', method='xml')
|
||||
|
||||
# Add XML declaration if not present
|
||||
if not rewritten.startswith('<?xml'):
|
||||
rewritten = '<?xml version="1.0" encoding="UTF-8"?>\n' + rewritten
|
||||
|
||||
logger.debug(f"Successfully rewrote MPD for provider '{self.provider_name}'")
|
||||
return rewritten
|
||||
|
||||
except ET.ParseError as e:
|
||||
logger.error(f"Failed to parse MPD XML: {e}")
|
||||
raise ValueError(f"Invalid MPD XML: {e}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to rewrite MPD: {e}")
|
||||
raise
|
||||
|
||||
def _extract_base_url(self, root: ET.Element, manifest_url: str) -> str:
|
||||
"""
|
||||
Extract base URL from MPD or use manifest URL
|
||||
|
||||
Priority:
|
||||
1. First BaseURL element in MPD
|
||||
2. Manifest URL's directory
|
||||
|
||||
Args:
|
||||
root: MPD root element
|
||||
manifest_url: URL where manifest was fetched
|
||||
|
||||
Returns:
|
||||
Base URL for resolving relative URLs
|
||||
"""
|
||||
# Try to find BaseURL element
|
||||
base_url_elem = root.find('.//mpd:BaseURL', self.MPD_NAMESPACE)
|
||||
if base_url_elem is not None and base_url_elem.text:
|
||||
base_url = base_url_elem.text.strip()
|
||||
logger.debug(f"Using BaseURL from MPD: {base_url}")
|
||||
return base_url
|
||||
|
||||
# Fall back to manifest URL directory
|
||||
parsed = urlparse(manifest_url)
|
||||
base_url = f"{parsed.scheme}://{parsed.netloc}{parsed.path.rsplit('/', 1)[0]}/"
|
||||
logger.debug(f"Using manifest URL directory as base: {base_url}")
|
||||
return base_url
|
||||
|
||||
def _remove_base_urls(self, root: ET.Element) -> None:
|
||||
"""
|
||||
Remove all BaseURL elements from MPD (Option 3 strategy)
|
||||
|
||||
Args:
|
||||
root: MPD root element
|
||||
"""
|
||||
# Find all BaseURL elements at any level
|
||||
for parent in root.findall('.//*'):
|
||||
for base_url_elem in list(parent.findall('mpd:BaseURL', self.MPD_NAMESPACE)):
|
||||
parent.remove(base_url_elem)
|
||||
logger.debug("Removed BaseURL element")
|
||||
|
||||
def _rewrite_urls_recursive(self, element: ET.Element, base_url: str) -> None:
|
||||
"""
|
||||
Recursively rewrite all URLs in MPD element tree
|
||||
|
||||
Args:
|
||||
element: Current XML element
|
||||
base_url: Base URL for resolving relative URLs
|
||||
"""
|
||||
# Attributes that contain URLs
|
||||
url_attributes = [
|
||||
'media', # SegmentTemplate
|
||||
'initialization', # SegmentTemplate
|
||||
'sourceURL', # Initialization, RepresentationIndex
|
||||
'indexRange', # SegmentBase (not a URL but can be affected)
|
||||
]
|
||||
|
||||
# Rewrite URL attributes in current element
|
||||
for attr in url_attributes:
|
||||
if attr in element.attrib:
|
||||
original_url = element.attrib[attr]
|
||||
|
||||
if not original_url:
|
||||
continue
|
||||
|
||||
# Resolve to absolute URL first
|
||||
resolved = urljoin(base_url, original_url)
|
||||
|
||||
# Check if URL contains template variables
|
||||
if '$' in resolved:
|
||||
# Split into base path and template pattern
|
||||
base_path, template_pattern = self.split_template_url(resolved)
|
||||
element.attrib[attr] = self.build_proxy_url(base_path, template_pattern)
|
||||
logger.debug(f"Rewrote template URL: {original_url} -> proxy with template {template_pattern}")
|
||||
else:
|
||||
# Regular URL without templates
|
||||
element.attrib[attr] = self.build_proxy_url(resolved)
|
||||
logger.debug(f"Rewrote URL: {original_url} -> proxy")
|
||||
|
||||
# Handle SegmentURL elements (used in SegmentList)
|
||||
if element.tag.endswith('SegmentURL'):
|
||||
if 'media' in element.attrib:
|
||||
original_url = element.attrib['media']
|
||||
resolved = urljoin(base_url, original_url)
|
||||
|
||||
# SegmentURL typically doesn't have templates, but handle it just in case
|
||||
if '$' in resolved:
|
||||
base_path, template_pattern = self.split_template_url(resolved)
|
||||
element.attrib['media'] = self.build_proxy_url(base_path, template_pattern)
|
||||
else:
|
||||
element.attrib['media'] = self.build_proxy_url(resolved)
|
||||
|
||||
# Recurse to child elements
|
||||
for child in element:
|
||||
self._rewrite_urls_recursive(child, base_url)
|
||||
|
||||
@staticmethod
|
||||
def extract_cache_ttl(headers: dict) -> int:
|
||||
"""
|
||||
Extract cache TTL from HTTP response headers
|
||||
|
||||
Priority:
|
||||
1. Cache-Control: max-age=X
|
||||
2. Expires header
|
||||
3. Default to 300 seconds (5 minutes)
|
||||
|
||||
Args:
|
||||
headers: HTTP response headers dict
|
||||
|
||||
Returns:
|
||||
Cache TTL in seconds
|
||||
"""
|
||||
# Check Cache-Control header
|
||||
cache_control = headers.get('Cache-Control', headers.get('cache-control', ''))
|
||||
if 'max-age=' in cache_control:
|
||||
try:
|
||||
# Extract max-age value
|
||||
for directive in cache_control.split(','):
|
||||
directive = directive.strip()
|
||||
if directive.startswith('max-age='):
|
||||
max_age = int(directive.split('=')[1])
|
||||
logger.debug(f"Cache TTL from Cache-Control: {max_age}s")
|
||||
return max_age
|
||||
except (ValueError, IndexError) as e:
|
||||
logger.warning(f"Failed to parse max-age from Cache-Control: {e}")
|
||||
|
||||
# Check Expires header
|
||||
expires = headers.get('Expires', headers.get('expires'))
|
||||
if expires:
|
||||
try:
|
||||
from email.utils import parsedate_to_datetime
|
||||
from datetime import datetime, timezone
|
||||
|
||||
expires_dt = parsedate_to_datetime(expires)
|
||||
now = datetime.now(timezone.utc)
|
||||
ttl = int((expires_dt - now).total_seconds())
|
||||
|
||||
if ttl > 0:
|
||||
logger.debug(f"Cache TTL from Expires: {ttl}s")
|
||||
return ttl
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to parse Expires header: {e}")
|
||||
|
||||
# Default TTL
|
||||
default_ttl = 300
|
||||
logger.debug(f"Using default cache TTL: {default_ttl}s")
|
||||
return default_ttl
|
||||
|
||||
@staticmethod
|
||||
def extract_mpd_update_period(mpd_content: str) -> Optional[int]:
|
||||
"""
|
||||
Extract minimumUpdatePeriod from MPD as fallback TTL
|
||||
|
||||
Args:
|
||||
mpd_content: MPD XML content
|
||||
|
||||
Returns:
|
||||
Update period in seconds, or None if not found/applicable
|
||||
"""
|
||||
try:
|
||||
root = ET.fromstring(mpd_content)
|
||||
|
||||
# Check if dynamic manifest
|
||||
mpd_type = root.attrib.get('type', 'static')
|
||||
if mpd_type != 'dynamic':
|
||||
return None
|
||||
|
||||
# Get minimumUpdatePeriod
|
||||
update_period = root.attrib.get('minimumUpdatePeriod')
|
||||
if update_period:
|
||||
# Parse ISO 8601 duration (e.g., "PT5S" = 5 seconds)
|
||||
return MPDRewriter._parse_iso_duration(update_period)
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not extract MPD update period: {e}")
|
||||
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _parse_iso_duration(duration: str) -> int:
|
||||
"""
|
||||
Parse ISO 8601 duration to seconds
|
||||
|
||||
Supports formats like: PT5S, PT1M30S, PT1H
|
||||
|
||||
Args:
|
||||
duration: ISO 8601 duration string
|
||||
|
||||
Returns:
|
||||
Duration in seconds
|
||||
"""
|
||||
import re
|
||||
|
||||
# Remove PT prefix
|
||||
duration = duration.replace('PT', '')
|
||||
|
||||
# Parse hours, minutes, seconds
|
||||
hours = minutes = seconds = 0
|
||||
|
||||
h_match = re.search(r'(\d+)H', duration)
|
||||
if h_match:
|
||||
hours = int(h_match.group(1))
|
||||
|
||||
m_match = re.search(r'(\d+)M', duration)
|
||||
if m_match:
|
||||
minutes = int(m_match.group(1))
|
||||
|
||||
s_match = re.search(r'(\d+(?:\.\d+)?)S', duration)
|
||||
if s_match:
|
||||
seconds = float(s_match.group(1))
|
||||
|
||||
total_seconds = int(hours * 3600 + minutes * 60 + seconds)
|
||||
logger.debug(f"Parsed ISO duration '{duration}' to {total_seconds}s")
|
||||
return total_seconds
|
||||
@@ -0,0 +1,476 @@
|
||||
# streaming_providers/base/utils/vfs.py
|
||||
"""
|
||||
Virtual File System abstraction layer
|
||||
Provides transparent file operations for both Kodi and regular Python environments
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from typing import Optional, Any, List
|
||||
from pathlib import Path
|
||||
|
||||
# Import centralized logger
|
||||
from .logger import logger
|
||||
|
||||
# Kodi imports - with fallback for non-Kodi environments
|
||||
try:
|
||||
import xbmc
|
||||
import xbmcvfs
|
||||
import xbmcaddon
|
||||
|
||||
KODI_AVAILABLE = True
|
||||
logger.info("Kodi VFS environment detected")
|
||||
except ImportError:
|
||||
KODI_AVAILABLE = False
|
||||
logger.info("Standard filesystem environment detected")
|
||||
|
||||
|
||||
class VFS:
|
||||
"""
|
||||
Virtual File System abstraction layer
|
||||
|
||||
Provides a unified interface for file operations that works in both
|
||||
Kodi addon environments and standard Python environments.
|
||||
"""
|
||||
|
||||
def __init__(self, config_dir: Optional[str] = None, addon_subdir: str = ""):
|
||||
"""
|
||||
Initialize VFS handler with explicit config directory support
|
||||
|
||||
Args:
|
||||
config_dir: Optional explicit config directory (overrides automatic detection)
|
||||
addon_subdir: Optional subdirectory within addon data (for organization)
|
||||
"""
|
||||
self.addon_subdir = addon_subdir
|
||||
self._base_path = None
|
||||
self._explicit_config_dir = config_dir
|
||||
logger.debug(f"VFS initialized with config_dir={config_dir}, addon_subdir={addon_subdir}")
|
||||
|
||||
@property
|
||||
def base_path(self) -> str:
|
||||
"""Get the base path for file operations"""
|
||||
if self._base_path is None:
|
||||
if self._explicit_config_dir:
|
||||
# Use explicitly provided config directory
|
||||
self._base_path = self._explicit_config_dir
|
||||
logger.info(f"Using explicit config directory: {self._base_path}")
|
||||
elif KODI_AVAILABLE:
|
||||
# Use Kodi's addon data directory
|
||||
try:
|
||||
addon = xbmcaddon.Addon()
|
||||
# Use xbmcvfs.translatePath instead of xbmc.translatePath for Kodi 19+
|
||||
addon_profile = xbmcvfs.translatePath(addon.getAddonInfo('profile'))
|
||||
if self.addon_subdir:
|
||||
self._base_path = os.path.join(addon_profile, self.addon_subdir).replace('\\', '/')
|
||||
else:
|
||||
self._base_path = addon_profile.replace('\\', '/')
|
||||
logger.info(f"Kodi base path: {self._base_path}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting Kodi addon path: {e}")
|
||||
# Fallback to temp directory
|
||||
try:
|
||||
self._base_path = xbmcvfs.translatePath("special://temp/streaming_providers")
|
||||
except:
|
||||
self._base_path = "/tmp/streaming_providers"
|
||||
else:
|
||||
# Use standard filesystem
|
||||
if self.addon_subdir:
|
||||
self._base_path = str(Path.home() / '.streaming_providers' / self.addon_subdir)
|
||||
else:
|
||||
self._base_path = str(Path.home() / '.streaming_providers')
|
||||
logger.info(f"Standard filesystem base path: {self._base_path}")
|
||||
|
||||
# Ensure base directory exists
|
||||
self.mkdirs('')
|
||||
|
||||
return self._base_path
|
||||
|
||||
def join_path(self, *parts) -> str:
|
||||
"""
|
||||
Join path components using appropriate separator for environment
|
||||
|
||||
Args:
|
||||
*parts: Path components to join
|
||||
|
||||
Returns:
|
||||
Joined path string
|
||||
"""
|
||||
if KODI_AVAILABLE:
|
||||
# Kodi VFS uses forward slashes
|
||||
path = self.base_path
|
||||
for part in parts:
|
||||
if part:
|
||||
path = path.rstrip('/') + '/' + str(part).lstrip('/')
|
||||
return path
|
||||
else:
|
||||
# Use pathlib for standard filesystem
|
||||
path = Path(self.base_path)
|
||||
for part in parts:
|
||||
if part:
|
||||
path = path / str(part)
|
||||
return str(path)
|
||||
|
||||
def exists(self, filepath: str) -> bool:
|
||||
"""
|
||||
Check if file or directory exists
|
||||
|
||||
Args:
|
||||
filepath: Path to check (relative to base_path or absolute)
|
||||
|
||||
Returns:
|
||||
True if exists, False otherwise
|
||||
"""
|
||||
try:
|
||||
if not os.path.isabs(filepath):
|
||||
filepath = self.join_path(filepath)
|
||||
|
||||
if KODI_AVAILABLE:
|
||||
return xbmcvfs.exists(filepath)
|
||||
else:
|
||||
return Path(filepath).exists()
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking if {filepath} exists: {e}")
|
||||
return False
|
||||
|
||||
def mkdirs(self, dirpath: str) -> bool:
|
||||
"""
|
||||
Create directory and all parent directories
|
||||
|
||||
Args:
|
||||
dirpath: Directory path to create
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
if not os.path.isabs(dirpath):
|
||||
dirpath = self.join_path(dirpath)
|
||||
|
||||
if KODI_AVAILABLE:
|
||||
if not xbmcvfs.exists(dirpath):
|
||||
result = xbmcvfs.mkdirs(dirpath)
|
||||
logger.debug(f"Kodi mkdirs {dirpath}: {result}")
|
||||
return result
|
||||
return True
|
||||
else:
|
||||
Path(dirpath).mkdir(parents=True, exist_ok=True)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating directory {dirpath}: {e}")
|
||||
return False
|
||||
|
||||
def read_text(self, filepath: str, encoding: str = 'utf-8') -> Optional[str]:
|
||||
"""
|
||||
Read text content from file
|
||||
|
||||
Args:
|
||||
filepath: File path to read
|
||||
encoding: Text encoding (ignored in Kodi VFS)
|
||||
|
||||
Returns:
|
||||
File content as string or None if error/not found
|
||||
"""
|
||||
try:
|
||||
if not os.path.isabs(filepath):
|
||||
filepath = self.join_path(filepath)
|
||||
|
||||
if KODI_AVAILABLE:
|
||||
if not xbmcvfs.exists(filepath):
|
||||
return None
|
||||
with xbmcvfs.File(filepath, 'r') as f:
|
||||
content = f.read()
|
||||
return content if content else None
|
||||
else:
|
||||
path = Path(filepath)
|
||||
if not path.exists():
|
||||
return None
|
||||
with open(path, 'r', encoding=encoding) as f:
|
||||
return f.read()
|
||||
except Exception as e:
|
||||
logger.error(f"Error reading file {filepath}: {e}")
|
||||
return None
|
||||
|
||||
def write_text(self, filepath: str, content: str, encoding: str = 'utf-8') -> bool:
|
||||
"""
|
||||
Write text content to file
|
||||
|
||||
Args:
|
||||
filepath: File path to write
|
||||
content: Text content to write
|
||||
encoding: Text encoding (ignored in Kodi VFS)
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
if not os.path.isabs(filepath):
|
||||
filepath = self.join_path(filepath)
|
||||
|
||||
if KODI_AVAILABLE:
|
||||
# Ensure directory exists
|
||||
dir_path = '/'.join(filepath.split('/')[:-1])
|
||||
if dir_path and not xbmcvfs.exists(dir_path):
|
||||
xbmcvfs.mkdirs(dir_path)
|
||||
|
||||
with xbmcvfs.File(filepath, 'w') as f:
|
||||
bytes_written = f.write(content)
|
||||
logger.debug(f"Kodi file write: {bytes_written} bytes to {filepath}")
|
||||
return bytes_written > 0
|
||||
else:
|
||||
path = Path(filepath)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(path, 'w', encoding=encoding) as f:
|
||||
f.write(content)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error writing file {filepath}: {e}")
|
||||
return False
|
||||
|
||||
def delete(self, filepath: str) -> bool:
|
||||
"""
|
||||
Delete file
|
||||
|
||||
Args:
|
||||
filepath: File path to delete
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
if not os.path.isabs(filepath):
|
||||
filepath = self.join_path(filepath)
|
||||
|
||||
if KODI_AVAILABLE:
|
||||
if xbmcvfs.exists(filepath):
|
||||
return xbmcvfs.delete(filepath)
|
||||
return True
|
||||
else:
|
||||
path = Path(filepath)
|
||||
if path.exists():
|
||||
path.unlink()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting file {filepath}: {e}")
|
||||
return False
|
||||
|
||||
def read_json(self, filepath: str) -> Optional[dict]:
|
||||
"""
|
||||
Read and parse JSON file
|
||||
|
||||
Args:
|
||||
filepath: JSON file path to read
|
||||
|
||||
Returns:
|
||||
Parsed JSON data or None if error/not found
|
||||
"""
|
||||
try:
|
||||
content = self.read_text(filepath)
|
||||
if content:
|
||||
return json.loads(content)
|
||||
return None
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"Invalid JSON in {filepath}: {e}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Error reading JSON file {filepath}: {e}")
|
||||
return None
|
||||
|
||||
def write_json(self, filepath: str, data: Any, indent: int = 2) -> bool:
|
||||
"""
|
||||
Write data to JSON file
|
||||
|
||||
Args:
|
||||
filepath: JSON file path to write
|
||||
data: Data to serialize as JSON
|
||||
indent: JSON indentation level
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
content = json.dumps(data, indent=indent, ensure_ascii=False, default=str)
|
||||
return self.write_text(filepath, content)
|
||||
except Exception as e:
|
||||
logger.error(f"Error writing JSON file {filepath}: {e}")
|
||||
return False
|
||||
|
||||
def list_files(self, dirpath: str = "", pattern: str = "*") -> List[str]:
|
||||
"""
|
||||
List files in directory
|
||||
|
||||
Args:
|
||||
dirpath: Directory path to list (relative to base_path)
|
||||
pattern: File pattern filter (basic glob patterns)
|
||||
|
||||
Returns:
|
||||
List of filenames
|
||||
"""
|
||||
try:
|
||||
if not dirpath:
|
||||
dirpath = self.base_path
|
||||
elif not os.path.isabs(dirpath):
|
||||
dirpath = self.join_path(dirpath)
|
||||
|
||||
if KODI_AVAILABLE:
|
||||
if not xbmcvfs.exists(dirpath):
|
||||
return []
|
||||
|
||||
dirs, files = xbmcvfs.listdir(dirpath)
|
||||
# Basic pattern matching (only supports * wildcard)
|
||||
if pattern == "*":
|
||||
return files
|
||||
else:
|
||||
# Simple pattern matching
|
||||
import fnmatch
|
||||
return [f for f in files if fnmatch.fnmatch(f, pattern)]
|
||||
else:
|
||||
path = Path(dirpath)
|
||||
if not path.exists() or not path.is_dir():
|
||||
return []
|
||||
|
||||
if pattern == "*":
|
||||
return [f.name for f in path.iterdir() if f.is_file()]
|
||||
else:
|
||||
return [f.name for f in path.glob(pattern) if f.is_file()]
|
||||
except Exception as e:
|
||||
logger.error(f"Error listing files in {dirpath}: {e}")
|
||||
return []
|
||||
|
||||
def get_size(self, filepath: str) -> Optional[int]:
|
||||
"""
|
||||
Get file size in bytes
|
||||
|
||||
Args:
|
||||
filepath: File path
|
||||
|
||||
Returns:
|
||||
File size in bytes or None if error/not found
|
||||
"""
|
||||
try:
|
||||
if not os.path.isabs(filepath):
|
||||
filepath = self.join_path(filepath)
|
||||
|
||||
if KODI_AVAILABLE:
|
||||
if not xbmcvfs.exists(filepath):
|
||||
return None
|
||||
stat = xbmcvfs.Stat(filepath)
|
||||
return stat.st_size()
|
||||
else:
|
||||
path = Path(filepath)
|
||||
if not path.exists():
|
||||
return None
|
||||
return path.stat().st_size
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting size of {filepath}: {e}")
|
||||
return None
|
||||
|
||||
def ensure_directory(self, filepath: str) -> bool:
|
||||
"""
|
||||
Ensure directory for filepath exists
|
||||
|
||||
Args:
|
||||
filepath: File path to ensure directory for
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
if not os.path.isabs(filepath):
|
||||
filepath = self.join_path(filepath)
|
||||
|
||||
dir_path = os.path.dirname(filepath)
|
||||
return self.mkdirs(dir_path)
|
||||
except Exception as e:
|
||||
logger.error(f"Error ensuring directory for {filepath}: {e}")
|
||||
return False
|
||||
|
||||
def debug_info(self) -> dict:
|
||||
"""
|
||||
Get debug information about the VFS environment
|
||||
|
||||
Returns:
|
||||
Dictionary with debug information
|
||||
"""
|
||||
info = {
|
||||
'kodi_available': KODI_AVAILABLE,
|
||||
'base_path': self.base_path,
|
||||
'base_path_exists': self.exists(''),
|
||||
'explicit_config_dir': self._explicit_config_dir,
|
||||
'addon_subdir': self.addon_subdir
|
||||
}
|
||||
|
||||
if KODI_AVAILABLE:
|
||||
try:
|
||||
addon = xbmcaddon.Addon()
|
||||
info.update({
|
||||
'addon_id': addon.getAddonInfo('id'),
|
||||
'addon_version': addon.getAddonInfo('version'),
|
||||
'kodi_version': xbmc.getInfoLabel('System.BuildVersion'),
|
||||
})
|
||||
except Exception as e:
|
||||
info['kodi_error'] = str(e)
|
||||
|
||||
return info
|
||||
|
||||
|
||||
# Convenience functions for global VFS instance
|
||||
_global_vfs = None
|
||||
|
||||
|
||||
def get_vfs(config_dir: Optional[str] = None, addon_subdir: str = "") -> VFS:
|
||||
"""
|
||||
Get global VFS instance
|
||||
|
||||
Args:
|
||||
config_dir: Optional explicit config directory
|
||||
addon_subdir: Optional subdirectory within addon data
|
||||
|
||||
Returns:
|
||||
VFS instance
|
||||
"""
|
||||
global _global_vfs
|
||||
if _global_vfs is None or _global_vfs._explicit_config_dir != config_dir or _global_vfs.addon_subdir != addon_subdir:
|
||||
_global_vfs = VFS(config_dir, addon_subdir)
|
||||
return _global_vfs
|
||||
|
||||
|
||||
# Convenience functions that use global VFS
|
||||
def exists(filepath: str, config_dir: Optional[str] = None) -> bool:
|
||||
"""Check if file exists"""
|
||||
return get_vfs(config_dir).exists(filepath)
|
||||
|
||||
|
||||
def mkdirs(dirpath: str, config_dir: Optional[str] = None) -> bool:
|
||||
"""Create directories"""
|
||||
return get_vfs(config_dir).mkdirs(dirpath)
|
||||
|
||||
|
||||
def read_text(filepath: str, encoding: str = 'utf-8', config_dir: Optional[str] = None) -> Optional[str]:
|
||||
"""Read text file"""
|
||||
return get_vfs(config_dir).read_text(filepath, encoding)
|
||||
|
||||
|
||||
def write_text(filepath: str, content: str, encoding: str = 'utf-8', config_dir: Optional[str] = None) -> bool:
|
||||
"""Write text file"""
|
||||
return get_vfs(config_dir).write_text(filepath, content, encoding)
|
||||
|
||||
|
||||
def read_json(filepath: str, config_dir: Optional[str] = None) -> Optional[dict]:
|
||||
"""Read JSON file"""
|
||||
return get_vfs(config_dir).read_json(filepath)
|
||||
|
||||
|
||||
def write_json(filepath: str, data: Any, indent: int = 2, config_dir: Optional[str] = None) -> bool:
|
||||
"""Write JSON file"""
|
||||
return get_vfs(config_dir).write_json(filepath, data, indent)
|
||||
|
||||
|
||||
def delete(filepath: str, config_dir: Optional[str] = None) -> bool:
|
||||
"""Delete file"""
|
||||
return get_vfs(config_dir).delete(filepath)
|
||||
|
||||
|
||||
def join_path(*parts, config_dir: Optional[str] = None) -> str:
|
||||
"""Join path components"""
|
||||
vfs = get_vfs(config_dir)
|
||||
return vfs.join_path(*parts)
|
||||
@@ -0,0 +1,25 @@
|
||||
# streaming_providers/providers/joyn/__init__.py
|
||||
from .provider import JoynProvider
|
||||
from .models import JoynChannel, PlaybackRestrictedException
|
||||
from .auth import JoynAuthenticator, JoynAuthToken, JoynCredentials
|
||||
from .constants import (
|
||||
COUNTRY_TENANT_MAPPING,
|
||||
DEFAULT_VIDEO_CONFIG,
|
||||
JOYN_GRAPHQL_ENDPOINTS,
|
||||
JOYN_STREAMING_ENDPOINTS
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
'JoynProvider',
|
||||
'JoynChannel',
|
||||
'PlaybackRestrictedException',
|
||||
'JoynAuthenticator',
|
||||
'JoynAuthToken',
|
||||
'JoynCredentials',
|
||||
'COUNTRY_TENANT_MAPPING',
|
||||
'DEFAULT_VIDEO_CONFIG',
|
||||
'JOYN_GRAPHQL_ENDPOINTS',
|
||||
'JOYN_STREAMING_ENDPOINTS'
|
||||
]
|
||||
|
||||
__version__ = '1.1.0' # Updated version for refactored code
|
||||
@@ -0,0 +1,758 @@
|
||||
# streaming_providers/providers/joyn/auth.py
|
||||
# -*- coding: utf-8 -*-
|
||||
import uuid
|
||||
from typing import Dict, Optional, Any
|
||||
from dataclasses import dataclass, field
|
||||
import time
|
||||
|
||||
from ...base.auth.base_oauth2_auth import BaseOAuth2Authenticator
|
||||
from ...base.auth.base_auth import BaseAuthToken, TokenAuthLevel
|
||||
from ...base.auth.credentials import ClientCredentials
|
||||
from ...base.models.proxy_models import ProxyConfig
|
||||
from ...base.utils.logger import logger
|
||||
from .constants import (
|
||||
COUNTRY_TENANT_MAPPING,
|
||||
SUPPORTED_COUNTRIES,
|
||||
DEVICE_IDS,
|
||||
JOYN_DOMAINS,
|
||||
JOYN_OAUTH_SCOPE,
|
||||
JOYN_SSO_DISCOVERY_URL,
|
||||
JOYN_CLIENT_VERSION,
|
||||
DEFAULT_PLATFORM,
|
||||
JOYN_USER_AGENT,
|
||||
JOYN_CIDAAS_ENDPOINTS,
|
||||
DEFAULT_COUNTRY,
|
||||
DEFAULT_REQUEST_TIMEOUT, JOYN_AUTH_ENDPOINTS
|
||||
)
|
||||
|
||||
|
||||
class JoynSSODiscovery:
|
||||
"""Service to discover SSO endpoints dynamically"""
|
||||
|
||||
def __init__(self, http_manager, country: str = DEFAULT_COUNTRY, platform: str = DEFAULT_PLATFORM):
|
||||
self.http_manager = http_manager
|
||||
self.country = country
|
||||
self.platform = platform
|
||||
self._endpoints_cache = None
|
||||
self._cache_timestamp = None
|
||||
self._cache_ttl = 3600 # 1 hour cache
|
||||
|
||||
@staticmethod
|
||||
def get_fallback_endpoints() -> Dict[str, str]:
|
||||
"""Fallback endpoints if discovery fails"""
|
||||
return {
|
||||
'device-login': 'https://sso.joyn.de/ci',
|
||||
'device-register': 'https://sso.joyn.de/cr',
|
||||
'web-login': 'https://auth.7pass.de/authz-srv/authz',
|
||||
'redeem-token': 'https://auth.joyn.de/auth/7pass/token'
|
||||
}
|
||||
|
||||
def get_endpoints(self, force_refresh: bool = False) -> Dict[str, str]:
|
||||
"""Get SSO endpoints, with caching"""
|
||||
if (self._endpoints_cache and not force_refresh and
|
||||
time.time() - self._cache_timestamp < self._cache_ttl):
|
||||
return self._endpoints_cache
|
||||
|
||||
try:
|
||||
params = {
|
||||
'client_id': DEVICE_IDS[self.platform],
|
||||
'client_name': self.platform
|
||||
}
|
||||
|
||||
response = self.http_manager.get(
|
||||
JOYN_SSO_DISCOVERY_URL,
|
||||
operation='sso_discovery',
|
||||
params=params
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
self._endpoints_cache = response.json()
|
||||
self._cache_timestamp = time.time()
|
||||
logger.debug(f"SSO discovery successful, endpoints: {list(self._endpoints_cache.keys())}")
|
||||
return self._endpoints_cache
|
||||
|
||||
except Exception as e:
|
||||
# Fallback to hardcoded endpoints if discovery fails
|
||||
logger.warning(f"SSO discovery failed, using fallback: {e}")
|
||||
return self.get_fallback_endpoints()
|
||||
|
||||
def get_auth_endpoint(self, auth_type: str = None) -> str:
|
||||
"""Get specific auth endpoint by type"""
|
||||
# If no auth_type specified, use platform-specific login endpoint
|
||||
if auth_type is None:
|
||||
auth_type = f'{self.platform}-login'
|
||||
|
||||
endpoints = self.get_endpoints()
|
||||
endpoint = endpoints.get(auth_type)
|
||||
if not endpoint:
|
||||
logger.warning(f"Auth endpoint '{auth_type}' not found, using fallback")
|
||||
fallback = self.get_fallback_endpoints()
|
||||
# Try platform-specific first, then generic web-login
|
||||
endpoint = fallback.get(auth_type) or fallback.get(f'{self.platform}-login') or fallback.get('web-login',
|
||||
'')
|
||||
return endpoint
|
||||
|
||||
|
||||
@dataclass
|
||||
class JoynCredentials(ClientCredentials):
|
||||
"""
|
||||
Joyn-specific credentials for client credentials flow (anonymous auth)
|
||||
"""
|
||||
client_name: str = DEFAULT_PLATFORM
|
||||
country: str = DEFAULT_COUNTRY
|
||||
distribution_tenant: Optional[str] = field(default=None)
|
||||
|
||||
def __post_init__(self):
|
||||
# Set client_id from constant if not provided
|
||||
if not self.client_id:
|
||||
self.client_id = DEVICE_IDS.get(self.client_name, DEVICE_IDS[DEFAULT_PLATFORM])
|
||||
|
||||
if not self.distribution_tenant and self.country in COUNTRY_TENANT_MAPPING:
|
||||
self.distribution_tenant = COUNTRY_TENANT_MAPPING[self.country]
|
||||
|
||||
def validate(self) -> bool:
|
||||
"""Validate Joyn credentials"""
|
||||
if not self.client_id or not self.client_name:
|
||||
return False
|
||||
if self.country not in SUPPORTED_COUNTRIES:
|
||||
return False
|
||||
return True
|
||||
|
||||
def to_auth_payload(self) -> Dict[str, Any]:
|
||||
"""Convert to authentication payload for Joyn's anonymous auth endpoint"""
|
||||
return {
|
||||
'client_id': self.client_id,
|
||||
'client_name': self.client_name,
|
||||
'anon_device_id': str(uuid.uuid4())
|
||||
}
|
||||
|
||||
@property
|
||||
def credential_type(self) -> str:
|
||||
return "joyn_client_credentials"
|
||||
|
||||
|
||||
@dataclass
|
||||
class JoynAuthToken(BaseAuthToken):
|
||||
"""
|
||||
Joyn-specific authentication token
|
||||
"""
|
||||
refresh_token: Optional[str] = field(default="")
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert token to dictionary"""
|
||||
return {
|
||||
'access_token': self.access_token,
|
||||
'refresh_token': self.refresh_token or "",
|
||||
'token_type': self.token_type,
|
||||
'expires_in': self.expires_in,
|
||||
'issued_at': self.issued_at
|
||||
}
|
||||
|
||||
def get_jwt_claims(self) -> Optional[Dict[str, Any]]:
|
||||
"""Extract JWT claims from access token for debugging and classification"""
|
||||
try:
|
||||
if not self.access_token:
|
||||
return None
|
||||
|
||||
parts = self.access_token.split('.')
|
||||
if len(parts) != 3:
|
||||
return None
|
||||
|
||||
import base64
|
||||
import json
|
||||
|
||||
payload_b64 = parts[1]
|
||||
padding = len(payload_b64) % 4
|
||||
if padding:
|
||||
payload_b64 += '=' * (4 - padding)
|
||||
|
||||
payload_json = base64.b64decode(payload_b64).decode('utf-8')
|
||||
return json.loads(payload_json)
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to extract JWT claims: {e}")
|
||||
return None
|
||||
|
||||
class JoynAuthConfig:
|
||||
"""Configuration object for Joyn authentication with dynamic endpoints"""
|
||||
|
||||
def __init__(self, country: str, distribution_tenant: str, http_manager, platform: str = DEFAULT_PLATFORM):
|
||||
self.country = country
|
||||
self.distribution_tenant = distribution_tenant
|
||||
self.platform = platform
|
||||
self.user_agent = JOYN_USER_AGENT
|
||||
self.timeout = DEFAULT_REQUEST_TIMEOUT
|
||||
self.http_manager = http_manager
|
||||
|
||||
# Only create SSO discovery if we have http_manager
|
||||
if http_manager is not None:
|
||||
self.sso_discovery = JoynSSODiscovery(http_manager, country, platform)
|
||||
else:
|
||||
self.sso_discovery = None
|
||||
|
||||
def get_token_redeem_endpoint(self) -> str:
|
||||
"""Get token redemption endpoint for user login flows"""
|
||||
if self.sso_discovery:
|
||||
return self.sso_discovery.get_auth_endpoint('redeem-token')
|
||||
# Fallback if SSO discovery not available
|
||||
return JoynSSODiscovery.get_fallback_endpoints()['redeem-token']
|
||||
|
||||
def get_authorize_endpoint(self) -> str:
|
||||
"""Get authorization endpoint for OAuth2 flow"""
|
||||
if self.sso_discovery:
|
||||
# Try platform-specific login endpoint first
|
||||
return self.sso_discovery.get_auth_endpoint(f'{self.platform}-login')
|
||||
# Fallback if SSO discovery not available - try platform-specific, then web-login
|
||||
fallback = JoynSSODiscovery.get_fallback_endpoints()
|
||||
return fallback.get(f'{self.platform}-login') or fallback.get('web-login', '')
|
||||
|
||||
def get_base_headers(self) -> Dict[str, str]:
|
||||
"""Get base headers for all requests"""
|
||||
return {
|
||||
'User-Agent': self.user_agent,
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'Origin': JOYN_DOMAINS.get(self.country, JOYN_DOMAINS['de'])
|
||||
}
|
||||
|
||||
def get_auth_headers(self) -> Dict[str, str]:
|
||||
"""Get headers for authentication requests"""
|
||||
headers = self.get_base_headers()
|
||||
headers.update({
|
||||
'joyn-client-version': JOYN_CLIENT_VERSION,
|
||||
'joyn-country': self.country.upper(),
|
||||
'joyn-distribution-tenant': self.distribution_tenant,
|
||||
'joyn-platform': self.platform,
|
||||
'joyn-request-id': str(uuid.uuid4())
|
||||
})
|
||||
return headers
|
||||
|
||||
|
||||
class JoynAuthenticator(BaseOAuth2Authenticator):
|
||||
"""
|
||||
Joyn authenticator using OAuth2 client credentials flow with dynamic endpoints
|
||||
"""
|
||||
|
||||
def __init__(self, country: str = DEFAULT_COUNTRY,
|
||||
platform: str = DEFAULT_PLATFORM,
|
||||
settings_manager=None,
|
||||
credentials=None,
|
||||
config_dir: Optional[str] = None,
|
||||
http_manager=None,
|
||||
proxy_config: Optional[ProxyConfig] = None):
|
||||
"""
|
||||
Initialize authenticator for specific country
|
||||
"""
|
||||
if country not in SUPPORTED_COUNTRIES:
|
||||
raise ValueError(f"Unsupported country: {country}. Must be one of: {SUPPORTED_COUNTRIES}")
|
||||
|
||||
# Validate that http_manager is provided
|
||||
if http_manager is None:
|
||||
raise ValueError(
|
||||
"http_manager is required for JoynAuthenticator. "
|
||||
"It should be created in JoynProvider and passed to the authenticator."
|
||||
)
|
||||
|
||||
# Set country-specific attributes FIRST
|
||||
self.country = country
|
||||
self.platform = platform
|
||||
self.distribution_tenant = COUNTRY_TENANT_MAPPING[country]
|
||||
|
||||
# Store http_manager reference (provided by JoynProvider)
|
||||
self._http_manager = http_manager
|
||||
|
||||
# Setup Joyn-specific config BEFORE super().__init__
|
||||
self._config = JoynAuthConfig(self.country, self.distribution_tenant, self._http_manager, self.platform)
|
||||
|
||||
# Extract and cache client_id during initialization
|
||||
self._client_id = self._extract_client_id_from_endpoints()
|
||||
|
||||
# NOW call parent __init__ - config, http_manager AND country are ready
|
||||
super().__init__(
|
||||
provider_name='joyn',
|
||||
settings_manager=settings_manager,
|
||||
credentials=credentials,
|
||||
country=country,
|
||||
config_dir=config_dir,
|
||||
enable_kodi_integration=True,
|
||||
http_manager=self._http_manager,
|
||||
proxy_config=proxy_config
|
||||
)
|
||||
|
||||
def _get_joyn_auth_headers(self) -> Dict[str, str]:
|
||||
"""Get standardized Joyn authentication headers"""
|
||||
from .constants import JOYN_AUTH_HEADERS_BASE
|
||||
headers = JOYN_AUTH_HEADERS_BASE.copy()
|
||||
headers['Origin'] = f'https://www.joyn.{self.country.lower()}'
|
||||
headers.update({
|
||||
'joyn-country': self.country.upper(),
|
||||
'joyn-distribution-tenant': self.distribution_tenant,
|
||||
'joyn-platform': self.platform, # Using self.platform
|
||||
'joyn-request-id': str(uuid.uuid4())
|
||||
})
|
||||
return headers
|
||||
|
||||
def _extract_client_id_from_endpoints(self) -> str:
|
||||
"""Extract client_id from SSO endpoints during initialization"""
|
||||
try:
|
||||
# Get endpoints from SSO discovery
|
||||
endpoints = self._config.sso_discovery.get_endpoints()
|
||||
|
||||
# Get the platform-specific login endpoint
|
||||
platform_key = f"{self.platform}-login"
|
||||
login_url = endpoints.get(platform_key)
|
||||
|
||||
if not login_url:
|
||||
logger.warning(f"No {platform_key} endpoint found, trying generic web-login as fallback")
|
||||
login_url = endpoints.get('web-login')
|
||||
|
||||
if not login_url:
|
||||
raise Exception(
|
||||
f"No login endpoint found for platform '{self.platform}' or generic 'web-login' in SSO discovery")
|
||||
|
||||
# Extract client_id from the URL parameters
|
||||
from urllib.parse import urlparse, parse_qs
|
||||
parsed_url = urlparse(login_url)
|
||||
query_params = parse_qs(parsed_url.query)
|
||||
|
||||
client_id = query_params.get('client_id', [None])[0]
|
||||
|
||||
if not client_id:
|
||||
raise Exception("No client_id found in login endpoint")
|
||||
|
||||
logger.debug(f"Extracted and cached client_id for {self.platform}: {client_id}")
|
||||
return client_id
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Error extracting client_id from endpoints: {e}, using fallback: {DEVICE_IDS.get(self.platform, DEVICE_IDS[DEFAULT_PLATFORM])}")
|
||||
return DEVICE_IDS.get(self.platform, DEVICE_IDS[DEFAULT_PLATFORM])
|
||||
|
||||
@property
|
||||
def oauth_client_id(self) -> str:
|
||||
"""Get OAuth2 client ID - uses cached value from initialization"""
|
||||
return self._client_id
|
||||
|
||||
@property
|
||||
def oauth_scope(self) -> str:
|
||||
"""OAuth2 scopes for authorization code flow"""
|
||||
return JOYN_OAUTH_SCOPE
|
||||
|
||||
@property
|
||||
def oauth_redirect_uri(self) -> str:
|
||||
"""OAuth2 redirect URI - country-specific"""
|
||||
from .constants import get_oauth_redirect_uri
|
||||
return get_oauth_redirect_uri(self.country)
|
||||
|
||||
@property
|
||||
def auth_endpoint(self) -> str:
|
||||
"""Authentication endpoint URL - dynamic based on flow"""
|
||||
from ...base.auth.credentials import UserPasswordCredentials
|
||||
if isinstance(self.credentials, UserPasswordCredentials):
|
||||
# For authorization code flow - use token endpoint from SSO discovery
|
||||
return self._config.get_token_redeem_endpoint()
|
||||
else:
|
||||
# For client credentials flow - use anonymous endpoint
|
||||
return JOYN_AUTH_ENDPOINTS['ANONYMOUS']
|
||||
|
||||
def _get_auth_headers(self) -> Dict[str, str]:
|
||||
"""Get headers for authentication request"""
|
||||
return self._config.get_auth_headers()
|
||||
|
||||
def _build_auth_payload(self) -> Dict[str, Any]:
|
||||
"""Build authentication payload - only used for client credentials flow"""
|
||||
if not self.credentials:
|
||||
raise Exception("No credentials available")
|
||||
return self.credentials.to_auth_payload()
|
||||
|
||||
def _create_token_from_response(self, response_data: Dict[str, Any]) -> BaseAuthToken:
|
||||
"""Create token object from API response"""
|
||||
token = JoynAuthToken(
|
||||
access_token=response_data['access_token'],
|
||||
refresh_token=response_data.get('refresh_token', ''),
|
||||
token_type=response_data.get('token_type', 'Bearer'),
|
||||
expires_in=response_data.get('expires_in', 86400),
|
||||
issued_at=response_data.get('issued_at', time.time())
|
||||
)
|
||||
|
||||
# ALWAYS classify immediately when creating from response
|
||||
token.auth_level = self._classify_token(token)
|
||||
logger.debug(f"Token created and classified as: {token.auth_level.value}")
|
||||
|
||||
return token
|
||||
|
||||
def get_fallback_credentials(self) -> JoynCredentials:
|
||||
"""Get fallback credentials when no user credentials are available"""
|
||||
return JoynCredentials(
|
||||
client_id=self._client_id,
|
||||
client_secret="", # Joyn doesn't use client_secret
|
||||
country=self.country
|
||||
)
|
||||
|
||||
def get_token_redeem_url(self) -> str:
|
||||
"""Get token redemption URL for OAuth flows"""
|
||||
return self._config.get_token_redeem_endpoint()
|
||||
|
||||
# MAIN AUTHENTICATION METHOD - UPDATED to handle both flows
|
||||
def _perform_authentication(self) -> BaseAuthToken:
|
||||
"""
|
||||
Perform authentication using appropriate flow based on credential type
|
||||
"""
|
||||
from ...base.auth.credentials import UserPasswordCredentials, ClientCredentials
|
||||
|
||||
if isinstance(self.credentials, UserPasswordCredentials):
|
||||
# Use OAuth2 authorization code flow with PKCE
|
||||
logger.info(f"Using OAuth2 authorization code flow for {self.provider_name}")
|
||||
token_data = self._perform_oauth_authorization_code_flow(
|
||||
self.credentials.username,
|
||||
self.credentials.password
|
||||
)
|
||||
elif isinstance(self.credentials, ClientCredentials):
|
||||
# Use client credentials flow (anonymous auth)
|
||||
logger.info(f"Using OAuth2 client credentials flow for {self.provider_name}")
|
||||
token_data = self._perform_oauth_client_credentials_flow()
|
||||
else:
|
||||
raise Exception(f"Unsupported credential type for {self.provider_name}: {type(self.credentials)}")
|
||||
|
||||
return self._create_token_from_response(token_data)
|
||||
|
||||
# Client credentials flow implementation - KEEP EXISTING
|
||||
def _perform_oauth_client_credentials_flow(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Client credentials flow because Joyn uses JSON instead of form data
|
||||
"""
|
||||
try:
|
||||
logger.debug(f"Starting Joyn-specific OAuth2 client credentials flow")
|
||||
|
||||
headers = self._get_auth_headers()
|
||||
payload = self._build_auth_payload()
|
||||
|
||||
logger.debug(headers)
|
||||
logger.debug(payload)
|
||||
# Joyn expects JSON, not form-encoded data
|
||||
response = self.http_manager.post(
|
||||
self.auth_endpoint,
|
||||
operation='auth',
|
||||
headers=headers,
|
||||
json_data=payload # JSON instead of form data
|
||||
)
|
||||
|
||||
self._check_oauth_error_response(response)
|
||||
response.raise_for_status()
|
||||
|
||||
token_data = response.json()
|
||||
logger.debug(f"OAuth2 client credentials flow successful for {self.provider_name}")
|
||||
return token_data
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"OAuth2 client credentials flow on endpoint {self.auth_endpoint} failed for {self.provider_name}: {e}")
|
||||
raise Exception(f"OAuth2 client credentials flow failed: {e}")
|
||||
|
||||
# Authorization code flow - Joyn-specific implementation
|
||||
def _perform_oauth_authorization_code_flow(self, username: str, password: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Simplified Joyn OAuth2 authorization code flow using existing methods
|
||||
"""
|
||||
try:
|
||||
logger.debug("Starting Joyn OAuth2 authorization code flow using existing methods")
|
||||
|
||||
# Step 1: Use existing SSO endpoints from config (already initialized)
|
||||
web_login_url = self._config.get_authorize_endpoint()
|
||||
logger.debug(f"Using existing authorize endpoint: {web_login_url}")
|
||||
|
||||
# Step 2: Use our existing working method to get request_id
|
||||
from urllib.parse import urlencode, parse_qs, urlparse
|
||||
import re
|
||||
|
||||
# Build authorization URL with PKCE (our existing working method)
|
||||
code_verifier = self.generate_pkce_verifier()
|
||||
code_challenge = self.generate_pkce_challenge(code_verifier)
|
||||
state = self.generate_oauth_state()
|
||||
|
||||
params = {
|
||||
'response_type': 'code',
|
||||
'client_id': self.oauth_client_id,
|
||||
'redirect_uri': self.oauth_redirect_uri,
|
||||
'scope': self.oauth_scope,
|
||||
'state': state,
|
||||
'code_challenge': code_challenge,
|
||||
'code_challenge_method': 'S256',
|
||||
'response_mode': 'query',
|
||||
'view_type': 'login',
|
||||
'prompt': 'consent',
|
||||
'cd1': str(uuid.uuid4()),
|
||||
}
|
||||
|
||||
authorization_url = f"{web_login_url}?{urlencode(params)}"
|
||||
|
||||
session = self._create_oauth_session()
|
||||
|
||||
# This is our existing working method to get request_id
|
||||
logger.debug("Fetching authorization page for request_id")
|
||||
auth_response = session.get(authorization_url, timeout=self._config.timeout)
|
||||
auth_response.raise_for_status()
|
||||
|
||||
# Extract request_id from the redirect URL - THIS WORKS!
|
||||
parsed_url = urlparse(auth_response.url)
|
||||
query_params = parse_qs(parsed_url.query)
|
||||
request_id = query_params.get('requestId', [None])[0]
|
||||
|
||||
if not request_id:
|
||||
# Fallback: try to extract from response body
|
||||
request_id_match = re.search(r'requestId["\']?\s*:\s*["\']([^"\']+)', auth_response.text)
|
||||
if request_id_match:
|
||||
request_id = request_id_match.group(1)
|
||||
else:
|
||||
logger.error(f"Could not extract request_id from URL: {auth_response.url}")
|
||||
raise Exception("Could not extract request_id from authorization page")
|
||||
|
||||
logger.debug(f"Extracted request_id: {request_id}")
|
||||
|
||||
# Step 3: DIRECT LOGIN - Skip redundant verification steps
|
||||
logger.debug("Performing direct login with credentials")
|
||||
login_url = JOYN_CIDAAS_ENDPOINTS['LOGIN']
|
||||
login_data = {
|
||||
"username": username,
|
||||
"password": password,
|
||||
"requestId": request_id
|
||||
}
|
||||
|
||||
login_response = session.post(login_url, data=login_data, timeout=self._config.timeout,
|
||||
allow_redirects=False)
|
||||
login_response.raise_for_status()
|
||||
|
||||
# Step 4: Extract authorization code from redirect
|
||||
redirect_url = login_response.headers.get('Location', '')
|
||||
if not redirect_url:
|
||||
# Check if login failed - look for error messages
|
||||
if 'error' in auth_response.text.lower() or 'invalid' in auth_response.text.lower():
|
||||
logger.error("Login likely failed - check credentials")
|
||||
raise Exception("Authentication failed - check username and password")
|
||||
raise Exception("No redirect URL after login")
|
||||
|
||||
# Follow redirects to get the final URL with authorization code
|
||||
final_response = session.get(redirect_url, timeout=self._config.timeout, allow_redirects=True)
|
||||
final_params = parse_qs(urlparse(final_response.url).query)
|
||||
|
||||
auth_code = final_params.get('code', [None])[0]
|
||||
if not auth_code:
|
||||
raise Exception("Could not extract authorization code from login flow")
|
||||
|
||||
logger.debug(f"Extracted authorization code: {auth_code}")
|
||||
|
||||
# Step 5: Exchange authorization code for tokens using existing method
|
||||
logger.debug("Exchanging authorization code for tokens")
|
||||
token_data = self._exchange_authorization_code_for_token(
|
||||
authorization_code=auth_code,
|
||||
code_verifier=code_verifier,
|
||||
state=state
|
||||
)
|
||||
|
||||
logger.debug("Joyn OAuth2 authorization code flow successful")
|
||||
return token_data
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Joyn OAuth2 authorization code flow failed: {e}")
|
||||
raise Exception(f"OAuth2 authorization code flow failed: {e}")
|
||||
|
||||
def _build_token_exchange_payload(self, authorization_code: str, code_verifier: str,
|
||||
state: str = None, **kwargs) -> Dict[str, Any]:
|
||||
"""Joyn-specific token exchange payload"""
|
||||
return {
|
||||
'code': authorization_code,
|
||||
'client_id': self.oauth_client_id,
|
||||
'redirect_uri': self.oauth_redirect_uri,
|
||||
'tracking_id': str(uuid.uuid4()),
|
||||
'tracking_name': self.platform,
|
||||
'code_verifier': code_verifier
|
||||
# No grant_type for Joyn
|
||||
}
|
||||
|
||||
def _get_token_exchange_endpoint(self, **kwargs) -> str:
|
||||
"""Joyn-specific token exchange endpoint"""
|
||||
token_endpoint = self._config.get_token_redeem_endpoint()
|
||||
logger.debug(f"Using dynamic token endpoint: {token_endpoint}")
|
||||
return token_endpoint
|
||||
|
||||
def _get_token_exchange_headers(self, **kwargs) -> Dict[str, str]:
|
||||
return self._get_joyn_auth_headers()
|
||||
|
||||
def _should_use_json_for_token_exchange(self, **kwargs) -> bool:
|
||||
"""Joyn uses JSON instead of form-encoded"""
|
||||
return True
|
||||
|
||||
# Refresh token - KEEP EXISTING
|
||||
def _refresh_oauth_token(self) -> Optional[BaseAuthToken]:
|
||||
"""Joyn-specific token refresh implementation"""
|
||||
if not self._current_token or not self._current_token.refresh_token:
|
||||
logger.debug(f"No refresh token available for {self.provider_name}")
|
||||
return None
|
||||
|
||||
try:
|
||||
logger.debug(f"Refreshing OAuth2 token for {self.provider_name}")
|
||||
|
||||
# Joyn-specific refresh payload
|
||||
payload = {
|
||||
'client_id': self.oauth_client_id,
|
||||
'client_name': self.platform, # Joyn requires this
|
||||
'grant_type': 'Bearer', # Joyn uses 'Bearer' instead of 'refresh_token'
|
||||
'refresh_token': self._current_token.refresh_token
|
||||
}
|
||||
|
||||
headers = self._get_joyn_auth_headers()
|
||||
|
||||
# Use the refresh-specific endpoint
|
||||
refresh_endpoint = JOYN_AUTH_ENDPOINTS['REFRESH']
|
||||
|
||||
response = self.http_manager.post(
|
||||
refresh_endpoint,
|
||||
operation='auth',
|
||||
headers=headers,
|
||||
json_data=payload, # JSON format
|
||||
timeout=self._config.timeout
|
||||
)
|
||||
|
||||
response.raise_for_status()
|
||||
|
||||
new_token_data = response.json()
|
||||
refreshed_token = self._create_token_from_response(new_token_data)
|
||||
logger.info(f"OAuth2 token refresh successful for {self.provider_name}")
|
||||
return refreshed_token
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"OAuth2 token refresh failed for {self.provider_name}: {e}")
|
||||
return None
|
||||
|
||||
# Backward compatibility methods
|
||||
def is_authenticated(self) -> bool:
|
||||
"""Check if currently authenticated with valid token"""
|
||||
return self._current_token is not None and not self._current_token.is_expired
|
||||
|
||||
def invalidate_token(self) -> None:
|
||||
"""Invalidate current token (forces re-authentication on next request)"""
|
||||
self._current_token = None
|
||||
try:
|
||||
self.settings_manager.clear_token(self.provider_name)
|
||||
except (AttributeError, KeyError, IOError, OSError):
|
||||
# AttributeError: settings_manager is None
|
||||
# KeyError: provider_name not found in settings
|
||||
# IOError/OSError: filesystem errors when clearing token
|
||||
pass
|
||||
|
||||
def _classify_token(self, token: BaseAuthToken) -> TokenAuthLevel:
|
||||
"""
|
||||
Classify Joyn token based on JWT claims and token structure
|
||||
|
||||
Args:
|
||||
token: JoynAuthToken to classify
|
||||
|
||||
Returns:
|
||||
TokenAuthLevel indicating authentication level
|
||||
"""
|
||||
try:
|
||||
if not token or not token.access_token:
|
||||
return TokenAuthLevel.UNKNOWN
|
||||
|
||||
# Parse JWT token to extract claims
|
||||
try:
|
||||
# JWT tokens are in format: header.payload.signature
|
||||
parts = token.access_token.split('.')
|
||||
if len(parts) != 3:
|
||||
logger.warning(f"Invalid JWT format for token classification")
|
||||
return TokenAuthLevel.UNKNOWN
|
||||
|
||||
import base64
|
||||
import json
|
||||
|
||||
# Decode payload (second part)
|
||||
payload_b64 = parts[1]
|
||||
# Add padding if needed
|
||||
padding = len(payload_b64) % 4
|
||||
if padding:
|
||||
payload_b64 += '=' * (4 - padding)
|
||||
|
||||
payload_json = base64.b64decode(payload_b64).decode('utf-8')
|
||||
claims = json.loads(payload_json)
|
||||
|
||||
logger.debug(
|
||||
f"JWT claims for classification: { {k: v for k, v in claims.items() if k not in ['access_token', 'refresh_token']} }")
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to parse JWT for classification: {e}")
|
||||
return TokenAuthLevel.UNKNOWN
|
||||
|
||||
# Classification logic based on your analysis:
|
||||
|
||||
# 1. Check jIdC prefix - most reliable indicator
|
||||
jidc = claims.get('jIdC', '')
|
||||
if jidc.startswith('JNAA-'):
|
||||
logger.debug("Token classified as CLIENT_CREDENTIALS (JNAA prefix)")
|
||||
return TokenAuthLevel.CLIENT_CREDENTIALS
|
||||
elif jidc.startswith('JNDE-'):
|
||||
logger.debug("Token classified as USER_AUTHENTICATED (JNDE prefix)")
|
||||
return TokenAuthLevel.USER_AUTHENTICATED
|
||||
|
||||
# 2. Check for social_id presence - clear indicator of user authentication
|
||||
if 'social_id' in claims:
|
||||
logger.debug("Token classified as USER_AUTHENTICATED (social_id present)")
|
||||
return TokenAuthLevel.USER_AUTHENTICATED
|
||||
|
||||
# 3. Check client ID (cId) against known client IDs
|
||||
client_id = claims.get('cId', '')
|
||||
known_client_ids = {
|
||||
DEVICE_IDS['web'], # Web client
|
||||
DEVICE_IDS['android'], # Android client
|
||||
DEVICE_IDS['ios'] # iOS client
|
||||
}
|
||||
|
||||
if client_id in known_client_ids:
|
||||
logger.debug("Token classified as CLIENT_CREDENTIALS (known client ID)")
|
||||
return TokenAuthLevel.CLIENT_CREDENTIALS
|
||||
|
||||
# 4. Check for anonymous device patterns in subject (sub)
|
||||
subject = claims.get('sub', '')
|
||||
if subject and len(subject) == 36: # UUID format
|
||||
# Client credentials tokens often have UUID subjects representing the client
|
||||
# User tokens might have different patterns or include user identifiers
|
||||
logger.debug("Token classified as CLIENT_CREDENTIALS (UUID subject pattern)")
|
||||
return TokenAuthLevel.CLIENT_CREDENTIALS
|
||||
|
||||
# 5. Fallback: Check token scope or other claims
|
||||
scope = claims.get('scope', '')
|
||||
if scope:
|
||||
scopes = scope.split()
|
||||
if 'offline_access' in scopes and 'profile' in scopes:
|
||||
logger.debug("Token classified as USER_AUTHENTICATED (user scopes present)")
|
||||
return TokenAuthLevel.USER_AUTHENTICATED
|
||||
elif 'openid' in scopes and len(scopes) <= 2:
|
||||
logger.debug("Token classified as CLIENT_CREDENTIALS (minimal scopes)")
|
||||
return TokenAuthLevel.CLIENT_CREDENTIALS
|
||||
|
||||
logger.warning(f"Could not definitively classify token, using UNKNOWN")
|
||||
return TokenAuthLevel.UNKNOWN
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error classifying token: {e}")
|
||||
return TokenAuthLevel.UNKNOWN
|
||||
|
||||
def debug_token_classification(self) -> Dict[str, Any]:
|
||||
"""Debug method to analyze current token classification"""
|
||||
if not self._current_token:
|
||||
return {'error': 'No current token'}
|
||||
|
||||
claims = self._current_token.get_jwt_claims() if hasattr(self._current_token, 'get_jwt_claims') else {}
|
||||
|
||||
return {
|
||||
'token_type': type(self._current_token).__name__,
|
||||
'auth_level': self._current_token.auth_level.value,
|
||||
'is_expired': self._current_token.is_expired,
|
||||
'has_refresh': bool(self._current_token.refresh_token),
|
||||
'jwt_claims_available': bool(claims),
|
||||
'key_claims': {
|
||||
'jIdC': claims.get('jIdC', 'MISSING'),
|
||||
'cId': claims.get('cId', 'MISSING'),
|
||||
'social_id': 'PRESENT' if 'social_id' in claims else 'MISSING',
|
||||
'sub': claims.get('sub', 'MISSING')[:8] + '...' if claims.get('sub') else 'MISSING',
|
||||
'scope': claims.get('scope', 'MISSING')
|
||||
} if claims else {}
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
# streaming_providers/providers/joyn/constants.py
|
||||
# ============================================================================
|
||||
# SSO Discovery Configuration
|
||||
# ============================================================================
|
||||
|
||||
# SSO endpoints discovery URL
|
||||
JOYN_SSO_DISCOVERY_URL = 'https://auth.joyn.de/sso/endpoints'
|
||||
|
||||
# Default client IDs for different platforms (fallback)
|
||||
DEVICE_IDS = {
|
||||
'web': '709115c2-f87e-4bad-9b94-28ac08d72cd9',
|
||||
'android': '05f5f3df-1130-4707-a761-c04d0c50b7f2',
|
||||
'ios': '21218403-52ec-4a65-abf4-f36a0eadd631'
|
||||
}
|
||||
|
||||
# OAuth2 Configuration
|
||||
JOYN_OAUTH_SCOPE = "openid email profile offline_access"
|
||||
|
||||
# ============================================================================
|
||||
# Authentication Configuration
|
||||
# ============================================================================
|
||||
|
||||
# Base authentication URL
|
||||
JOYN_AUTH_BASE_URL = 'https://auth.joyn.de/auth'
|
||||
|
||||
# Authentication endpoints
|
||||
JOYN_AUTH_ENDPOINTS = {
|
||||
'ANONYMOUS': f'{JOYN_AUTH_BASE_URL}/anonymous', # Client credentials flow
|
||||
'REFRESH': f'{JOYN_AUTH_BASE_URL}/refresh', # Token refresh
|
||||
'LOGOUT': f'{JOYN_AUTH_BASE_URL}/logout', # Logout
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Cidaas/7pass Configuration
|
||||
# ============================================================================
|
||||
|
||||
# Cidaas base URL (7pass authentication service)
|
||||
JOYN_CIDAAS_BASE_URL = 'https://auth.7pass.de'
|
||||
|
||||
# Cidaas API endpoints
|
||||
JOYN_CIDAAS_ENDPOINTS = {
|
||||
'LOGIN': f'{JOYN_CIDAAS_BASE_URL}/login-srv/login',
|
||||
'VERIFICATION_INITIATE': f'{JOYN_CIDAAS_BASE_URL}/verification-srv/v2/authenticate/initiate/PASSWORD',
|
||||
'VERIFICATION_AUTHENTICATE': f'{JOYN_CIDAAS_BASE_URL}/verification-srv/v2/authenticate/authenticate/PASSWORD',
|
||||
'REGISTRATION_SETUP': f'{JOYN_CIDAAS_BASE_URL}/registration-setup-srv/public/list',
|
||||
'USER_CHECK_EXISTS': f'{JOYN_CIDAAS_BASE_URL}/users-srv/user/checkexists',
|
||||
'VERIFICATION_LIST': f'{JOYN_CIDAAS_BASE_URL}/verification-srv/v2/setup/public/configured/list',
|
||||
'CONSENT_ACCEPT': f'{JOYN_CIDAAS_BASE_URL}/consent-management-srv/consent/scope/accept',
|
||||
'LOGIN_CONTINUE': f'{JOYN_CIDAAS_BASE_URL}/login-srv/precheck/continue'
|
||||
}
|
||||
|
||||
# Base URLs
|
||||
JOYN_BASE_URLS = {
|
||||
'ORIGIN': 'https://www.joyn.de',
|
||||
'REFERER': 'https://www.joyn.de/',
|
||||
'SIGNIN_BASE': 'https://signin.7pass.de'
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# API Configuration
|
||||
# ============================================================================
|
||||
|
||||
# Client version used in API requests
|
||||
JOYN_CLIENT_VERSION = '5.1261.0'
|
||||
|
||||
# Platform identifier
|
||||
DEFAULT_PLATFORM = 'web'
|
||||
|
||||
# Default user agent for all requests
|
||||
JOYN_USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36'
|
||||
|
||||
# Base64 encoded secret key for signature generation
|
||||
SIGNATURE_SECRET_KEY = 'MzU0MzM3MzgzMzM4MzMzNjM1NDMzNzM4MzYzNDM2MzYzNTQzMzczODM2MzYzMzM4MzIzNjM1NDMzNzM4MzMzMDM2MzQzNTM5MzU0MzM3MzgzMzM5MzMzNTMyMzQzNTQzMzczODM2MzUzMzM5MzU0MzM3MzgzMzM4MzMzMjMzNDYzNTQzMzczODM2MzYzMzMzMzM0NDMzNDIzNTQzMzczODMzMzgzNjM2MzMzNQ=='
|
||||
|
||||
JOYN_AUTH_HEADERS_BASE = {
|
||||
'User-Agent': JOYN_USER_AGENT,
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'Origin': JOYN_BASE_URLS['ORIGIN'],
|
||||
'joyn-client-version': JOYN_CLIENT_VERSION,
|
||||
# Note: 'joyn-platform' is added dynamically in auth.py and provider.py
|
||||
}
|
||||
|
||||
# Base API headers (without dynamic auth tokens)
|
||||
JOYN_API_BASE_HEADERS = {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': JOYN_USER_AGENT
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# GraphQL Configuration
|
||||
# ============================================================================
|
||||
|
||||
# GraphQL base URL
|
||||
JOYN_GRAPHQL_BASE_URL = 'https://api.joyn.de/graphql'
|
||||
|
||||
# GraphQL persisted query hashes
|
||||
GRAPHQL_QUERY_HASHES = {
|
||||
'LIVE_PLAYER': '52b37a3cf5bc75e56026aed7b0d234874eeabd2eccd369d0cd3d3a6ea15ef566',
|
||||
'LIVE_CHANNELS': 'b7703103ddd0516be6b49ed66186092a6c6f6d815ccc502a9f50800a8cc18dd2'
|
||||
}
|
||||
|
||||
# GraphQL endpoints with full URLs
|
||||
JOYN_GRAPHQL_ENDPOINTS = {
|
||||
'LIVE_PLAYER': f'{JOYN_GRAPHQL_BASE_URL}?operationName=PageLivePlayerClientSide&enable_user_location=true&watch_assistant_variant=true&extensions=%7B%22persistedQuery%22%3A%7B%22version%22%3A1%2C%22sha256Hash%22%3A%22{GRAPHQL_QUERY_HASHES["LIVE_PLAYER"]}%22%7D%7D',
|
||||
'LIVE_CHANNELS': f'{JOYN_GRAPHQL_BASE_URL}?operationName=LiveChannelsAndEpg&enable_user_location=true&watch_assistant_variant=true'
|
||||
}
|
||||
|
||||
# Base GraphQL headers (without country-specific ones)
|
||||
JOYN_GRAPHQL_BASE_HEADERS = {
|
||||
'X-Api-Key': '4f0fd9f18abbe3cf0e87fdb556bc39c8',
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': JOYN_USER_AGENT
|
||||
}
|
||||
|
||||
# GraphQL persisted query version
|
||||
GRAPHQL_PERSISTED_QUERY_VERSION = 1
|
||||
|
||||
# GraphQL query defaults
|
||||
GRAPHQL_LIVE_CHANNELS_FILTER = "DEFAULT"
|
||||
GRAPHQL_MAX_RESULTS = 5000
|
||||
GRAPHQL_OFFSET = 0
|
||||
|
||||
# ============================================================================
|
||||
# Streaming Configuration
|
||||
# ============================================================================
|
||||
|
||||
# Streaming API endpoints
|
||||
JOYN_STREAMING_ENDPOINTS = {
|
||||
'ENTITLEMENT': 'https://entitlement.p7s1.io/api/user/entitlement-token',
|
||||
'PLAYLIST': 'https://api.vod-prd.s.joyn.de/v1/channel/{channel_id}/playlist'
|
||||
}
|
||||
|
||||
# Default video data payload configuration
|
||||
"""
|
||||
DEFAULT_VIDEO_CONFIG = {
|
||||
'manufacturer': 'unknown',
|
||||
'platform': 'browser',
|
||||
'maxSecurityLevel': 1,
|
||||
'model': 'unknown',
|
||||
'protectionSystem': 'widevine',
|
||||
'streamingFormat': 'dash',
|
||||
'enableSubtitles': True,
|
||||
'maxResolution': 1080,
|
||||
'version': 'v1',
|
||||
}
|
||||
"""
|
||||
DEFAULT_VIDEO_CONFIG = {
|
||||
"enableDolbyAtmos": True,
|
||||
"enableSubtitles": True,
|
||||
"manufacturer": "",
|
||||
"maxResolution": 2160,
|
||||
"model": "",
|
||||
"platform": "android-tv",
|
||||
"protectionSystem": "widevine",
|
||||
"streamingFormat": "dash",
|
||||
"variantName": "",
|
||||
"version": "v1",
|
||||
"maxSecurityLevel": 5,
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Content Configuration
|
||||
# ============================================================================
|
||||
|
||||
# Content types
|
||||
CONTENT_TYPE_LIVE = 'LIVE'
|
||||
CONTENT_TYPE_VOD = 'VOD'
|
||||
|
||||
# Stream types
|
||||
STREAM_TYPE_LINEAR = 'LINEAR'
|
||||
STREAM_TYPE_EVENT = 'EVENT'
|
||||
STREAM_TYPE_ON_DEMAND = 'ON_DEMAND'
|
||||
|
||||
# Livestream types for GraphQL queries
|
||||
DEFAULT_LIVESTREAM_TYPES = ['EVENT', 'LINEAR', 'ON_DEMAND']
|
||||
|
||||
# Stream modes
|
||||
MODE_LIVE = 'live'
|
||||
MODE_VOD = 'vod'
|
||||
|
||||
# ============================================================================
|
||||
# Error Codes
|
||||
# ============================================================================
|
||||
|
||||
# Known error codes from Joyn API
|
||||
ERROR_CODES = {
|
||||
'PLAYBACK_RESTRICTED': 'ENT_RVOD_Playback_Restricted',
|
||||
'UNAUTHORIZED': 'ENT_Unauthorized',
|
||||
'NOT_FOUND': 'ENT_Not_Found',
|
||||
'GEOBLOCKED': 'ENT_Geoblocked',
|
||||
'VALIDATION_ERROR': 'VALIDATION_ERROR', # Added for token refresh
|
||||
'INVALID_JWT': 'INVALID_JWT' # Added for expired tokens
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Country/Region Configuration
|
||||
# ============================================================================
|
||||
|
||||
# Country to distribution tenant mapping
|
||||
COUNTRY_TENANT_MAPPING = {
|
||||
'de': 'JOYN',
|
||||
'at': 'JOYN_AT',
|
||||
'ch': 'JOYN_CH'
|
||||
}
|
||||
|
||||
JOYN_DOMAINS = {
|
||||
'de': 'https://www.joyn.de',
|
||||
'at': 'https://www.joyn.at',
|
||||
'ch': 'https://www.joyn.ch'
|
||||
}
|
||||
|
||||
def get_oauth_redirect_uri(country: str) -> str:
|
||||
"""Get country-specific OAuth redirect URI"""
|
||||
return "https://www.joyn.de/oauth"
|
||||
|
||||
# Supported countries
|
||||
SUPPORTED_COUNTRIES = list(COUNTRY_TENANT_MAPPING.keys())
|
||||
|
||||
# Default country
|
||||
DEFAULT_COUNTRY = 'de'
|
||||
|
||||
# ============================================================================
|
||||
# DRM Configuration
|
||||
# ============================================================================
|
||||
|
||||
# DRM system
|
||||
DRM_SYSTEM_WIDEVINE = 'widevine'
|
||||
|
||||
# DRM request headers
|
||||
DRM_REQUEST_HEADERS = {
|
||||
'Content-Type': 'application/octet-stream',
|
||||
'User-Agent': JOYN_USER_AGENT
|
||||
}
|
||||
|
||||
# DRM license request template (without bearer token)
|
||||
DRM_LICENSE_HEADERS_BASE = {
|
||||
'Content-Type': 'application/octet-stream',
|
||||
'User-Agent': JOYN_USER_AGENT
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Request Configuration
|
||||
# ============================================================================
|
||||
|
||||
# Default timeout for HTTP requests (seconds)
|
||||
DEFAULT_REQUEST_TIMEOUT = 30
|
||||
|
||||
# Default maximum retries for failed requests
|
||||
DEFAULT_MAX_RETRIES = 3
|
||||
|
||||
# Default time window for EPG queries (hours)
|
||||
DEFAULT_EPG_WINDOW_HOURS = 3
|
||||
|
||||
# ============================================================================
|
||||
# Channel Configuration
|
||||
# ============================================================================
|
||||
|
||||
# Default channel settings
|
||||
DEFAULT_CHANNEL_CONFIG = {
|
||||
'video': 'best',
|
||||
'on_demand': True,
|
||||
'speed_up': True,
|
||||
'use_cdm': True,
|
||||
'cdm_mode': 'external',
|
||||
'session_manifest': False
|
||||
}
|
||||
|
||||
# Default language
|
||||
DEFAULT_LANGUAGE = 'de'
|
||||
@@ -0,0 +1,203 @@
|
||||
# streaming_providers/providers/joyn/models.py
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Dict, Optional
|
||||
import json
|
||||
|
||||
from ...base.models import StreamingChannel
|
||||
|
||||
|
||||
class PlaybackRestrictedException(Exception):
|
||||
"""
|
||||
Exception raised when content playback is restricted
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class JoynChannel:
|
||||
"""
|
||||
Represents a Joyn channel with all necessary streaming data
|
||||
"""
|
||||
# Core identification
|
||||
name: str
|
||||
channel_id: str
|
||||
|
||||
# Visual elements
|
||||
logo_url: Optional[str] = None
|
||||
|
||||
# Streaming configuration
|
||||
mode: str = "live" # "live" or "vod"
|
||||
session_manifest: bool = False
|
||||
manifest: Optional[str] = None
|
||||
manifest_script: Optional[str] = None
|
||||
|
||||
# CDM (Content Decryption Module) settings
|
||||
cdm_type: Optional[str] = None
|
||||
use_cdm: bool = True
|
||||
cdm: Optional[str] = None # Usually "pid={pid}"
|
||||
cdm_mode: str = 'external'
|
||||
|
||||
# Video settings
|
||||
video: str = 'best'
|
||||
on_demand: bool = True
|
||||
speed_up: bool = True
|
||||
|
||||
# Additional metadata
|
||||
content_type: str = 'LIVE' # 'LIVE' or 'VOD'
|
||||
description: Optional[str] = None
|
||||
genre: Optional[str] = None
|
||||
language: str = 'de'
|
||||
country: str = 'DE'
|
||||
|
||||
# Streaming data
|
||||
license_url: Optional[str] = None
|
||||
certificate_url: Optional[str] = None
|
||||
streaming_format: Optional[str] = None
|
||||
|
||||
# Internal tracking
|
||||
raw_data: Dict = field(default_factory=dict)
|
||||
|
||||
@classmethod
|
||||
def from_api_data(cls, api_data: Dict, **kwargs) -> 'JoynChannel':
|
||||
"""
|
||||
Create JoynChannel from API response data
|
||||
|
||||
Args:
|
||||
api_data: Raw API response data
|
||||
**kwargs: Additional parameters to override defaults
|
||||
|
||||
Returns:
|
||||
JoynChannel instance
|
||||
"""
|
||||
channel = cls(
|
||||
name=api_data.get('title', 'Unknown Channel'),
|
||||
channel_id=api_data.get('id', ''),
|
||||
content_type=api_data.get('type', 'LIVE'),
|
||||
raw_data=api_data.copy()
|
||||
)
|
||||
|
||||
# Apply any additional parameters
|
||||
for key, value in kwargs.items():
|
||||
if hasattr(channel, key):
|
||||
setattr(channel, key, value)
|
||||
|
||||
return channel
|
||||
|
||||
def set_streaming_data(self, manifest: str, cdm_type: str = None,
|
||||
pid: str = None, license_url: str = None,
|
||||
certificate_url: str = None, streaming_format: str = None) -> None:
|
||||
"""
|
||||
Configure streaming-specific data
|
||||
|
||||
Args:
|
||||
manifest: Manifest URL for streaming
|
||||
cdm_type: Content decryption module type
|
||||
pid: Program ID for CDM
|
||||
license_url: License URL for DRM
|
||||
certificate_url: Certificate URL for DRM
|
||||
streaming_format: Streaming format (e.g., 'dash')
|
||||
"""
|
||||
self.manifest = manifest
|
||||
|
||||
if cdm_type:
|
||||
self.cdm_type = cdm_type
|
||||
|
||||
if pid:
|
||||
self.cdm = f"pid={pid}"
|
||||
|
||||
if license_url:
|
||||
self.license_url = license_url
|
||||
|
||||
if certificate_url:
|
||||
self.certificate_url = certificate_url
|
||||
|
||||
if streaming_format:
|
||||
self.streaming_format = streaming_format
|
||||
|
||||
def set_logo(self, logo_url: str) -> None:
|
||||
"""Set channel logo URL"""
|
||||
self.logo_url = logo_url
|
||||
|
||||
def set_metadata(self, description: str = None, genre: str = None) -> None:
|
||||
"""Set additional metadata"""
|
||||
if description:
|
||||
self.description = description
|
||||
if genre:
|
||||
self.genre = genre
|
||||
|
||||
def is_live(self) -> bool:
|
||||
"""Check if channel is live TV"""
|
||||
return self.content_type == 'LIVE' and self.mode == 'live'
|
||||
|
||||
def is_vod(self) -> bool:
|
||||
"""Check if channel is video on demand"""
|
||||
return self.content_type == 'VOD' or self.mode == 'vod'
|
||||
|
||||
def to_streaming_channel(self, provider_name: str = 'joyn') -> StreamingChannel:
|
||||
"""
|
||||
Convert to generic StreamingChannel object
|
||||
|
||||
Args:
|
||||
provider_name: Provider name to set
|
||||
|
||||
Returns:
|
||||
StreamingChannel instance
|
||||
"""
|
||||
return StreamingChannel(
|
||||
name=self.name,
|
||||
channel_id=self.channel_id,
|
||||
provider=provider_name,
|
||||
logo_url=self.logo_url,
|
||||
mode=self.mode,
|
||||
session_manifest=self.session_manifest,
|
||||
manifest=self.manifest,
|
||||
manifest_script=self.manifest_script,
|
||||
cdm_type=self.cdm_type,
|
||||
use_cdm=self.use_cdm,
|
||||
cdm=self.cdm,
|
||||
cdm_mode=self.cdm_mode,
|
||||
video=self.video,
|
||||
on_demand=self.on_demand,
|
||||
speed_up=self.speed_up,
|
||||
content_type=self.content_type,
|
||||
description=self.description,
|
||||
genre=self.genre,
|
||||
language=self.language,
|
||||
country=self.country,
|
||||
license_url=self.license_url,
|
||||
certificate_url=self.certificate_url,
|
||||
streaming_format=self.streaming_format
|
||||
)
|
||||
|
||||
def to_dict(self) -> Dict:
|
||||
"""
|
||||
Convert to dictionary format matching your output specification
|
||||
|
||||
Returns:
|
||||
Dictionary in the format expected by your application
|
||||
"""
|
||||
return {
|
||||
'Name': self.name,
|
||||
'LogoUrl': self.logo_url,
|
||||
'Mode': self.mode,
|
||||
'SessionManifest': self.session_manifest,
|
||||
'Manifest': self.manifest,
|
||||
'ManifestScript': self.manifest_script,
|
||||
'CdmType': self.cdm_type,
|
||||
'UseCdm': self.use_cdm,
|
||||
'Cdm': self.cdm,
|
||||
'CdmMode': self.cdm_mode,
|
||||
'Video': self.video,
|
||||
'OnDemand': self.on_demand,
|
||||
'SpeedUp': self.speed_up
|
||||
}
|
||||
|
||||
def to_json(self, indent: int = 2) -> str:
|
||||
"""Convert to JSON string"""
|
||||
return json.dumps(self.to_dict(), indent=indent, ensure_ascii=False)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"JoynChannel(name='{self.name}', id='{self.channel_id}', type='{self.content_type}')"
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return self.__str__()
|
||||
@@ -0,0 +1,725 @@
|
||||
# streaming_providers/providers/joyn/provider.py
|
||||
# -*- coding: utf-8 -*-
|
||||
from typing import Dict, Optional, List
|
||||
import json
|
||||
import time
|
||||
import hashlib
|
||||
import urllib.parse
|
||||
from datetime import datetime, timedelta
|
||||
from base64 import b64decode
|
||||
from json import dumps
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from ...base.provider import StreamingProvider
|
||||
from ...base.models import DRMConfig, LicenseConfig, DRMSystem
|
||||
from ...base.models.streaming_channel import StreamingChannel
|
||||
from ...base.network import HTTPManagerFactory, ProxyConfigManager
|
||||
from ...base.models.proxy_models import ProxyConfig
|
||||
from ...base.utils.logger import logger
|
||||
from .models import JoynChannel, PlaybackRestrictedException
|
||||
from .auth import JoynAuthenticator
|
||||
from .constants import (
|
||||
SIGNATURE_SECRET_KEY,
|
||||
DEFAULT_VIDEO_CONFIG,
|
||||
COUNTRY_TENANT_MAPPING,
|
||||
SUPPORTED_COUNTRIES,
|
||||
JOYN_GRAPHQL_ENDPOINTS,
|
||||
JOYN_GRAPHQL_BASE_HEADERS,
|
||||
JOYN_STREAMING_ENDPOINTS,
|
||||
JOYN_CLIENT_VERSION,
|
||||
DEFAULT_PLATFORM,
|
||||
JOYN_USER_AGENT,
|
||||
JOYN_API_BASE_HEADERS,
|
||||
JOYN_DOMAINS,
|
||||
ERROR_CODES,
|
||||
CONTENT_TYPE_LIVE,
|
||||
CONTENT_TYPE_VOD,
|
||||
DEFAULT_LIVESTREAM_TYPES,
|
||||
MODE_LIVE,
|
||||
MODE_VOD,
|
||||
DRM_SYSTEM_WIDEVINE,
|
||||
DRM_REQUEST_HEADERS,
|
||||
DEFAULT_REQUEST_TIMEOUT,
|
||||
DEFAULT_MAX_RETRIES,
|
||||
DEFAULT_EPG_WINDOW_HOURS,
|
||||
GRAPHQL_PERSISTED_QUERY_VERSION,
|
||||
GRAPHQL_LIVE_CHANNELS_FILTER,
|
||||
GRAPHQL_MAX_RESULTS,
|
||||
GRAPHQL_OFFSET,
|
||||
GRAPHQL_QUERY_HASHES
|
||||
)
|
||||
|
||||
|
||||
def create_video_payload(config: Optional[Dict] = None, compact: bool = True) -> str:
|
||||
"""
|
||||
Create video data payload for requests
|
||||
"""
|
||||
video_config = config or DEFAULT_VIDEO_CONFIG
|
||||
payload = dumps(video_config)
|
||||
return payload.replace(' ', '') if compact else payload
|
||||
|
||||
|
||||
def build_signature(entitlement_token: str, video_payload: Optional[str] = None,
|
||||
secret_key: Optional[str] = None) -> str:
|
||||
"""
|
||||
Build signature for video data requests
|
||||
"""
|
||||
if video_payload is None:
|
||||
video_payload = create_video_payload()
|
||||
|
||||
if secret_key is None:
|
||||
secret_key = b64decode(SIGNATURE_SECRET_KEY).decode('utf-8')
|
||||
|
||||
signature_input = f"{video_payload},{entitlement_token}{secret_key}"
|
||||
return hashlib.sha1(signature_input.encode('utf-8')).hexdigest()
|
||||
|
||||
|
||||
class JoynProvider(StreamingProvider):
|
||||
"""
|
||||
Joyn streaming provider implementation with centralized HTTP management
|
||||
"""
|
||||
|
||||
def __init__(self, country: str = 'de',
|
||||
platform: str = DEFAULT_PLATFORM,
|
||||
config_dir: Optional[str] = None,
|
||||
proxy_config: Optional[ProxyConfig] = None,
|
||||
proxy_url: Optional[str] = None):
|
||||
"""
|
||||
Initialize Joyn provider
|
||||
|
||||
Args:
|
||||
country: Country code ('de', 'at', 'ch')
|
||||
config_dir: Optional config directory override
|
||||
proxy_config: Optional proxy configuration (overrides ProxyConfigManager)
|
||||
proxy_url: Optional proxy URL string (converted to ProxyConfig)
|
||||
"""
|
||||
super().__init__(country=country)
|
||||
|
||||
if country not in SUPPORTED_COUNTRIES:
|
||||
raise ValueError(f"Unsupported country: {country}. Must be one of: {SUPPORTED_COUNTRIES}")
|
||||
|
||||
self.distribution_tenant = COUNTRY_TENANT_MAPPING[country]
|
||||
|
||||
# Setup proxy configuration with priority: proxy_config > proxy_url > ProxyConfigManager
|
||||
self.proxy_config = (
|
||||
proxy_config or
|
||||
(ProxyConfig.from_url(proxy_url) if proxy_url else None) or
|
||||
self._load_proxy_from_manager(config_dir)
|
||||
)
|
||||
|
||||
if self.proxy_config:
|
||||
logger.info("Using proxy configuration for Joyn")
|
||||
else:
|
||||
logger.debug("No proxy configuration found for Joyn")
|
||||
|
||||
# Create HTTP manager FIRST - single instance for all operations
|
||||
self.http_manager = HTTPManagerFactory.create_for_provider(
|
||||
provider_name='joyn',
|
||||
proxy_config=self.proxy_config,
|
||||
user_agent=JOYN_USER_AGENT,
|
||||
timeout=DEFAULT_REQUEST_TIMEOUT,
|
||||
max_retries=DEFAULT_MAX_RETRIES
|
||||
)
|
||||
|
||||
self.platform = platform
|
||||
|
||||
# Create authenticator with shared HTTP manager
|
||||
self.authenticator = JoynAuthenticator(
|
||||
country=country,
|
||||
platform=platform,
|
||||
config_dir=config_dir,
|
||||
http_manager=self.http_manager,
|
||||
proxy_config=self.proxy_config
|
||||
)
|
||||
|
||||
# Always authenticate through authenticator (no direct bearer_token parameter)
|
||||
try:
|
||||
self.bearer_token = self.authenticator.get_bearer_token()
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not authenticate during initialization: {e}")
|
||||
self.bearer_token = None
|
||||
|
||||
def _load_proxy_from_manager(self, config_dir: Optional[str]) -> Optional[ProxyConfig]:
|
||||
"""
|
||||
Load proxy configuration from ProxyConfigManager
|
||||
|
||||
Args:
|
||||
config_dir: Optional config directory path
|
||||
|
||||
Returns:
|
||||
ProxyConfig if found, None otherwise
|
||||
"""
|
||||
try:
|
||||
proxy_manager = ProxyConfigManager(config_dir)
|
||||
return proxy_manager.get_proxy_config('joyn', self.country)
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not load proxy from ProxyConfigManager: {e}")
|
||||
return None
|
||||
|
||||
@property
|
||||
def provider_name(self) -> str:
|
||||
return 'joyn'
|
||||
|
||||
@property
|
||||
def uses_dynamic_manifests(self) -> bool:
|
||||
return False
|
||||
|
||||
def authenticate(self, **kwargs) -> str:
|
||||
"""Authenticate and return bearer token"""
|
||||
self.bearer_token = self.authenticator.get_bearer_token(force_refresh=kwargs.get('force_refresh', False))
|
||||
return self.bearer_token
|
||||
|
||||
def get_dynamic_manifest_params(self, channel: StreamingChannel, **kwargs) -> Optional[str]:
|
||||
return None
|
||||
|
||||
def _get_graphql_headers(self) -> Dict[str, str]:
|
||||
"""Get headers for GraphQL requests"""
|
||||
headers = JOYN_GRAPHQL_BASE_HEADERS.copy()
|
||||
headers.update({
|
||||
'joyn-client-version': JOYN_CLIENT_VERSION,
|
||||
'joyn-country': self.country.upper(),
|
||||
'joyn-distribution-tenant': self.distribution_tenant,
|
||||
'joyn-platform': self.platform,
|
||||
'joyn-user-state': 'code=R_A'
|
||||
})
|
||||
return headers
|
||||
|
||||
def refresh_authentication(self) -> str:
|
||||
"""Force refresh authentication"""
|
||||
self.bearer_token = self.authenticator.get_bearer_token(force_refresh=True)
|
||||
return self.bearer_token
|
||||
|
||||
def fetch_channels(self,
|
||||
time_window_hours: int = DEFAULT_EPG_WINDOW_HOURS,
|
||||
fetch_manifests: bool = False,
|
||||
populate_streaming_data: bool = True,
|
||||
**kwargs) -> List[StreamingChannel]:
|
||||
"""
|
||||
Fetch available channels from Joyn GraphQL API
|
||||
|
||||
Args:
|
||||
time_window_hours: EPG time window in hours
|
||||
fetch_manifests: Whether to immediately populate streaming data
|
||||
populate_streaming_data: Whether to populate streaming data when fetch_manifests is True
|
||||
**kwargs: Additional parameters
|
||||
|
||||
Returns:
|
||||
List of StreamingChannel objects
|
||||
"""
|
||||
try:
|
||||
headers = self._get_graphql_headers()
|
||||
|
||||
current_time = int(time.time())
|
||||
end_time = current_time + (time_window_hours * 3600)
|
||||
|
||||
variables = {
|
||||
"liveStreamGroupFilter": GRAPHQL_LIVE_CHANNELS_FILTER,
|
||||
"first": GRAPHQL_MAX_RESULTS,
|
||||
"offset": GRAPHQL_OFFSET,
|
||||
"livestreamTypes": DEFAULT_LIVESTREAM_TYPES,
|
||||
"from": current_time,
|
||||
"to": end_time
|
||||
}
|
||||
|
||||
variables_encoded = urllib.parse.quote(json.dumps(variables))
|
||||
extensions = {
|
||||
"persistedQuery": {
|
||||
"version": GRAPHQL_PERSISTED_QUERY_VERSION,
|
||||
"sha256Hash": GRAPHQL_QUERY_HASHES['LIVE_CHANNELS']
|
||||
}
|
||||
}
|
||||
extensions_encoded = urllib.parse.quote(json.dumps(extensions))
|
||||
|
||||
url = f"{JOYN_GRAPHQL_ENDPOINTS['LIVE_CHANNELS']}&variables={variables_encoded}&extensions={extensions_encoded}"
|
||||
|
||||
# Use http_manager instead of requests
|
||||
response = self.http_manager.get(
|
||||
url,
|
||||
operation='api',
|
||||
headers=headers,
|
||||
timeout=DEFAULT_REQUEST_TIMEOUT
|
||||
)
|
||||
response.raise_for_status()
|
||||
channel_data = response.json()
|
||||
|
||||
channels = self._process_graphql_response(channel_data)
|
||||
|
||||
# Populate streaming data immediately after fetching channels
|
||||
if fetch_manifests and populate_streaming_data:
|
||||
channels = self.populate_streaming_data(channels)
|
||||
|
||||
logger.info(f"Successfully fetched {len(channels)} channels for country {self.country}")
|
||||
return channels
|
||||
|
||||
except Exception as e:
|
||||
raise Exception(f"Error fetching channels from GraphQL: {e}")
|
||||
|
||||
def _process_graphql_response(self, response_data: Dict) -> List[StreamingChannel]:
|
||||
"""Process GraphQL response and convert to StreamingChannel objects"""
|
||||
if 'data' not in response_data or 'liveStreams' not in response_data['data']:
|
||||
raise Exception("Invalid GraphQL response structure")
|
||||
|
||||
live_streams = response_data['data']['liveStreams']
|
||||
channels = []
|
||||
|
||||
for stream_data in live_streams:
|
||||
try:
|
||||
channel_id = stream_data.get('id', '')
|
||||
title = stream_data.get('title', 'Unknown Channel')
|
||||
stream_type = stream_data.get('type', 'LINEAR')
|
||||
quality = stream_data.get('quality', '')
|
||||
|
||||
logo_url = None
|
||||
if 'logo' in stream_data and 'url' in stream_data['logo']:
|
||||
logo_url = stream_data['logo']['url']
|
||||
|
||||
content_type = CONTENT_TYPE_LIVE if stream_type == 'LINEAR' else CONTENT_TYPE_VOD
|
||||
mode = MODE_LIVE if stream_type == 'LINEAR' else MODE_VOD
|
||||
|
||||
joyn_channel = JoynChannel(
|
||||
name=title,
|
||||
channel_id=channel_id,
|
||||
logo_url=logo_url,
|
||||
mode=mode,
|
||||
content_type=content_type,
|
||||
country=self.country,
|
||||
raw_data=stream_data
|
||||
)
|
||||
|
||||
if quality:
|
||||
joyn_channel.name = f"{title} ({quality})"
|
||||
|
||||
if 'brand' in stream_data:
|
||||
brand_data = stream_data['brand']
|
||||
if 'brand_id' in brand_data:
|
||||
joyn_channel.raw_data['brand_id'] = brand_data['brand_id']
|
||||
|
||||
if stream_data.get('eventStream', False):
|
||||
joyn_channel.raw_data['is_event_stream'] = True
|
||||
|
||||
streaming_channel = joyn_channel.to_streaming_channel(
|
||||
provider_name=self.provider_name
|
||||
)
|
||||
channels.append(streaming_channel)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Error processing channel data: {e}")
|
||||
|
||||
return channels
|
||||
|
||||
def get_entitlement_token(self, content_id: str, content_type: str = CONTENT_TYPE_LIVE) -> str:
|
||||
"""
|
||||
Get entitlement token for content
|
||||
|
||||
Args:
|
||||
content_id: Content ID
|
||||
content_type: Content type ('LIVE' or 'VOD')
|
||||
|
||||
Returns:
|
||||
Entitlement token string
|
||||
"""
|
||||
headers = JOYN_API_BASE_HEADERS.copy()
|
||||
headers.update({
|
||||
'joyn-client-version': JOYN_CLIENT_VERSION,
|
||||
'joyn-country': self.country.upper(),
|
||||
'joyn-distribution-tenant': self.distribution_tenant,
|
||||
'joyn-platform': self.platform,
|
||||
'origin': JOYN_DOMAINS.get(self.country, JOYN_DOMAINS['de'])
|
||||
})
|
||||
headers['Authorization'] = f'Bearer {self.bearer_token}'
|
||||
|
||||
payload = {
|
||||
"content_id": content_id,
|
||||
"content_type": content_type
|
||||
}
|
||||
|
||||
try:
|
||||
# Use http_manager instead of requests (proxy already configured)
|
||||
response = self.http_manager.post(
|
||||
JOYN_STREAMING_ENDPOINTS['ENTITLEMENT'],
|
||||
operation='auth',
|
||||
headers=headers,
|
||||
json_data=payload,
|
||||
timeout=DEFAULT_REQUEST_TIMEOUT
|
||||
)
|
||||
|
||||
if response.status_code == 400:
|
||||
try:
|
||||
error_data = response.json()
|
||||
if isinstance(error_data, list) and len(error_data) > 0:
|
||||
error = error_data[0]
|
||||
code = error.get("code", "UNKNOWN")
|
||||
msg = error.get("msg", "No error message provided")
|
||||
if code == ERROR_CODES['PLAYBACK_RESTRICTED']:
|
||||
raise PlaybackRestrictedException(f"Playback restricted for {content_id}: {msg}")
|
||||
else:
|
||||
raise Exception(f"Entitlement error for {content_id} ({code}): {msg}")
|
||||
except (json.JSONDecodeError, KeyError, IndexError) as e:
|
||||
raise Exception(f"Bad response for {content_id} (400), and failed to parse error: {e}")
|
||||
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
return data['entitlement_token']
|
||||
|
||||
except PlaybackRestrictedException:
|
||||
raise
|
||||
except KeyError:
|
||||
raise Exception(f"No entitlement_token in response for {content_id}")
|
||||
except Exception as e:
|
||||
raise Exception(f"Error getting entitlement token for {content_id}: {e}")
|
||||
|
||||
def get_channel_playlist(self, channel_id: str, entitlement_token: str,
|
||||
video_config: Optional[Dict] = None) -> Dict:
|
||||
"""
|
||||
Get channel playlist data
|
||||
|
||||
Args:
|
||||
channel_id: Channel ID
|
||||
entitlement_token: Entitlement token
|
||||
video_config: Optional video configuration override
|
||||
|
||||
Returns:
|
||||
Playlist data dictionary
|
||||
"""
|
||||
video_payload = create_video_payload(video_config)
|
||||
signature = build_signature(entitlement_token, video_payload)
|
||||
|
||||
url = JOYN_STREAMING_ENDPOINTS['PLAYLIST'].format(channel_id=channel_id)
|
||||
url += f"?signature={signature}"
|
||||
|
||||
headers = JOYN_API_BASE_HEADERS.copy()
|
||||
headers['Authorization'] = f'Bearer {entitlement_token}'
|
||||
|
||||
try:
|
||||
# Use http_manager instead of requests (proxy already configured)
|
||||
response = self.http_manager.post(
|
||||
url,
|
||||
operation='manifest',
|
||||
headers=headers,
|
||||
data=video_payload,
|
||||
timeout=DEFAULT_REQUEST_TIMEOUT
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
except Exception as e:
|
||||
raise Exception(f"Error getting playlist for {channel_id}: {e}")
|
||||
|
||||
def populate_streaming_data(self, channels: List[StreamingChannel],
|
||||
video_config: Optional[Dict] = None,
|
||||
max_retries: int = DEFAULT_MAX_RETRIES) -> List[StreamingChannel]:
|
||||
"""
|
||||
Populate streaming data (manifest, DRM) for all channels
|
||||
|
||||
Args:
|
||||
channels: List of channels to populate
|
||||
video_config: Optional video configuration
|
||||
max_retries: Maximum retry attempts per channel
|
||||
|
||||
Returns:
|
||||
List of successfully populated channels
|
||||
"""
|
||||
successful_channels = []
|
||||
|
||||
for channel in channels:
|
||||
retries = 0
|
||||
success = False
|
||||
is_restricted = False
|
||||
|
||||
while retries < max_retries and not success and not is_restricted:
|
||||
try:
|
||||
logger.debug(f"Getting entitlement token for: {channel.name} (attempt {retries + 1})")
|
||||
|
||||
entitlement_token = self.get_entitlement_token(
|
||||
content_id=channel.channel_id,
|
||||
content_type=channel.content_type
|
||||
)
|
||||
|
||||
logger.debug(f"Getting playlist data for: {channel.name}")
|
||||
|
||||
playlist_data = self.get_channel_playlist(
|
||||
channel.channel_id,
|
||||
entitlement_token,
|
||||
video_config
|
||||
)
|
||||
|
||||
manifest_url = playlist_data.get('manifestUrl')
|
||||
license_url = playlist_data.get('licenseUrl')
|
||||
certificate_url = playlist_data.get('certificateUrl')
|
||||
streaming_format = playlist_data.get('streamingFormat', 'dash')
|
||||
|
||||
if manifest_url:
|
||||
channel.manifest = manifest_url
|
||||
channel.cdm_type = DRM_SYSTEM_WIDEVINE
|
||||
channel.cdm = f"pid={channel.channel_id}"
|
||||
channel.license_url = license_url
|
||||
channel.certificate_url = certificate_url
|
||||
channel.streaming_format = streaming_format
|
||||
|
||||
logger.info(f"Streaming data populated for: {channel.name}")
|
||||
successful_channels.append(channel)
|
||||
success = True
|
||||
else:
|
||||
raise Exception("No manifestUrl in response")
|
||||
|
||||
except PlaybackRestrictedException as e:
|
||||
logger.warning(f"Playback restricted for {channel.name}: {e}")
|
||||
is_restricted = True
|
||||
|
||||
except Exception as e:
|
||||
retries += 1
|
||||
if retries < max_retries:
|
||||
logger.debug(f"Retry {retries}/{max_retries} for {channel.name}: {e}")
|
||||
time.sleep(1)
|
||||
else:
|
||||
logger.error(f"Failed to get streaming data for {channel.name}: {e}")
|
||||
|
||||
logger.info(f"Streaming data population complete:")
|
||||
logger.info(f" Successful: {len(successful_channels)}")
|
||||
logger.info(f" Restricted: {len([c for c in channels if c not in successful_channels])}")
|
||||
logger.info(f" Total: {len(channels)}")
|
||||
|
||||
return successful_channels
|
||||
|
||||
def enrich_channel_data(self,
|
||||
channel: StreamingChannel,
|
||||
video_config: Optional[Dict] = None,
|
||||
**kwargs) -> Optional[StreamingChannel]:
|
||||
"""
|
||||
Get manifest URL for a specific channel and properly configure DRM
|
||||
|
||||
Args:
|
||||
channel: StreamingChannel to enrich
|
||||
video_config: Optional video configuration dictionary
|
||||
**kwargs: Additional parameters
|
||||
|
||||
Returns:
|
||||
The enriched StreamingChannel with manifest and proper DRM config, or None if failed
|
||||
"""
|
||||
try:
|
||||
# First get entitlement token
|
||||
entitlement_token = self.get_entitlement_token(
|
||||
content_id=channel.channel_id,
|
||||
content_type=channel.content_type
|
||||
)
|
||||
|
||||
# Then get playlist data
|
||||
playlist_data = self.get_channel_playlist(
|
||||
channel.channel_id,
|
||||
entitlement_token,
|
||||
video_config
|
||||
)
|
||||
|
||||
manifest_url = playlist_data.get('manifestUrl')
|
||||
if not manifest_url:
|
||||
return None
|
||||
|
||||
# Update the channel with manifest
|
||||
channel.manifest = manifest_url
|
||||
channel.streaming_format = playlist_data.get('streamingFormat', 'dash')
|
||||
|
||||
# Only set up DRM if we have license information
|
||||
license_url = playlist_data.get('licenseUrl')
|
||||
if license_url:
|
||||
# Create proper DRM configuration with Joyn-specific requirements
|
||||
drm_config = DRMConfig(
|
||||
system=DRMSystem.WIDEVINE,
|
||||
priority=1,
|
||||
license=LicenseConfig(
|
||||
server_url=license_url,
|
||||
server_certificate=playlist_data.get('certificateUrl'),
|
||||
req_headers=json.dumps({
|
||||
'User-Agent': JOYN_USER_AGENT,
|
||||
'Content-Type': DRM_REQUEST_HEADERS['Content-Type']
|
||||
}),
|
||||
req_data="{CHA-RAW}",
|
||||
use_http_get_request=False
|
||||
)
|
||||
)
|
||||
channel.drm_config = drm_config
|
||||
channel.cdm_type = DRM_SYSTEM_WIDEVINE
|
||||
channel.cdm = f"pid={channel.channel_id}"
|
||||
|
||||
return channel
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting manifest for {channel.name}: {e}")
|
||||
return None
|
||||
|
||||
def get_drm_configs(self,
|
||||
channel: StreamingChannel,
|
||||
needs_base64_wrap: bool = False,
|
||||
**kwargs) -> List[DRMConfig]:
|
||||
"""
|
||||
Get DRM configuration for Joyn channels (Widevine)
|
||||
|
||||
Args:
|
||||
channel: StreamingChannel object
|
||||
needs_base64_wrap: Whether to wrap the license request in base64
|
||||
**kwargs: Additional parameters
|
||||
|
||||
Returns:
|
||||
List of DRMConfig objects (typically contains one Widevine config)
|
||||
"""
|
||||
if not channel.license_url:
|
||||
return [] # No DRM if no license URL
|
||||
|
||||
try:
|
||||
# Get fresh auth token if needed
|
||||
if not self.authenticator.is_authenticated():
|
||||
self.bearer_token = self.authenticator.authenticate()
|
||||
|
||||
# Prepare license headers
|
||||
license_headers = DRM_REQUEST_HEADERS.copy()
|
||||
license_headers['Authorization'] = f"Bearer {self.bearer_token}"
|
||||
|
||||
return [
|
||||
DRMConfig(
|
||||
system=DRMSystem.WIDEVINE,
|
||||
priority=1, # Highest priority for Widevine
|
||||
license=LicenseConfig(
|
||||
server_url=channel.license_url,
|
||||
server_certificate=channel.certificate_url,
|
||||
req_headers=urlencode(license_headers),
|
||||
use_http_get_request=False,
|
||||
wrapper="base64" if needs_base64_wrap else None
|
||||
)
|
||||
)
|
||||
]
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error generating DRM config for {channel.name}: {e}")
|
||||
return []
|
||||
|
||||
def get_manifest(self,
|
||||
channel_id: str,
|
||||
content_type: str = CONTENT_TYPE_LIVE,
|
||||
video_config: Optional[Dict] = None,
|
||||
**kwargs) -> Optional[str]:
|
||||
"""
|
||||
Get manifest URL for a specific channel by ID
|
||||
|
||||
Args:
|
||||
channel_id: ID of the channel to get manifest for
|
||||
content_type: Content type ('LIVE' or 'VOD')
|
||||
video_config: Optional video configuration dictionary
|
||||
**kwargs: Additional parameters (ignored for compatibility)
|
||||
|
||||
Returns:
|
||||
Manifest URL string, or None if not available
|
||||
"""
|
||||
try:
|
||||
# Get entitlement token
|
||||
entitlement_token = self.get_entitlement_token(
|
||||
content_id=channel_id,
|
||||
content_type=content_type
|
||||
)
|
||||
|
||||
# Get playlist data
|
||||
playlist_data = self.get_channel_playlist(
|
||||
channel_id,
|
||||
entitlement_token,
|
||||
video_config
|
||||
)
|
||||
|
||||
return playlist_data.get('manifestUrl')
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting manifest for channel {channel_id}: {e}")
|
||||
return None
|
||||
|
||||
def get_drm_configs_by_id(self,
|
||||
channel_id: str,
|
||||
content_type: str = CONTENT_TYPE_LIVE,
|
||||
video_config: Optional[Dict] = None,
|
||||
**kwargs) -> List[DRMConfig]:
|
||||
"""
|
||||
Get all DRM configurations for a channel by ID
|
||||
|
||||
Args:
|
||||
channel_id: ID of the channel to get DRM configs
|
||||
content_type: Content type ('LIVE' or 'VOD')
|
||||
video_config: Optional video configuration dictionary
|
||||
**kwargs: Additional parameters (ignored for compatibility)
|
||||
|
||||
Returns:
|
||||
List of DRMConfig objects (can be empty if no DRM is used)
|
||||
"""
|
||||
try:
|
||||
# Get entitlement token
|
||||
entitlement_token = self.get_entitlement_token(
|
||||
content_id=channel_id,
|
||||
content_type=content_type
|
||||
)
|
||||
|
||||
# Get playlist data
|
||||
playlist_data = self.get_channel_playlist(
|
||||
channel_id,
|
||||
entitlement_token,
|
||||
video_config
|
||||
)
|
||||
|
||||
license_url = playlist_data.get('licenseUrl')
|
||||
if not license_url:
|
||||
return [] # No DRM if no license URL
|
||||
|
||||
# Create DRM configuration
|
||||
drm_config = DRMConfig(
|
||||
system=DRMSystem.WIDEVINE,
|
||||
priority=1,
|
||||
license=LicenseConfig(
|
||||
server_url=license_url,
|
||||
server_certificate=playlist_data.get('certificateUrl'),
|
||||
req_headers=json.dumps({
|
||||
'Authorization': f'Bearer {self.bearer_token}',
|
||||
'Content-Type': DRM_REQUEST_HEADERS['Content-Type'],
|
||||
'User-Agent': JOYN_USER_AGENT
|
||||
}),
|
||||
req_data="{CHA-RAW}",
|
||||
use_http_get_request=False
|
||||
)
|
||||
)
|
||||
|
||||
return [drm_config]
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting DRM configs for channel {channel_id}: {e}")
|
||||
return []
|
||||
|
||||
def get_epg(self,
|
||||
channel_id: str,
|
||||
start_time: Optional[datetime] = None,
|
||||
end_time: Optional[datetime] = None,
|
||||
**kwargs) -> List[Dict]:
|
||||
"""
|
||||
Get EPG data for a channel
|
||||
|
||||
Args:
|
||||
channel_id: Channel ID to get EPG for
|
||||
start_time: Optional start time for EPG window
|
||||
end_time: Optional end time for EPG window
|
||||
**kwargs: Additional parameters
|
||||
|
||||
Returns:
|
||||
List of EPG entries (each containing start/end times, title, description, etc.)
|
||||
"""
|
||||
# Joyn EPG implementation would require additional GraphQL queries
|
||||
# This is a stub implementation - would need specific Joyn EPG endpoints
|
||||
try:
|
||||
# Default time window if not provided
|
||||
if start_time is None:
|
||||
start_time = datetime.now()
|
||||
if end_time is None:
|
||||
end_time = datetime.now() + timedelta(hours=DEFAULT_EPG_WINDOW_HOURS)
|
||||
|
||||
headers = self._get_graphql_headers()
|
||||
|
||||
# This would need the actual Joyn EPG GraphQL query
|
||||
# For now, return empty list as EPG functionality would require
|
||||
# additional endpoint investigation
|
||||
logger.info(f"EPG data requested for channel {channel_id} - not yet implemented")
|
||||
return []
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting EPG for channel {channel_id}: {e}")
|
||||
return []
|
||||
@@ -0,0 +1,8 @@
|
||||
# lib/streaming_providers/providers/rtlplus/__init__.py
|
||||
"""
|
||||
RTL+ streaming provider module
|
||||
"""
|
||||
|
||||
from .provider import RTLPlusProvider
|
||||
__all__ = ["RTLPlusProvider"]
|
||||
|
||||
@@ -0,0 +1,347 @@
|
||||
# streaming_providers/providers/rtlplus/auth.py
|
||||
import json
|
||||
import base64
|
||||
from typing import Dict, Any, Optional
|
||||
from ...base.auth.base_auth import BaseAuthToken, TokenAuthLevel
|
||||
from ...base.auth.base_oauth2_auth import BaseOAuth2Authenticator
|
||||
from ...base.utils.logger import logger
|
||||
from .models import RTLPlusClientCredentials, RTLPlusUserCredentials, RTLPlusAuthToken
|
||||
from .constants import RTLPlusDefaults, RTLPlusConfig
|
||||
from ...base.models.proxy_models import ProxyConfig
|
||||
|
||||
|
||||
class RTLPlusAuthenticator(BaseOAuth2Authenticator):
|
||||
def __init__(self, credentials=None, config_dir=None, client_version=None, device_id=None,
|
||||
proxy_config: Optional[ProxyConfig] = None, http_manager=None):
|
||||
|
||||
# Initialize configuration FIRST
|
||||
config_dict = {}
|
||||
if client_version:
|
||||
config_dict['client_version'] = client_version
|
||||
if device_id:
|
||||
config_dict['device_id'] = device_id
|
||||
|
||||
self._config = RTLPlusConfig(config_dict)
|
||||
self._client_id = None
|
||||
|
||||
# Get proxy_config if not provided
|
||||
if proxy_config is None:
|
||||
from ...base.network import ProxyConfigManager
|
||||
proxy_mgr = ProxyConfigManager(config_dir)
|
||||
proxy_config = proxy_mgr.get_proxy_config('rtlplus')
|
||||
|
||||
# Call parent init FIRST
|
||||
super().__init__(
|
||||
provider_name='rtlplus',
|
||||
credentials=credentials, # Pass None if not provided
|
||||
config_dir=config_dir,
|
||||
proxy_config=proxy_config,
|
||||
http_manager=http_manager
|
||||
)
|
||||
|
||||
# NOW set default credentials if needed (after super init)
|
||||
if self.credentials is None:
|
||||
self.credentials = self._get_default_credentials()
|
||||
|
||||
@property
|
||||
def auth_endpoint(self) -> str:
|
||||
"""Override auth_endpoint to use our config"""
|
||||
return self.config.auth_endpoint
|
||||
|
||||
# Required OAuth2 properties
|
||||
@property
|
||||
def oauth_client_id(self) -> str:
|
||||
return self._get_client_id()
|
||||
|
||||
@property
|
||||
def oauth_scope(self) -> str:
|
||||
return "openid email"
|
||||
|
||||
@property
|
||||
def oauth_redirect_uri(self) -> str:
|
||||
return self.config.base_website
|
||||
|
||||
def _get_auth_headers(self) -> Dict[str, str]:
|
||||
"""RTL+-specific authentication headers"""
|
||||
return self.config.get_auth_headers()
|
||||
|
||||
def _build_auth_payload(self) -> Dict[str, Any]:
|
||||
"""Build authentication payload from credentials"""
|
||||
return self.credentials.to_auth_payload()
|
||||
|
||||
def _get_default_credentials(self):
|
||||
"""Get default client credentials for anonymous access"""
|
||||
try:
|
||||
# Try to get dynamic credentials first
|
||||
config_creds = self._get_anonymous_credentials_from_config()
|
||||
if config_creds:
|
||||
return RTLPlusClientCredentials(
|
||||
client_id=config_creds.get('client_id', RTLPlusDefaults.ANONYMOUS_CLIENT_ID),
|
||||
client_secret=config_creds.get('client_secret', RTLPlusDefaults.ANONYMOUS_CLIENT_SECRET)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not get dynamic credentials: {e}")
|
||||
|
||||
# Fallback to default credentials
|
||||
return RTLPlusClientCredentials()
|
||||
|
||||
def _create_token_from_response(self, response_data: Dict[str, Any]) -> RTLPlusAuthToken:
|
||||
"""Create RTL+-specific token from OAuth2 response"""
|
||||
import time
|
||||
return RTLPlusAuthToken(
|
||||
access_token=response_data['access_token'],
|
||||
token_type=response_data.get('token_type', 'Bearer'),
|
||||
expires_in=response_data.get('expires_in', 86400),
|
||||
issued_at=response_data.get('issued_at', time.time()),
|
||||
refresh_token=response_data.get('refresh_token'),
|
||||
refresh_expires_in=response_data.get('refresh_expires_in', 0),
|
||||
not_before_policy=response_data.get('not-before-policy'),
|
||||
scope=response_data.get('scope', '')
|
||||
)
|
||||
|
||||
def get_fallback_credentials(self):
|
||||
"""Get fallback credentials (anonymous client credentials)"""
|
||||
return self._get_default_credentials()
|
||||
|
||||
def _classify_token(self, token: BaseAuthToken) -> TokenAuthLevel:
|
||||
"""
|
||||
Classify RTL+ token authentication level by decoding JWT payload
|
||||
|
||||
Logic:
|
||||
- CLIENT_CREDENTIALS: isGuest=True AND clientId='anonymous-user'
|
||||
- USER_AUTHENTICATED: Has preferred_username OR email claims
|
||||
- UNKNOWN: Cannot determine or invalid token
|
||||
|
||||
Args:
|
||||
token: Token to classify
|
||||
|
||||
Returns:
|
||||
TokenAuthLevel indicating the authentication level
|
||||
"""
|
||||
if not token or not token.access_token:
|
||||
logger.debug("RTL+ Cannot classify: No token or access token")
|
||||
return TokenAuthLevel.UNKNOWN
|
||||
|
||||
try:
|
||||
# Decode JWT without verification to check the payload
|
||||
parts = token.access_token.split('.')
|
||||
if len(parts) < 2:
|
||||
logger.debug("RTL+ Cannot classify: Invalid token format")
|
||||
return TokenAuthLevel.UNKNOWN
|
||||
|
||||
# Add padding if needed and decode
|
||||
payload_segment = parts[1]
|
||||
padding = 4 - len(payload_segment) % 4
|
||||
if padding != 4:
|
||||
payload_segment += '=' * padding
|
||||
|
||||
payload_json = base64.b64decode(payload_segment)
|
||||
payload = json.loads(payload_json)
|
||||
|
||||
# Extract relevant claims
|
||||
client_id = payload.get('clientId')
|
||||
is_guest = payload.get('isGuest', False)
|
||||
preferred_username = payload.get('preferred_username')
|
||||
email = payload.get('email')
|
||||
|
||||
logger.debug(f"RTL+ Token JWT payload: clientId={client_id}, isGuest={is_guest}, "
|
||||
f"has_preferred_username={bool(preferred_username)}, has_email={bool(email)}")
|
||||
|
||||
# Check for user-authenticated token
|
||||
if preferred_username or email:
|
||||
logger.debug("RTL+ Token classified as USER_AUTHENTICATED (has user claims)")
|
||||
return TokenAuthLevel.USER_AUTHENTICATED
|
||||
|
||||
# Check for client credentials (anonymous) token
|
||||
if is_guest and client_id == 'anonymous-user':
|
||||
logger.debug("RTL+ Token classified as CLIENT_CREDENTIALS (anonymous)")
|
||||
return TokenAuthLevel.CLIENT_CREDENTIALS
|
||||
|
||||
# Cannot determine
|
||||
logger.debug("RTL+ Token classified as UNKNOWN (no matching criteria)")
|
||||
return TokenAuthLevel.UNKNOWN
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"RTL+ Error classifying token: {e}")
|
||||
return TokenAuthLevel.UNKNOWN
|
||||
|
||||
def _perform_oauth_authorization_code_flow(self, username: str, password: str) -> Dict[str, Any]:
|
||||
"""
|
||||
RTL+ specific OAuth2 authorization code flow with PKCE
|
||||
Uses base class generic form login
|
||||
"""
|
||||
return self._perform_generic_form_login(
|
||||
username=username,
|
||||
password=password,
|
||||
form_selector_pattern=r'<form id="rtlplus-form-login" action="([^"]*)"',
|
||||
login_fields={'username': 'username', 'password': 'password'},
|
||||
extra_params={'prompt': 'login'},
|
||||
additional_form_data={
|
||||
'credentialId': '',
|
||||
'rememberMe': 'on'
|
||||
}
|
||||
)
|
||||
|
||||
def _get_client_id(self) -> str:
|
||||
"""Get client ID from RTL+ website configuration using base class method"""
|
||||
if self._client_id:
|
||||
return self._client_id
|
||||
|
||||
# Use base class method for extraction
|
||||
self._client_id = self._extract_client_id_from_js(
|
||||
main_page_url=self.config.base_website,
|
||||
js_file_pattern=r'<script src="(main[A-z0-9\-\.]+\.js)"',
|
||||
client_id_pattern=r'clientId:"([^"]+)"'
|
||||
)
|
||||
|
||||
if self._client_id:
|
||||
return self._client_id
|
||||
|
||||
# Fallback to default if extraction failed
|
||||
logger.warning("Could not extract client ID, using default")
|
||||
return RTLPlusDefaults.CLIENT_ID
|
||||
|
||||
def _get_client_version(self) -> str:
|
||||
"""Get client version from RTL+ configuration"""
|
||||
if self.config.client_version != RTLPlusDefaults.CLIENT_VERSION:
|
||||
return self.config.client_version
|
||||
|
||||
try:
|
||||
headers = self.config.get_base_headers()
|
||||
response = self.http_manager.get(
|
||||
self.config.config_endpoint,
|
||||
operation='api',
|
||||
headers=headers
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
config_data = response.json()
|
||||
version = config_data.get("version", RTLPlusDefaults.CLIENT_VERSION)
|
||||
|
||||
# Update config with retrieved version
|
||||
self.config.client_version = version
|
||||
return version
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting client version: {e}")
|
||||
return self.config.client_version
|
||||
|
||||
def _get_anonymous_credentials_from_config(self) -> Optional[Dict[str, str]]:
|
||||
"""
|
||||
Extract anonymous credentials from RTL+ website configuration
|
||||
Uses base class generic config extraction
|
||||
"""
|
||||
def parse_credentials(config_str: str) -> Dict[str, str]:
|
||||
"""Parse anonymousCredentials config string"""
|
||||
credentials = {}
|
||||
for pair in config_str.split(','):
|
||||
if ':' in pair:
|
||||
key, value = pair.split(':', 1)
|
||||
key = key.strip().strip('"')
|
||||
value = value.strip().strip('"')
|
||||
credentials[key] = value
|
||||
return credentials
|
||||
|
||||
return self._extract_config_from_js(
|
||||
main_page_url=self.config.base_website,
|
||||
js_file_pattern=r'<script src="(main[A-z0-9\-\.]+\.js)"',
|
||||
config_pattern=r'anonymousCredentials:\{([^}]+)\}',
|
||||
parse_function=parse_credentials
|
||||
)
|
||||
|
||||
# RTL+-specific credential management methods
|
||||
def set_user_credentials(self, username: str, password: str, client_id: Optional[str] = None) -> bool:
|
||||
"""
|
||||
Set RTL+ user credentials for authentication
|
||||
|
||||
Args:
|
||||
username: RTL+ username/email
|
||||
password: RTL+ password
|
||||
client_id: Optional client ID for user authentication
|
||||
|
||||
Returns:
|
||||
True if credentials were set and saved successfully
|
||||
"""
|
||||
try:
|
||||
# Create new user credentials
|
||||
user_creds = RTLPlusUserCredentials(
|
||||
username=username,
|
||||
password=password,
|
||||
client_id=client_id
|
||||
)
|
||||
|
||||
# Validate credentials
|
||||
if not user_creds.validate():
|
||||
logger.warning("Invalid user credentials provided")
|
||||
return False
|
||||
|
||||
# Set as current credentials
|
||||
self.credentials = user_creds
|
||||
|
||||
# Save to persistent storage
|
||||
success = self.save_credentials(user_creds)
|
||||
if success:
|
||||
logger.info("RTL+ user credentials saved successfully")
|
||||
# Invalidate current token to force re-authentication with new credentials
|
||||
self.invalidate_token()
|
||||
else:
|
||||
logger.error("Failed to save RTL+ user credentials")
|
||||
|
||||
return success
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error setting RTL+ user credentials: {e}")
|
||||
return False
|
||||
|
||||
def has_user_credentials(self) -> bool:
|
||||
"""
|
||||
Check if user credentials are currently set (not anonymous)
|
||||
|
||||
Returns:
|
||||
True if using user credentials, False if using anonymous access
|
||||
"""
|
||||
from ...base.auth.credentials import UserPasswordCredentials
|
||||
return isinstance(self.credentials, (RTLPlusUserCredentials, UserPasswordCredentials))
|
||||
|
||||
def has_stored_credentials(self) -> bool:
|
||||
"""
|
||||
Check for stored RTL+ user credentials
|
||||
"""
|
||||
try:
|
||||
logger.debug("RTL+ Checking for stored credentials using settings manager")
|
||||
stored_creds = self.settings_manager.get_provider_credentials(self.provider_name)
|
||||
|
||||
if not stored_creds:
|
||||
logger.debug("RTL+ No stored credentials found")
|
||||
return False
|
||||
|
||||
# Import the base credential types
|
||||
from ...base.auth.credentials import UserPasswordCredentials
|
||||
|
||||
# Check if it's either RTLPlusUserCredentials OR base UserPasswordCredentials
|
||||
is_user_creds = isinstance(stored_creds, (RTLPlusUserCredentials, UserPasswordCredentials))
|
||||
logger.debug(f"RTL+ Has stored user credentials: {is_user_creds} (type: {type(stored_creds)})")
|
||||
|
||||
return is_user_creds
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"RTL+ Error checking stored credentials: {e}")
|
||||
return False
|
||||
|
||||
def get_authentication_status(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Get RTL+-specific authentication status information
|
||||
"""
|
||||
status = super().get_authentication_status()
|
||||
|
||||
# Add RTL+-specific information
|
||||
status.update({
|
||||
'has_user_credentials': self.has_user_credentials(),
|
||||
'authentication_mode': 'user' if self.has_user_credentials() else 'anonymous',
|
||||
'client_version': self.config.client_version
|
||||
})
|
||||
|
||||
if self.has_user_credentials() and hasattr(self.credentials, 'username'):
|
||||
status['username'] = self.credentials.username
|
||||
|
||||
return status
|
||||
@@ -0,0 +1,155 @@
|
||||
# streaming_providers/providers/rtlplus/constants.py
|
||||
"""
|
||||
RTL+ provider constants and default configurations
|
||||
"""
|
||||
|
||||
|
||||
class RTLPlusDefaults:
|
||||
"""Default values for RTL+ provider"""
|
||||
|
||||
# Client and version information
|
||||
CLIENT_VERSION = '2025.6.26.0'
|
||||
CHROME_VERSION = '121.0.0.0'
|
||||
CLIENT_ID = 'rci:rtlplus:web'
|
||||
|
||||
# Device information
|
||||
DEVICE_ID = '8c3f37cc-13a3-4141-bd0f-e4b3673fe5e4'
|
||||
DEVICE_NAME = 'Linux Chrome'
|
||||
|
||||
# User Agent components
|
||||
USER_AGENT = f'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/{CHROME_VERSION} Safari/537.36'
|
||||
|
||||
# API endpoints
|
||||
AUTH_BASE_URL = 'https://auth.rtl.de/auth/realms/rtlplus/protocol/openid-connect'
|
||||
AUTH_ENDPOINT = f'{AUTH_BASE_URL}/token'
|
||||
AUTH_AUTHORIZE_ENDPOINT = f'{AUTH_BASE_URL}/auth'
|
||||
GRAPHQL_ENDPOINT = 'https://cdn.gateway.now-plus-prod.aws-cbc.cloud/graphql'
|
||||
MANIFEST_ENDPOINT = 'https://stus.player.streamingtech.de/livestream/linear/{channel_id}?platform=web'
|
||||
BASE_WEBSITE = 'https://plus.rtl.de/'
|
||||
CONFIG_ENDPOINT = 'https://plus.rtl.de/assets/config/config.json'
|
||||
|
||||
# Anonymous credentials (fallback)
|
||||
ANONYMOUS_CLIENT_ID = 'anonymous-user'
|
||||
ANONYMOUS_CLIENT_SECRET = '4bfeb73f-1c4a-4e9f-a7fa-96aa1ad3d94c'
|
||||
|
||||
# HTTP settings
|
||||
DEFAULT_TIMEOUT = 30
|
||||
|
||||
# GraphQL query parameters
|
||||
CHANNELS_QUERY_PARAMS = {
|
||||
"operationName": "LiveTvStations",
|
||||
"variables": '{"epgCount":4,"filter":{"channelTypes":["BROADCAST","FAST"]}}',
|
||||
"extensions": '{"persistedQuery":{"version":1,"sha256Hash":"845cf56a2a78110a0f978c1a2af2bc7f9a1c937d0f324ffaf852a9a4414c8485"}}'
|
||||
}
|
||||
|
||||
|
||||
class RTLPlusHeaders:
|
||||
"""Standard header configurations for RTL+ requests"""
|
||||
|
||||
@staticmethod
|
||||
def get_base_headers(user_agent: str = None) -> dict:
|
||||
"""Get base HTTP headers"""
|
||||
return {
|
||||
'User-Agent': user_agent or RTLPlusDefaults.USER_AGENT,
|
||||
'Accept': 'application/json',
|
||||
# 'Accept-Language': 'de-DE,de;q=0.9,en;q=0.8',
|
||||
# 'Accept-Encoding': 'gzip, deflate, br',
|
||||
# 'DNT': '1',
|
||||
# 'Connection': 'keep-alive',
|
||||
# 'Upgrade-Insecure-Requests': '1'
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def get_auth_headers(user_agent: str = None) -> dict:
|
||||
"""Get headers for authentication requests"""
|
||||
headers = RTLPlusHeaders.get_base_headers(user_agent)
|
||||
headers.update({
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'Origin': RTLPlusDefaults.BASE_WEBSITE.rstrip('/'),
|
||||
'Referer': RTLPlusDefaults.BASE_WEBSITE
|
||||
})
|
||||
return headers
|
||||
|
||||
@staticmethod
|
||||
def get_api_headers(access_token: str = None, device_id: str = None,
|
||||
client_version: str = None, user_agent: str = None) -> dict:
|
||||
"""Get headers for authenticated API requests"""
|
||||
headers = RTLPlusHeaders.get_base_headers(user_agent)
|
||||
headers.update({
|
||||
'Content-Type': 'application/json',
|
||||
'Rtlplus-Client-Id': RTLPlusDefaults.CLIENT_ID,
|
||||
'Rtlplus-Referrer': '',
|
||||
'Rtlplus-Client-Version': client_version or RTLPlusDefaults.CLIENT_VERSION,
|
||||
})
|
||||
|
||||
if access_token:
|
||||
headers['Authorization'] = f'Bearer {access_token}'
|
||||
|
||||
if device_id:
|
||||
headers['X-Device-Id'] = device_id
|
||||
|
||||
return headers
|
||||
|
||||
@staticmethod
|
||||
def get_drm_headers(access_token: str, device_id: str = None, user_agent: str = None) -> dict:
|
||||
"""Get headers for DRM license requests"""
|
||||
return {
|
||||
'X-Auth-Token': f'Bearer {access_token}',
|
||||
'X-Device-Id': device_id or RTLPlusDefaults.DEVICE_ID,
|
||||
'X-Device-Name': RTLPlusDefaults.DEVICE_NAME,
|
||||
'Content-Type': 'application/octet-stream',
|
||||
'User-Agent': user_agent or RTLPlusDefaults.USER_AGENT
|
||||
}
|
||||
|
||||
|
||||
class RTLPlusConfig:
|
||||
"""Configuration class that can be customized per instance"""
|
||||
|
||||
def __init__(self, config_dict: dict = None):
|
||||
"""Initialize with optional configuration overrides"""
|
||||
config = config_dict or {}
|
||||
|
||||
# Core settings (can be overridden)
|
||||
self.client_version = config.get('client_version', RTLPlusDefaults.CLIENT_VERSION)
|
||||
self.chrome_version = config.get('chrome_version', RTLPlusDefaults.CHROME_VERSION)
|
||||
self.device_id = config.get('device_id', RTLPlusDefaults.DEVICE_ID)
|
||||
self.user_agent = config.get('user_agent', RTLPlusDefaults.USER_AGENT)
|
||||
|
||||
# API endpoints (can be overridden for testing)
|
||||
self.auth_endpoint = config.get('auth_endpoint', RTLPlusDefaults.AUTH_ENDPOINT)
|
||||
self.graphql_endpoint = config.get('graphql_endpoint', RTLPlusDefaults.GRAPHQL_ENDPOINT)
|
||||
self.manifest_endpoint = config.get('manifest_endpoint', RTLPlusDefaults.MANIFEST_ENDPOINT)
|
||||
self.base_website = config.get('base_website', RTLPlusDefaults.BASE_WEBSITE)
|
||||
self.config_endpoint = config.get('config_endpoint', RTLPlusDefaults.CONFIG_ENDPOINT)
|
||||
|
||||
# HTTP settings
|
||||
self.timeout = config.get('timeout', RTLPlusDefaults.DEFAULT_TIMEOUT)
|
||||
|
||||
def get_manifest_url(self, channel_id: str) -> str:
|
||||
"""Get manifest URL for a specific channel"""
|
||||
return self.manifest_endpoint.format(channel_id=channel_id)
|
||||
|
||||
def get_base_headers(self) -> dict:
|
||||
"""Get base headers with this config's user agent"""
|
||||
return RTLPlusHeaders.get_base_headers(self.user_agent)
|
||||
|
||||
def get_auth_headers(self) -> dict:
|
||||
"""Get auth headers with this config's settings"""
|
||||
return RTLPlusHeaders.get_auth_headers(self.user_agent)
|
||||
|
||||
def get_api_headers(self, access_token: str = None) -> dict:
|
||||
"""Get API headers with this config's settings"""
|
||||
return RTLPlusHeaders.get_api_headers(
|
||||
access_token=access_token,
|
||||
device_id=self.device_id,
|
||||
client_version=self.client_version,
|
||||
user_agent=self.user_agent
|
||||
)
|
||||
|
||||
def get_drm_headers(self, access_token: str) -> dict:
|
||||
"""Get DRM headers with this config's settings"""
|
||||
return RTLPlusHeaders.get_drm_headers(
|
||||
access_token=access_token,
|
||||
device_id=self.device_id,
|
||||
user_agent=self.user_agent
|
||||
)
|
||||
@@ -0,0 +1,213 @@
|
||||
# streaming_providers/providers/rtlplus/models.py
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict, Any, Optional
|
||||
from ...base.auth.base_auth import BaseAuthToken
|
||||
from ...base.auth.credentials import UserPasswordCredentials, ClientCredentials
|
||||
from .constants import RTLPlusDefaults
|
||||
|
||||
|
||||
@dataclass
|
||||
class RTLPlusUserCredentials(UserPasswordCredentials):
|
||||
"""
|
||||
RTL+ specific username/password credentials
|
||||
"""
|
||||
|
||||
def __init__(self, username: str, password: str, client_id: Optional[str] = None):
|
||||
super().__init__(
|
||||
username=username,
|
||||
password=password,
|
||||
client_id=client_id or RTLPlusDefaults.CLIENT_ID,
|
||||
grant_type='password'
|
||||
)
|
||||
|
||||
def to_auth_payload(self) -> Dict[str, Any]:
|
||||
"""Convert to authentication payload for RTL+"""
|
||||
payload = {
|
||||
'grant_type': self.grant_type,
|
||||
'username': self.username,
|
||||
'password': self.password
|
||||
}
|
||||
# RTL+ might need client_id for user auth - adjust based on API requirements
|
||||
if self.client_id:
|
||||
payload['client_id'] = self.client_id
|
||||
return payload
|
||||
|
||||
|
||||
@dataclass
|
||||
class RTLPlusClientCredentials(ClientCredentials):
|
||||
"""
|
||||
RTL+ specific client credentials (anonymous access)
|
||||
"""
|
||||
|
||||
def __init__(self, client_id: Optional[str] = None, client_secret: Optional[str] = None):
|
||||
super().__init__(
|
||||
client_id=client_id or RTLPlusDefaults.ANONYMOUS_CLIENT_ID,
|
||||
client_secret=client_secret or RTLPlusDefaults.ANONYMOUS_CLIENT_SECRET,
|
||||
grant_type='client_credentials'
|
||||
)
|
||||
|
||||
|
||||
class RTLPlusAuthToken(BaseAuthToken):
|
||||
"""
|
||||
RTL+ specific authentication token
|
||||
"""
|
||||
|
||||
def __init__(self, access_token: str, token_type: str, expires_in: int,
|
||||
issued_at: float, refresh_token: Optional[str] = None,
|
||||
refresh_expires_in: int = 0, not_before_policy: Optional[int] = None,
|
||||
scope: str = ""):
|
||||
# Initialize parent class with refresh_expires_in
|
||||
super().__init__(
|
||||
access_token=access_token,
|
||||
token_type=token_type,
|
||||
expires_in=expires_in,
|
||||
issued_at=issued_at,
|
||||
refresh_token=refresh_token,
|
||||
refresh_expires_in=refresh_expires_in
|
||||
)
|
||||
# RTL+ specific fields
|
||||
self.refresh_expires_in = refresh_expires_in
|
||||
self.not_before_policy = not_before_policy
|
||||
self.scope = scope
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert token to dictionary"""
|
||||
return {
|
||||
'access_token': self.access_token,
|
||||
'token_type': self.token_type,
|
||||
'expires_in': self.expires_in,
|
||||
'issued_at': self.issued_at,
|
||||
'refresh_token': self.refresh_token,
|
||||
'refresh_expires_in': self.refresh_expires_in,
|
||||
'not_before_policy': self.not_before_policy,
|
||||
'scope': self.scope
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any], issued_at: float) -> 'RTLPlusAuthToken':
|
||||
"""Create token from dictionary response"""
|
||||
return cls(
|
||||
access_token=data['access_token'],
|
||||
token_type=data.get('token_type', 'Bearer'),
|
||||
expires_in=data.get('expires_in', 0),
|
||||
issued_at=issued_at,
|
||||
refresh_token=data.get('refresh_token'),
|
||||
refresh_expires_in=data.get('refresh_expires_in', 0),
|
||||
not_before_policy=data.get('not-before-policy'),
|
||||
scope=data.get('scope', '')
|
||||
)
|
||||
|
||||
def is_valid(self) -> bool:
|
||||
"""Check if token is still valid"""
|
||||
import time
|
||||
if not self.access_token:
|
||||
return False
|
||||
|
||||
current_time = time.time()
|
||||
# Add small buffer (30 seconds) to account for network delays
|
||||
buffer_time = 30
|
||||
|
||||
return current_time < (self.issued_at + self.expires_in - buffer_time)
|
||||
|
||||
# Add this method to the RTLPlusAuthToken class in models.py
|
||||
def is_anonymous_token(self) -> bool:
|
||||
"""Check if this token was obtained via anonymous authentication"""
|
||||
if not self.access_token:
|
||||
return True
|
||||
|
||||
try:
|
||||
# Simple check without full JWT decoding - look for anonymous client ID pattern
|
||||
return 'anonymous-user' in self.access_token
|
||||
except:
|
||||
return False
|
||||
|
||||
@dataclass
|
||||
class RTLPlusChannel:
|
||||
"""
|
||||
RTL+ channel information
|
||||
"""
|
||||
id: str
|
||||
name: str
|
||||
slug: str
|
||||
logo_url: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
is_live: bool = True
|
||||
channel_type: str = "BROADCAST"
|
||||
sort_order: int = 0
|
||||
|
||||
def __post_init__(self):
|
||||
"""Validate channel data after initialization"""
|
||||
if not self.id or not self.name:
|
||||
raise ValueError("Channel must have both id and name")
|
||||
|
||||
@classmethod
|
||||
def from_api_response(cls, data: Dict[str, Any]) -> 'RTLPlusChannel':
|
||||
"""Create channel from API response data"""
|
||||
return cls(
|
||||
id=data['id'],
|
||||
name=data['name'],
|
||||
slug=data.get('slug', ''),
|
||||
logo_url=data.get('logoUrl'),
|
||||
description=data.get('description'),
|
||||
is_live=data.get('isLive', True),
|
||||
channel_type=data.get('channelType', 'BROADCAST'),
|
||||
sort_order=data.get('sortOrder', 0)
|
||||
)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert channel to dictionary"""
|
||||
return {
|
||||
'id': self.id,
|
||||
'name': self.name,
|
||||
'slug': self.slug,
|
||||
'logo_url': self.logo_url,
|
||||
'description': self.description,
|
||||
'is_live': self.is_live,
|
||||
'channel_type': self.channel_type,
|
||||
'sort_order': self.sort_order
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class RTLPlusStreamInfo:
|
||||
"""
|
||||
RTL+ stream information
|
||||
"""
|
||||
manifest_url: str
|
||||
channel_id: str
|
||||
drm_license_url: Optional[str] = None
|
||||
drm_key_id: Optional[str] = None
|
||||
stream_type: str = "HLS"
|
||||
quality: str = "auto"
|
||||
|
||||
def __post_init__(self):
|
||||
"""Validate stream info after initialization"""
|
||||
if not self.manifest_url or not self.channel_id:
|
||||
raise ValueError("Stream must have both manifest_url and channel_id")
|
||||
|
||||
@classmethod
|
||||
def from_manifest_response(cls, data: Dict[str, Any], channel_id: str) -> 'RTLPlusStreamInfo':
|
||||
"""Create stream info from manifest API response"""
|
||||
return cls(
|
||||
manifest_url=data['url'],
|
||||
channel_id=channel_id,
|
||||
drm_license_url=data.get('drmLicenseUrl'),
|
||||
drm_key_id=data.get('drmKeyId'),
|
||||
stream_type=data.get('type', 'HLS'),
|
||||
quality=data.get('quality', 'auto')
|
||||
)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert stream info to dictionary"""
|
||||
return {
|
||||
'manifest_url': self.manifest_url,
|
||||
'channel_id': self.channel_id,
|
||||
'drm_license_url': self.drm_license_url,
|
||||
'drm_key_id': self.drm_key_id,
|
||||
'stream_type': self.stream_type,
|
||||
'quality': self.quality
|
||||
}
|
||||
|
||||
def has_drm(self) -> bool:
|
||||
"""Check if stream has DRM protection"""
|
||||
return bool(self.drm_license_url and self.drm_key_id)
|
||||
@@ -0,0 +1,417 @@
|
||||
# lib/streaming_providers/providers/rtlplus/provider.py
|
||||
import json
|
||||
import requests
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from ...base.provider import StreamingProvider
|
||||
from ...base.models.streaming_channel import StreamingChannel
|
||||
from ...base.models import DRMConfig, LicenseConfig, DRMSystem
|
||||
from .auth import RTLPlusAuthenticator
|
||||
from .constants import RTLPlusDefaults, RTLPlusConfig
|
||||
from ...base.utils import logger
|
||||
from ...base.models.proxy_models import ProxyConfig
|
||||
from ...base.network import HTTPManagerFactory
|
||||
|
||||
|
||||
class RTLPlusProvider(StreamingProvider):
|
||||
def __init__(self, country: str = 'DE', config: Optional[Dict] = None, proxy_config: Optional[ProxyConfig] = None):
|
||||
super().__init__(country)
|
||||
|
||||
# Initialize configuration with overrides
|
||||
self.rtl_config = RTLPlusConfig(config)
|
||||
self.channels_query_params = RTLPlusDefaults.CHANNELS_QUERY_PARAMS
|
||||
|
||||
# Create HTTP manager FIRST (this is the single source of truth)
|
||||
if proxy_config is None:
|
||||
from ...base.network import ProxyConfigManager
|
||||
proxy_mgr = ProxyConfigManager()
|
||||
proxy_config = proxy_mgr.get_proxy_config('rtlplus')
|
||||
|
||||
self.http_manager = HTTPManagerFactory.create_for_provider(
|
||||
'rtlplus',
|
||||
proxy_config=proxy_config,
|
||||
user_agent=self.rtl_config.user_agent,
|
||||
timeout=self.rtl_config.timeout
|
||||
)
|
||||
|
||||
# Initialize authenticator and SHARE the HTTP manager
|
||||
self.auth = RTLPlusAuthenticator(
|
||||
client_version=self.rtl_config.client_version,
|
||||
device_id=self.rtl_config.device_id,
|
||||
proxy_config=proxy_config,
|
||||
http_manager=self.http_manager # Share our HTTP manager instance
|
||||
)
|
||||
|
||||
# Share the HTTP manager with authenticator for consistency
|
||||
self.http_manager = self.auth.http_manager
|
||||
|
||||
try:
|
||||
self.bearer_token = self.auth.get_bearer_token()
|
||||
logger.debug(f"RTL+ authentication successful during initialization")
|
||||
except Exception as e:
|
||||
logger.warning(f"RTL+ could not authenticate during initialization: {e}")
|
||||
self.bearer_token = None
|
||||
|
||||
@property
|
||||
def provider_name(self) -> str:
|
||||
return "rtlplus"
|
||||
|
||||
@property
|
||||
def uses_dynamic_manifests(self) -> bool:
|
||||
# RTL+ provides relatively stable manifest URLs that can be fetched and cached
|
||||
return False
|
||||
|
||||
# In provider.py, modify the _get_authenticated_headers method:
|
||||
def _get_authenticated_headers(self) -> Dict[str, str]:
|
||||
"""
|
||||
Get headers with authentication and RTL+ specific headers
|
||||
"""
|
||||
# This will now automatically upgrade from anonymous to user token if possible
|
||||
bearer_token = self.auth.get_bearer_token(force_upgrade=True)
|
||||
return self.rtl_config.get_api_headers(access_token=bearer_token)
|
||||
|
||||
def fetch_channels(self, **kwargs) -> List[StreamingChannel]:
|
||||
"""
|
||||
Fetch channels from RTL+ GraphQL API with authentication
|
||||
"""
|
||||
try:
|
||||
headers = self._get_authenticated_headers()
|
||||
# OLD: response = requests.get(...)
|
||||
# NEW:
|
||||
response = self.http_manager.get(
|
||||
self.rtl_config.graphql_endpoint,
|
||||
operation='api',
|
||||
params=self.channels_query_params,
|
||||
headers=headers
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
channels = []
|
||||
if 'data' in data and 'liveTvStations' in data['data']:
|
||||
for station in data['data']['liveTvStations']:
|
||||
channel = self._parse_station_to_channel(station)
|
||||
if channel:
|
||||
channels.append(channel)
|
||||
|
||||
self.channels = channels
|
||||
return channels
|
||||
|
||||
except requests.RequestException as e:
|
||||
print(f"Error fetching RTL+ channels: {e}")
|
||||
# Try to refresh auth token and retry once
|
||||
try:
|
||||
print("Attempting to refresh authentication and retry...")
|
||||
self.auth.invalidate_token()
|
||||
headers = self._get_authenticated_headers()
|
||||
# OLD: response = requests.get(...)
|
||||
# NEW:
|
||||
response = self.http_manager.get(
|
||||
self.rtl_config.graphql_endpoint,
|
||||
operation='api',
|
||||
params=self.channels_query_params,
|
||||
headers=headers
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
channels = []
|
||||
if 'data' in data and 'liveTvStations' in data['data']:
|
||||
for station in data['data']['liveTvStations']:
|
||||
channel = self._parse_station_to_channel(station)
|
||||
if channel:
|
||||
channels.append(channel)
|
||||
|
||||
self.channels = channels
|
||||
return channels
|
||||
except Exception as retry_e:
|
||||
print(f"Retry failed: {retry_e}")
|
||||
return []
|
||||
except Exception as e:
|
||||
print(f"Error parsing RTL+ channels: {e}")
|
||||
return []
|
||||
|
||||
def _parse_station_to_channel(self, station: Dict) -> Optional[StreamingChannel]:
|
||||
"""
|
||||
Parse a station object from RTL+ API to StreamingChannel
|
||||
"""
|
||||
try:
|
||||
# Extract basic info
|
||||
name = station.get('name', '')
|
||||
channel_id = station.get('id', '')
|
||||
|
||||
if not name or not channel_id:
|
||||
return None
|
||||
|
||||
# Extract logo URL
|
||||
logo_url = None
|
||||
if 'images' in station and 'alternativeLandscapeUri' in station['images']:
|
||||
logo_url = station['images']['alternativeLandscapeUri']
|
||||
|
||||
# Determine if premium channel
|
||||
is_premium = station.get('isPremium', False)
|
||||
|
||||
# Extract watch path for potential manifest fetching
|
||||
watch_path = None
|
||||
if 'urlData' in station and 'watchPath' in station['urlData']:
|
||||
watch_path = station['urlData']['watchPath']
|
||||
|
||||
# Create channel object
|
||||
channel = StreamingChannel(
|
||||
name=name,
|
||||
channel_id=channel_id,
|
||||
provider=self.provider_name,
|
||||
logo_url=logo_url,
|
||||
mode="live",
|
||||
session_manifest=True, # RTL+ uses dynamic manifests
|
||||
manifest=None, # Will be set dynamically
|
||||
manifest_script=watch_path, # Store watch path for manifest fetching
|
||||
content_type="LIVE",
|
||||
country=self.country,
|
||||
language="de"
|
||||
)
|
||||
|
||||
# Set CDM settings for premium channels
|
||||
if is_premium:
|
||||
channel.use_cdm = True
|
||||
channel.cdm_type = "widevine" # Assumption - may need adjustment
|
||||
|
||||
return channel
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error parsing station {station}: {e}")
|
||||
return None
|
||||
|
||||
def enrich_channel_data(self, channel: StreamingChannel, **kwargs) -> Optional[StreamingChannel]:
|
||||
"""
|
||||
Enrich channel with manifest URL and additional data
|
||||
"""
|
||||
try:
|
||||
# Fetch manifest URL for this channel
|
||||
manifest_url = self.get_manifest(channel.channel_id, **kwargs)
|
||||
|
||||
if manifest_url:
|
||||
# Set the manifest URL - RTL+ provides relatively stable URLs
|
||||
channel.set_static_manifest(manifest_url)
|
||||
|
||||
# Check if this channel has DRM
|
||||
drm_configs = self.get_drm_configs_by_id(channel.channel_id, **kwargs)
|
||||
if drm_configs:
|
||||
# Set DRM configuration
|
||||
channel.use_cdm = True
|
||||
channel.cdm_type = "widevine" # Default to Widevine
|
||||
|
||||
# Set license URL from first Widevine config
|
||||
for config in drm_configs:
|
||||
if config.get('type') == 'widevine':
|
||||
channel.license_url = config.get('license_url')
|
||||
break
|
||||
else:
|
||||
channel.use_cdm = False
|
||||
channel.cdm_type = None
|
||||
|
||||
return channel
|
||||
else:
|
||||
print(f"Could not fetch manifest for channel {channel.name} ({channel.channel_id})")
|
||||
return channel
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error enriching channel data for {channel.name}: {e}")
|
||||
return channel
|
||||
|
||||
def get_manifest(self, channel_id: str, **kwargs) -> Optional[str]:
|
||||
manifest_url = self.rtl_config.get_manifest_url(channel_id)
|
||||
|
||||
try:
|
||||
# Log the request being made
|
||||
logger.debug(f"RTL+ Manifest Request: GET {manifest_url}")
|
||||
|
||||
headers = self.rtl_config.get_base_headers()
|
||||
# OLD: response = requests.get(...)
|
||||
# NEW:
|
||||
response = self.http_manager.get(
|
||||
manifest_url,
|
||||
operation='manifest',
|
||||
headers=headers
|
||||
)
|
||||
|
||||
# Log response status and headers
|
||||
logger.debug(f"RTL+ Manifest Response: Status={response.status_code}")
|
||||
logger.debug(f"RTL+ Response Headers: {dict(response.headers)}")
|
||||
|
||||
response.raise_for_status()
|
||||
manifest_data = response.json()
|
||||
|
||||
# Log the full response (sanitized if needed)
|
||||
logger.debug(f"RTL+ Manifest Data: {self._sanitize_manifest_log(manifest_data)}")
|
||||
|
||||
# Process manifest data (same as before)
|
||||
quality_preference = ['dashhd', 'dashsd']
|
||||
|
||||
for quality in quality_preference:
|
||||
for stream in manifest_data:
|
||||
if stream.get('name') == quality:
|
||||
sources = stream.get('sources', [])
|
||||
non_yospace_sources = [s for s in sources if not s.get('isYospace', False)]
|
||||
|
||||
if non_yospace_sources:
|
||||
selected_url = non_yospace_sources[0].get('url')
|
||||
logger.info(f"RTL+ Selected Manifest URL: {selected_url}")
|
||||
return selected_url
|
||||
|
||||
# Fallback logic
|
||||
for stream in manifest_data:
|
||||
sources = stream.get('sources', [])
|
||||
if sources:
|
||||
fallback_url = sources[0].get('url')
|
||||
logger.info(f"RTL+ Using Fallback Manifest URL: {fallback_url}")
|
||||
return fallback_url
|
||||
|
||||
logger.warning("RTL+ No valid manifest URL found in response")
|
||||
return None
|
||||
|
||||
except requests.RequestException as e:
|
||||
logger.error(f"RTL+ Manifest HTTP Error: {str(e)}")
|
||||
return None
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"RTL+ Manifest JSON Parse Error: {str(e)}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"RTL+ Manifest Unexpected Error: {str(e)}")
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _sanitize_manifest_log(manifest_data: Dict) -> Dict:
|
||||
"""
|
||||
Sanitize manifest data for logging (remove sensitive information)
|
||||
"""
|
||||
try:
|
||||
# Create a copy to avoid modifying original
|
||||
sanitized = manifest_data.copy()
|
||||
|
||||
# Remove or truncate potentially sensitive URLs
|
||||
if isinstance(sanitized, list):
|
||||
for stream in sanitized:
|
||||
if isinstance(stream, dict):
|
||||
if 'sources' in stream:
|
||||
for source in stream['sources']:
|
||||
if 'url' in source:
|
||||
# Truncate long URLs for logging
|
||||
url = source['url']
|
||||
if len(url) > 100:
|
||||
source['url'] = url[:100] + '...'
|
||||
return sanitized
|
||||
except Exception:
|
||||
return manifest_data
|
||||
|
||||
def get_drm_configs_by_id(self, channel_id: str, **kwargs) -> List[DRMConfig]:
|
||||
"""
|
||||
Get DRM configurations for a channel from RTL+ streaming API
|
||||
"""
|
||||
try:
|
||||
# Fetch manifest data to get license information
|
||||
manifest_url = self.rtl_config.get_manifest_url(channel_id)
|
||||
|
||||
# OLD: response = requests.get(manifest_url, timeout=self.rtl_config.timeout)
|
||||
# NEW:
|
||||
response = self.http_manager.get(
|
||||
manifest_url,
|
||||
operation='manifest'
|
||||
)
|
||||
response.raise_for_status()
|
||||
manifest_data = response.json()
|
||||
|
||||
drm_configs = []
|
||||
|
||||
# Get access token for license requests
|
||||
access_token = self.auth.get_bearer_token()
|
||||
|
||||
# Look for dashhd streams (preferred quality) and extract DRM info
|
||||
for stream in manifest_data:
|
||||
if stream.get('name') == 'dashhd' and 'licenses' in stream:
|
||||
licenses = stream.get('licenses', [])
|
||||
|
||||
for license_info in licenses:
|
||||
license_url = license_info.get('uri', {}).get('href')
|
||||
if not license_url:
|
||||
continue
|
||||
|
||||
if license_info.get('type') == 'WIDEVINE':
|
||||
drm_config = DRMConfig(
|
||||
system=DRMSystem.WIDEVINE,
|
||||
priority=1,
|
||||
license=LicenseConfig(
|
||||
server_url=license_url,
|
||||
req_headers=json.dumps(
|
||||
self.rtl_config.get_drm_headers(access_token)
|
||||
),
|
||||
req_data="{CHA-RAW}",
|
||||
use_http_get_request=False
|
||||
)
|
||||
)
|
||||
drm_configs.append(drm_config)
|
||||
|
||||
elif license_info.get('type') == 'PLAYREADY':
|
||||
drm_config = DRMConfig(
|
||||
system=DRMSystem.PLAYREADY,
|
||||
priority=2,
|
||||
license=LicenseConfig(
|
||||
server_url=license_url,
|
||||
req_headers=json.dumps(
|
||||
self.rtl_config.get_drm_headers(access_token)
|
||||
),
|
||||
req_data="{CHA-RAW}",
|
||||
use_http_get_request=False
|
||||
)
|
||||
)
|
||||
drm_configs.append(drm_config)
|
||||
|
||||
elif license_info.get('type') == 'FAIRPLAY':
|
||||
drm_config = DRMConfig(
|
||||
system=DRMSystem.FAIRPLAY,
|
||||
priority=3,
|
||||
license=LicenseConfig(
|
||||
server_url=license_url,
|
||||
req_headers=json.dumps(
|
||||
self.rtl_config.get_drm_headers(access_token)
|
||||
),
|
||||
req_data="{CHA-RAW}",
|
||||
use_http_get_request=False
|
||||
)
|
||||
)
|
||||
drm_configs.append(drm_config)
|
||||
break
|
||||
|
||||
return drm_configs
|
||||
|
||||
except requests.RequestException as e:
|
||||
print(f"Error fetching DRM configs for RTL+ channel {channel_id}: {e}")
|
||||
return []
|
||||
except Exception as e:
|
||||
print(f"Error parsing DRM configs for RTL+ channel {channel_id}: {e}")
|
||||
return []
|
||||
|
||||
def get_drm_configs(self, channel: StreamingChannel, **kwargs) -> List[DRMConfig]:
|
||||
"""
|
||||
Get DRM configurations for a channel
|
||||
"""
|
||||
return self.get_drm_configs_by_id(channel.channel_id, **kwargs)
|
||||
|
||||
@staticmethod
|
||||
def get_epg_data(channel_id: str, **kwargs) -> Optional[Dict]:
|
||||
"""
|
||||
Get EPG data for a channel
|
||||
"""
|
||||
# RTL+ EPG implementation would go here
|
||||
# This is a placeholder for future implementation
|
||||
return None
|
||||
|
||||
def get_license_url(self, channel: StreamingChannel, **kwargs) -> Optional[str]:
|
||||
"""
|
||||
Get license URL for a DRM-protected channel
|
||||
"""
|
||||
drm_configs = self.get_drm_configs(channel, **kwargs)
|
||||
if drm_configs:
|
||||
# Return the first license URL found
|
||||
return drm_configs[0].license.server_url
|
||||
return None
|
||||
@@ -0,0 +1,113 @@
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: Ultimate Streaming Backend\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2024-02-20 12:00+0000\n"
|
||||
"PO-Revision-Date: 2024-02-20 12:00+0000\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: \n"
|
||||
"Language: en_gb\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
|
||||
msgctxt "#30001"
|
||||
msgid "General"
|
||||
msgstr "General"
|
||||
|
||||
msgctxt "#30002"
|
||||
msgid "Joyn"
|
||||
msgstr "Joyn"
|
||||
|
||||
msgctxt "#30003"
|
||||
msgid "ZDF"
|
||||
msgstr "ZDF"
|
||||
|
||||
msgctxt "#30004"
|
||||
msgid "ARD"
|
||||
msgstr "ARD"
|
||||
|
||||
msgctxt "#30010"
|
||||
msgid "Server Port"
|
||||
msgstr "Server Port"
|
||||
|
||||
msgctxt "#30011"
|
||||
msgid "Default Country"
|
||||
msgstr "Default Country"
|
||||
|
||||
msgctxt "#30012"
|
||||
msgid "API Key"
|
||||
msgstr "API Key"
|
||||
|
||||
msgctxt "#30013"
|
||||
msgid "Enable EPG Caching"
|
||||
msgstr "Enable EPG Caching"
|
||||
|
||||
msgctxt "#30014"
|
||||
msgid "Cache Duration (hours)"
|
||||
msgstr "Cache Duration (hours)"
|
||||
|
||||
msgctxt "#30020"
|
||||
msgid "Enable Joyn"
|
||||
msgstr "Enable Joyn"
|
||||
|
||||
msgctxt "#30021"
|
||||
msgid "Joyn Email"
|
||||
msgstr "Joyn Email"
|
||||
|
||||
msgctxt "#30022"
|
||||
msgid "Joyn Password"
|
||||
msgstr "Joyn Password"
|
||||
|
||||
msgctxt "#30023"
|
||||
msgid "Joyn Country"
|
||||
msgstr "Joyn Country"
|
||||
|
||||
msgctxt "#30030"
|
||||
msgid "Enable ZDF"
|
||||
msgstr "Enable ZDF"
|
||||
|
||||
msgctxt "#30031"
|
||||
msgid "ZDF Username"
|
||||
msgstr "ZDF Username"
|
||||
|
||||
msgctxt "#30032"
|
||||
msgid "ZDF Password"
|
||||
msgstr "ZDF Password"
|
||||
|
||||
msgctxt "#30040"
|
||||
msgid "Enable ARD"
|
||||
msgstr "Enable ARD"
|
||||
|
||||
msgctxt "#30050"
|
||||
msgid "RTL+"
|
||||
msgstr "RTL+"
|
||||
|
||||
msgctxt "#30051"
|
||||
msgid "Enable RTL+"
|
||||
msgstr "Enable RTL+"
|
||||
|
||||
# RTL+ Settings
|
||||
msgctxt "#30100"
|
||||
msgid "RTL+"
|
||||
msgstr ""
|
||||
|
||||
msgctxt "#30101"
|
||||
msgid "Username"
|
||||
msgstr ""
|
||||
|
||||
msgctxt "#30102"
|
||||
msgid "Password"
|
||||
msgstr ""
|
||||
|
||||
msgctxt "#30110"
|
||||
msgid "Enable Proxy"
|
||||
msgstr ""
|
||||
|
||||
msgctxt "#30111"
|
||||
msgid "Proxy Host"
|
||||
msgstr ""
|
||||
|
||||
msgctxt "#30112"
|
||||
msgid "Proxy Port"
|
||||
msgstr ""
|
||||
@@ -0,0 +1,79 @@
|
||||
<?xml version="1.0" encoding="utf-8" standalone="yes"?>
|
||||
<settings>
|
||||
<category label="General">
|
||||
<setting id="server_port" type="number" label="30010" default="7777" />
|
||||
<setting id="default_country" type="labelenum" label="30011" values="DE|AT|CH|EU" default="DE" />
|
||||
<setting id="api_key" type="text" label="API Key" option="hidden" default="" />
|
||||
<setting id="enable_cache" type="bool" label="Enable EPG Caching" default="true" />
|
||||
<setting id="cache_duration" type="number" label="Cache Duration (hours)" default="6" visible="eq(-1,true)" />
|
||||
</category>
|
||||
|
||||
<category label="Joyn (DE)">
|
||||
<setting id="enable_joyn_de" type="bool" label="Enable Joyn (DE)" default="true" />
|
||||
<setting id="joyn_de_country" type="text" label="Country Code" default="de" visible="false" />
|
||||
<setting id="joyn_de_username" type="text" label="Email" default="" enable="eq(-2,true)" />
|
||||
<setting id="joyn_de_password" type="text" label="Password" option="hidden" default="" enable="eq(-3,true)" />
|
||||
|
||||
<!-- Optional: Proxy settings for DE -->
|
||||
<setting id="joyn_de_proxy_enabled" type="bool" label="Enable Proxy" default="false" enable="eq(-4,true)" />
|
||||
<setting id="joyn_de_proxy_host" type="text" label="Proxy Host" default="" visible="eq(-1,true)" />
|
||||
<setting id="joyn_de_proxy_port" type="number" label="Proxy Port" default="8080" visible="eq(-2,true)" />
|
||||
</category>
|
||||
|
||||
<!-- Joyn Austria -->
|
||||
<category label="Joyn (AT)">
|
||||
<setting id="enable_joyn_at" type="bool" label="Enable Joyn (AT)" default="false" />
|
||||
<setting id="joyn_at_country" type="text" label="Country Code" default="at" visible="false" />
|
||||
<setting id="joyn_at_username" type="text" label="Email" default="" enable="eq(-2,true)" />
|
||||
<setting id="joyn_at_password" type="text" label="Password" option="hidden" default="" enable="eq(-3,true)" />
|
||||
|
||||
<!-- Optional: Proxy settings for AT -->
|
||||
<setting id="joyn_at_proxy_enabled" type="bool" label="Enable Proxy" default="false" enable="eq(-4,true)" />
|
||||
<setting id="joyn_at_proxy_host" type="text" label="Proxy Host" default="" visible="eq(-1,true)" />
|
||||
<setting id="joyn_at_proxy_port" type="number" label="Proxy Port" default="8080" visible="eq(-2,true)" />
|
||||
</category>
|
||||
|
||||
<!-- Joyn Switzerland -->
|
||||
<category label="Joyn (CH)">
|
||||
<setting id="enable_joyn_ch" type="bool" label="Enable Joyn (CH)" default="false" />
|
||||
<setting id="joyn_ch_country" type="text" label="Country Code" default="ch" visible="false" />
|
||||
<setting id="joyn_ch_username" type="text" label="Email" default="" enable="eq(-2,true)" />
|
||||
<setting id="joyn_ch_password" type="text" label="Password" option="hidden" default="" enable="eq(-3,true)" />
|
||||
|
||||
<!-- Optional: Proxy settings for CH -->
|
||||
<setting id="joyn_ch_proxy_enabled" type="bool" label="Enable Proxy" default="false" enable="eq(-4,true)" />
|
||||
<setting id="joyn_ch_proxy_host" type="text" label="Proxy Host" default="" visible="eq(-1,true)" />
|
||||
<setting id="joyn_ch_proxy_port" type="number" label="Proxy Port" default="8080" visible="eq(-2,true)" />
|
||||
</category>
|
||||
|
||||
<!-- RTL+ Provider Settings -->
|
||||
<category id="rtlplus" label="30100">
|
||||
<setting id="rtlplus_username" type="text" label="30101" default="">
|
||||
<level>0</level>
|
||||
<constraints>
|
||||
<allowempty>false</allowempty>
|
||||
</constraints>
|
||||
</setting>
|
||||
|
||||
<setting id="rtlplus_password" type="text" label="30102" default="" option="hidden">
|
||||
<level>0</level>
|
||||
<constraints>
|
||||
<allowempty>false</allowempty>
|
||||
</constraints>
|
||||
</setting>
|
||||
|
||||
<setting id="rtlplus_proxy_enabled" type="bool" label="30110" default="false" />
|
||||
<setting id="rtlplus_proxy_host" type="text" label="30111" visible="eq(-1,true)" />
|
||||
<setting id="rtlplus_proxy_port" type="number" label="30112" visible="eq(-2,true)" />
|
||||
</category>
|
||||
|
||||
<category label="ZDF">
|
||||
<setting id="enable_zdf" type="bool" label="Enable ZDF" default="true" />
|
||||
<setting id="zdf_username" type="text" label="ZDF Username" default="" visible="eq(-1,true)" />
|
||||
<setting id="zdf_password" type="text" label="ZDF Password" option="hidden" default="" visible="eq(-2,true)" />
|
||||
</category>
|
||||
|
||||
<category label="ARD">
|
||||
<setting id="enable_ard" type="bool" label="Enable ARD" default="true" />
|
||||
</category>
|
||||
</settings>
|
||||
+916
@@ -0,0 +1,916 @@
|
||||
#!/usr/bin/env python3
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
from datetime import datetime
|
||||
import xbmc
|
||||
import xbmcaddon
|
||||
import json
|
||||
from bottle import Bottle, run, request, response, redirect, HTTPResponse
|
||||
from urllib.parse import urlencode, parse_qsl
|
||||
|
||||
# Get addon settings
|
||||
ADDON = xbmcaddon.Addon()
|
||||
ADDON_PATH = ADDON.getAddonInfo('path')
|
||||
LIB_PATH = os.path.join(ADDON_PATH, 'lib')
|
||||
sys.path.insert(0, LIB_PATH)
|
||||
|
||||
try:
|
||||
from streaming_providers import get_configured_manager
|
||||
from streaming_providers.base.models import StreamingChannel
|
||||
from streaming_providers.base.utils import logger, VFS, MPDRewriter, MPDCacheManager
|
||||
except ImportError as import_err:
|
||||
xbmc.log(f"Ultimate Backend: Critical import failed - {str(import_err)}", xbmc.LOGERROR)
|
||||
raise
|
||||
|
||||
|
||||
class UltimateService:
|
||||
def __init__(self):
|
||||
self.app = Bottle()
|
||||
try:
|
||||
self.manager = get_configured_manager()
|
||||
logger.info("Manager initialized successfully")
|
||||
except Exception as init_err:
|
||||
logger.error(f"Failed to initialize manager - {str(init_err)}")
|
||||
raise
|
||||
|
||||
# Initialize VFS for M3U caching
|
||||
self.vfs = VFS(addon_subdir="m3u_cache")
|
||||
logger.info(f"VFS initialized for M3U caching: {self.vfs.base_path}")
|
||||
|
||||
self.mpd_cache = MPDCacheManager()
|
||||
logger.info(f"MPD cache initialized: {self.mpd_cache.vfs.base_path}")
|
||||
|
||||
self.setup_routes()
|
||||
|
||||
def _get_proxied_manifest(self, provider: str, channel_id: str) -> str:
|
||||
"""
|
||||
Get proxied and rewritten MPD manifest for a channel.
|
||||
Uses cache when available and valid.
|
||||
|
||||
Args:
|
||||
provider: Provider name
|
||||
channel_id: Channel ID
|
||||
|
||||
Returns:
|
||||
Rewritten MPD content as string
|
||||
"""
|
||||
country = request.query.get('country')
|
||||
|
||||
# Try cache first
|
||||
cached_mpd = self.mpd_cache.get(provider, channel_id)
|
||||
if cached_mpd:
|
||||
response.content_type = 'application/dash+xml; charset=utf-8'
|
||||
return cached_mpd
|
||||
|
||||
# Cache miss - fetch and rewrite
|
||||
logger.info(f"Cache miss for {provider}/{channel_id}, fetching manifest")
|
||||
|
||||
# Get original manifest URL
|
||||
manifest_url = self.manager.get_channel_manifest(
|
||||
provider_name=provider,
|
||||
channel_id=channel_id,
|
||||
country=country
|
||||
)
|
||||
|
||||
if not manifest_url:
|
||||
response.status = 404
|
||||
response.content_type = 'application/json'
|
||||
return json.dumps(
|
||||
{'error': f'Manifest not available for channel "{channel_id}" from provider "{provider}"'})
|
||||
|
||||
# Get provider's HTTP manager
|
||||
http_manager = self.manager.get_provider_http_manager(provider)
|
||||
if not http_manager:
|
||||
logger.error(f"No HTTP manager found for provider '{provider}'")
|
||||
response.status = 502
|
||||
response.content_type = 'application/json'
|
||||
return json.dumps({'error': f'Provider "{provider}" not configured properly'})
|
||||
|
||||
# Fetch manifest via proxy
|
||||
try:
|
||||
logger.debug(f"Fetching manifest via proxy: {manifest_url}")
|
||||
manifest_response = http_manager.get(manifest_url, operation="manifest")
|
||||
|
||||
# Extract cache TTL from response headers
|
||||
ttl = MPDRewriter.extract_cache_ttl(manifest_response.headers)
|
||||
|
||||
# Also check MPD's own update period as fallback
|
||||
mpd_ttl = MPDRewriter.extract_mpd_update_period(manifest_response.text)
|
||||
if mpd_ttl and mpd_ttl < ttl:
|
||||
ttl = mpd_ttl
|
||||
logger.debug(f"Using MPD minimumUpdatePeriod as TTL: {ttl}s")
|
||||
|
||||
# Rewrite MPD URLs to point to proxy
|
||||
base_url = f"{request.urlparts.scheme}://{request.urlparts.netloc}"
|
||||
rewriter = MPDRewriter(base_url, provider)
|
||||
rewritten_mpd = rewriter.rewrite_mpd(manifest_response.text, manifest_url)
|
||||
|
||||
# Cache the rewritten MPD
|
||||
self.mpd_cache.set(
|
||||
provider=provider,
|
||||
channel_id=channel_id,
|
||||
mpd_content=rewritten_mpd,
|
||||
ttl=ttl,
|
||||
original_url=manifest_url
|
||||
)
|
||||
|
||||
# Return rewritten MPD
|
||||
response.content_type = 'application/dash+xml; charset=utf-8'
|
||||
return rewritten_mpd
|
||||
|
||||
except Exception as fetch_err:
|
||||
logger.error(f"Failed to fetch manifest via proxy: {fetch_err}")
|
||||
response.status = 502
|
||||
response.content_type = 'application/json'
|
||||
return json.dumps({'error': f'Failed to fetch manifest via proxy: {str(fetch_err)}'})
|
||||
|
||||
def setup_routes(self):
|
||||
@self.app.route('/api/providers')
|
||||
def list_providers():
|
||||
try:
|
||||
providers = self.manager.list_providers()
|
||||
default_country = ADDON.getSetting('default_country') or 'DE'
|
||||
return {
|
||||
'providers': providers,
|
||||
'default_country': default_country
|
||||
}
|
||||
except Exception as api_err:
|
||||
logger.error(f"API Error in /api/providers: {str(api_err)}")
|
||||
response.status = 500
|
||||
return {'error': str(api_err)}
|
||||
|
||||
@self.app.route('/api/providers/<provider>/channels')
|
||||
def get_channels(provider):
|
||||
try:
|
||||
channels = self.manager.get_channels(
|
||||
provider_name=provider,
|
||||
fetch_manifests=request.query.get('fetch_manifests', 'false').lower() == 'true',
|
||||
country=request.query.get('country')
|
||||
)
|
||||
return {
|
||||
'provider': provider,
|
||||
'country': self.manager.get_provider(provider).country if self.manager.get_provider(
|
||||
provider) else 'DE',
|
||||
'channels': [c.to_dict() for c in channels]
|
||||
}
|
||||
except Exception as api_err:
|
||||
logger.error(f"API Error in /api/providers/{provider}: {str(api_err)}")
|
||||
response.status = 500
|
||||
return {'error': str(api_err)}
|
||||
|
||||
@self.app.route('/api/providers/<provider>/channels/<channel_id>/manifest')
|
||||
def get_channel_manifest(provider, channel_id):
|
||||
"""
|
||||
Get channel manifest. If provider uses proxy, returns rewritten MPD content.
|
||||
Otherwise returns manifest URL as JSON.
|
||||
"""
|
||||
try:
|
||||
# Check if provider needs proxy
|
||||
if self.manager.needs_proxy(provider):
|
||||
# Proxy mode: return rewritten MPD content
|
||||
return self._get_proxied_manifest(provider, channel_id)
|
||||
else:
|
||||
# Direct mode: return manifest URL as JSON (existing behavior)
|
||||
manifest_url = self.manager.get_channel_manifest(
|
||||
provider_name=provider,
|
||||
channel_id=channel_id,
|
||||
country=request.query.get('country')
|
||||
)
|
||||
|
||||
if not manifest_url:
|
||||
response.status = 404
|
||||
return {'error': f'Manifest not available for channel "{channel_id}" from provider "{provider}"'}
|
||||
|
||||
return {
|
||||
'provider': provider,
|
||||
'channel_id': channel_id,
|
||||
'manifest_url': manifest_url
|
||||
}
|
||||
|
||||
except ValueError as val_err:
|
||||
logger.error(f"API Error in /api/providers/{provider}/channels/{channel_id}/manifest: {str(val_err)}")
|
||||
response.status = 404
|
||||
return {'error': str(val_err)}
|
||||
except Exception as api_err:
|
||||
logger.error(f"API Error in /api/providers/{provider}/channels/{channel_id}/manifest: {str(api_err)}")
|
||||
response.status = 500
|
||||
return {'error': f'Internal server error: {str(api_err)}'}
|
||||
|
||||
@self.app.route('/api/providers/<provider>/channels/<channel_id>/stream')
|
||||
def get_channel_stream(provider, channel_id):
|
||||
"""
|
||||
Returns HTTP 302 redirect to the actual manifest or rewritten manifest endpoint.
|
||||
This allows players to use this endpoint directly as a stream URL.
|
||||
"""
|
||||
try:
|
||||
# Check if provider needs proxy
|
||||
if self.manager.needs_proxy(provider):
|
||||
# Proxy mode: redirect to our manifest endpoint which serves rewritten MPD
|
||||
country = request.query.get('country')
|
||||
manifest_endpoint = f"/api/providers/{provider}/channels/{channel_id}/manifest"
|
||||
if country:
|
||||
manifest_endpoint += f"?country={country}"
|
||||
|
||||
logger.debug(f"Redirecting to proxied manifest endpoint: {manifest_endpoint}")
|
||||
redirect(manifest_endpoint)
|
||||
else:
|
||||
# Direct mode: redirect to original manifest URL
|
||||
manifest_url = self.manager.get_channel_manifest(
|
||||
provider_name=provider,
|
||||
channel_id=channel_id,
|
||||
country=request.query.get('country')
|
||||
)
|
||||
|
||||
if not manifest_url:
|
||||
response.status = 404
|
||||
return {
|
||||
'error': f'Manifest not available for channel "{channel_id}" from provider "{provider}"'}
|
||||
|
||||
logger.debug(f"Redirecting to manifest: {manifest_url}")
|
||||
redirect(manifest_url)
|
||||
|
||||
except HTTPResponse:
|
||||
# Re-raise HTTPResponse - this is how Bottle handles redirects
|
||||
raise
|
||||
except ValueError as val_err:
|
||||
logger.error(f"API Error in /api/providers/{provider}/channels/{channel_id}/stream: {str(val_err)}")
|
||||
response.status = 404
|
||||
return {'error': str(val_err)}
|
||||
except Exception as api_err:
|
||||
logger.error(f"API Error in /api/providers/{provider}/channels/{channel_id}/stream: {str(api_err)}")
|
||||
response.status = 500
|
||||
return {'error': f'Internal server error: {str(api_err)}'}
|
||||
|
||||
@self.app.route('/api/proxy/<provider>/<encoded_url:path>')
|
||||
def proxy_media_segment(provider, encoded_url):
|
||||
"""
|
||||
Proxy media segments through provider's HTTP manager.
|
||||
Decodes base64 URL and appends any template suffix, then fetches via configured proxy.
|
||||
|
||||
URL format: /api/proxy/<provider>/<base64_encoded_base_url>/<optional_template_path>
|
||||
Example: /api/proxy/joyn_ch/aHR0cHM6Ly9jZG4uZXhhbXBsZS5jb20vcGF0aA==/segment-123.m4s
|
||||
"""
|
||||
try:
|
||||
# Split the encoded_url into base64 part and optional suffix
|
||||
# The first path segment is the base64-encoded base URL
|
||||
# Everything after that is the template path (already resolved by client)
|
||||
parts = encoded_url.split('/', 1)
|
||||
base64_part = parts[0]
|
||||
template_suffix = parts[1] if len(parts) > 1 else ''
|
||||
|
||||
# Decode the base URL
|
||||
try:
|
||||
base_url = MPDRewriter.decode_url(base64_part)
|
||||
except Exception as decode_err:
|
||||
logger.error(f"Failed to decode proxy URL: {decode_err}")
|
||||
response.status = 400
|
||||
return {'error': 'Invalid encoded URL'}
|
||||
|
||||
# Reconstruct the full URL
|
||||
# If there's a template suffix, append it to the base URL
|
||||
if template_suffix:
|
||||
# Ensure proper joining (base_url might or might not end with /)
|
||||
if base_url.endswith('/'):
|
||||
original_url = base_url + template_suffix
|
||||
else:
|
||||
original_url = base_url + '/' + template_suffix
|
||||
else:
|
||||
original_url = base_url
|
||||
|
||||
logger.debug(f"Proxy request for {provider}:")
|
||||
logger.debug(f" Base64 part: {base64_part[:50]}...")
|
||||
logger.debug(f" Decoded base: {base_url}")
|
||||
logger.debug(f" Template suffix: {template_suffix}")
|
||||
logger.debug(f" Final URL: {original_url}")
|
||||
|
||||
# Get provider's HTTP manager
|
||||
http_manager = self.manager.get_provider_http_manager(provider)
|
||||
if not http_manager:
|
||||
logger.error(f"No HTTP manager found for provider '{provider}'")
|
||||
response.status = 404
|
||||
return {'error': f'Provider "{provider}" not found or not configured'}
|
||||
|
||||
# Check if proxy is configured
|
||||
if not http_manager.config.proxy_config:
|
||||
logger.error(f"Provider '{provider}' has no proxy configured")
|
||||
response.status = 502
|
||||
return {'error': f'Provider "{provider}" has no proxy configured'}
|
||||
|
||||
logger.info(f"Fetching media segment via proxy for {provider}: {original_url[:100]}...")
|
||||
|
||||
# Fetch via proxy using 'manifest' operation
|
||||
proxy_response = http_manager.get(original_url, operation="manifest")
|
||||
|
||||
logger.info(f"Successfully fetched segment, size: {len(proxy_response.content)} bytes")
|
||||
|
||||
# Set response headers from proxied response
|
||||
response.content_type = proxy_response.headers.get('Content-Type', 'application/octet-stream')
|
||||
|
||||
# Add Content-Length if available
|
||||
if 'Content-Length' in proxy_response.headers:
|
||||
response.headers['Content-Length'] = proxy_response.headers['Content-Length']
|
||||
|
||||
# Copy other potentially useful headers
|
||||
for header in ['Cache-Control', 'ETag', 'Last-Modified']:
|
||||
if header in proxy_response.headers:
|
||||
response.headers[header] = proxy_response.headers[header]
|
||||
|
||||
# Return the content directly
|
||||
return proxy_response.content
|
||||
|
||||
except Exception as proxy_err:
|
||||
logger.error(f"Proxy error for {provider}: {str(proxy_err)}", exc_info=True)
|
||||
response.status = 502
|
||||
return {'error': f'Proxy failed: {str(proxy_err)}'}
|
||||
|
||||
@self.app.route('/api/providers/<provider>/channels/<channel_id>/pssh')
|
||||
def get_channel_pssh(provider, channel_id):
|
||||
try:
|
||||
# Get the manifest URL first
|
||||
manifest_url = self.manager.get_channel_manifest(
|
||||
provider_name=provider,
|
||||
channel_id=channel_id,
|
||||
country=request.query.get('country')
|
||||
)
|
||||
|
||||
if not manifest_url:
|
||||
response.status = 404
|
||||
return {'error': f'Manifest not available for channel "{channel_id}" from provider "{provider}"'}
|
||||
|
||||
# Extract PSSH data from the manifest
|
||||
pssh_data_list = self.manager.extract_pssh_from_manifest(manifest_url)
|
||||
|
||||
if not pssh_data_list:
|
||||
response.status = 404
|
||||
return {
|
||||
'error': f'No PSSH data found in manifest for channel "{channel_id}" from provider "{provider}"'}
|
||||
|
||||
# Convert PSSH data to dictionary format for JSON response
|
||||
pssh_list = []
|
||||
for pssh_data in pssh_data_list:
|
||||
if hasattr(pssh_data, 'to_dict'):
|
||||
pssh_list.append(pssh_data.to_dict())
|
||||
else:
|
||||
# Fallback for basic PSSH data structure
|
||||
pssh_dict = {
|
||||
'pssh': getattr(pssh_data, 'pssh', str(pssh_data)) if hasattr(pssh_data, 'pssh') else str(
|
||||
pssh_data),
|
||||
'system_id': getattr(pssh_data, 'system_id', None),
|
||||
'key_id': getattr(pssh_data, 'key_id', None) if hasattr(pssh_data, 'key_id') else None
|
||||
}
|
||||
# Remove None values
|
||||
pssh_dict = {k: v for k, v in pssh_dict.items() if v is not None}
|
||||
pssh_list.append(pssh_dict)
|
||||
|
||||
return {
|
||||
'provider': provider,
|
||||
'channel_id': channel_id,
|
||||
'manifest_url': manifest_url,
|
||||
'pssh_data': pssh_list,
|
||||
'count': len(pssh_list)
|
||||
}
|
||||
|
||||
except ValueError as val_err:
|
||||
# This handles the case where manager raises ValueError for unknown provider
|
||||
logger.error(f"API Error in /api/providers/{provider}/channels/{channel_id}/pssh: {str(val_err)}")
|
||||
response.status = 404
|
||||
return {'error': str(val_err)}
|
||||
except Exception as api_err:
|
||||
logger.error(f"API Error in /api/providers/{provider}/channels/{channel_id}/pssh: {str(api_err)}")
|
||||
response.status = 500
|
||||
return {'error': f'Internal server error: {str(api_err)}'}
|
||||
|
||||
@self.app.route('/api/providers/<provider>/channels/<channel_id>/epg')
|
||||
def get_channel_epg(provider, channel_id):
|
||||
try:
|
||||
# Parse optional datetime parameters
|
||||
kwargs = {'country': request.query.get('country')}
|
||||
|
||||
if request.query.get('start_time'):
|
||||
try:
|
||||
kwargs['start_time'] = datetime.fromisoformat(
|
||||
request.query.get('start_time').replace('Z', '+00:00'))
|
||||
except ValueError:
|
||||
response.status = 400
|
||||
return {'error': 'Invalid start_time format. Use ISO format (YYYY-MM-DDTHH:MM:SS)'}
|
||||
|
||||
if request.query.get('end_time'):
|
||||
try:
|
||||
kwargs['end_time'] = datetime.fromisoformat(
|
||||
request.query.get('end_time').replace('Z', '+00:00'))
|
||||
except ValueError:
|
||||
response.status = 400
|
||||
return {'error': 'Invalid end_time format. Use ISO format (YYYY-MM-DDTHH:MM:SS)'}
|
||||
|
||||
epg_data = self.manager.get_channel_epg(
|
||||
provider_name=provider,
|
||||
channel_id=channel_id,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
return {
|
||||
'provider': provider,
|
||||
'channel_id': channel_id,
|
||||
'epg': epg_data
|
||||
}
|
||||
|
||||
except ValueError as val_err:
|
||||
# This handles the case where manager raises ValueError for unknown provider
|
||||
logger.error(f"API Error in /api/providers/{provider}/channels/{channel_id}/epg: {str(val_err)}")
|
||||
response.status = 404
|
||||
return {'error': str(val_err)}
|
||||
except Exception as api_err:
|
||||
logger.error(f"API Error in /api/providers/{provider}/channels/{channel_id}/epg: {str(api_err)}")
|
||||
response.status = 500
|
||||
return {'error': f'Internal server error: {str(api_err)}'}
|
||||
|
||||
@self.app.route('/api/providers/<provider>/epg')
|
||||
def get_provider_epg_xmltv(provider):
|
||||
try:
|
||||
# Set appropriate headers for XMLTV
|
||||
response.content_type = 'application/xml; charset=utf-8'
|
||||
response.headers['Content-Disposition'] = f'attachment; filename="{provider}_epg.xml"'
|
||||
|
||||
# Get the XMLTV data from the provider
|
||||
xmltv_data = self.manager.get_provider_epg_xmltv(
|
||||
provider_name=provider,
|
||||
country=request.query.get('country')
|
||||
)
|
||||
|
||||
if not xmltv_data:
|
||||
response.status = 404
|
||||
return {'error': f'EPG data not available for provider "{provider}"'}
|
||||
|
||||
return xmltv_data
|
||||
|
||||
except ValueError as val_err:
|
||||
# Handle unknown provider
|
||||
logger.error(f"API Error in /api/providers/{provider}/epg: {str(val_err)}")
|
||||
response.status = 404
|
||||
return {'error': str(val_err)}
|
||||
except Exception as api_err:
|
||||
logger.error(f"API Error in /api/providers/{provider}/epg: {str(api_err)}")
|
||||
response.status = 500
|
||||
return {'error': f'Internal server error: {str(api_err)}'}
|
||||
|
||||
@self.app.route('/api/providers/<provider>/channels/<channel_id>/drm')
|
||||
def get_channel_drm(provider, channel_id):
|
||||
try:
|
||||
drm_configs = self.manager.get_channel_drm_configs(
|
||||
provider_name=provider,
|
||||
channel_id=channel_id,
|
||||
country=request.query.get('country')
|
||||
)
|
||||
|
||||
return {
|
||||
'provider': provider,
|
||||
'channel_id': channel_id,
|
||||
'drm_configs': [config.to_dict() if hasattr(config, 'to_dict') else config for config in
|
||||
drm_configs]
|
||||
}
|
||||
|
||||
except ValueError as val_err:
|
||||
# This handles the case where manager raises ValueError for unknown provider
|
||||
logger.error(f"API Error in /api/providers/{provider}/channels/{channel_id}/drm: {str(val_err)}")
|
||||
response.status = 404
|
||||
return {'error': str(val_err)}
|
||||
except Exception as api_err:
|
||||
logger.error(f"API Error in /api/providers/{provider}/channels/{channel_id}/drm: {str(api_err)}")
|
||||
response.status = 500
|
||||
return {'error': f'Internal server error: {str(api_err)}'}
|
||||
|
||||
@self.app.route('/api/m3u')
|
||||
def get_m3u_all():
|
||||
"""
|
||||
Generates M3U playlist for all configured providers.
|
||||
Returns cached version if available, otherwise generates new one.
|
||||
|
||||
Example: http://localhost:7777/api/m3u
|
||||
"""
|
||||
try:
|
||||
cache_file = "playlist.m3u"
|
||||
|
||||
# Try to read cached file
|
||||
cached_content = self.vfs.read_text(cache_file)
|
||||
|
||||
if cached_content:
|
||||
logger.info("Serving cached M3U playlist for all providers")
|
||||
response.content_type = 'audio/x-mpegurl; charset=utf-8'
|
||||
response.headers['Content-Disposition'] = 'attachment; filename="playlist.m3u8"'
|
||||
return cached_content
|
||||
|
||||
# Cache doesn't exist or is corrupt, generate new M3U
|
||||
logger.info("No valid cache found, generating M3U playlist for all providers")
|
||||
return self._generate_m3u_all(save_to_cache=True)
|
||||
|
||||
except Exception as api_err:
|
||||
logger.error(f"API Error in /api/m3u: {str(api_err)}")
|
||||
response.status = 500
|
||||
return {'error': f'Internal server error: {str(api_err)}'}
|
||||
|
||||
@self.app.route('/api/m3u/generate')
|
||||
def generate_m3u_all():
|
||||
"""
|
||||
Forces regeneration of M3U playlist for all providers and saves to cache.
|
||||
|
||||
Example: http://localhost:7777/api/m3u/generate
|
||||
"""
|
||||
try:
|
||||
logger.info("Force generating M3U playlist for all providers")
|
||||
return self._generate_m3u_all(save_to_cache=True)
|
||||
|
||||
except Exception as api_err:
|
||||
logger.error(f"API Error in /api/m3u/generate: {str(api_err)}")
|
||||
response.status = 500
|
||||
return {'error': f'Internal server error: {str(api_err)}'}
|
||||
|
||||
@self.app.route('/api/providers/<provider>/m3u')
|
||||
def get_m3u_provider(provider):
|
||||
"""
|
||||
Generates M3U playlist for a specific provider.
|
||||
Returns cached version if available, otherwise generates new one.
|
||||
|
||||
Example: http://localhost:7777/api/providers/rtlplus/m3u
|
||||
"""
|
||||
try:
|
||||
cache_file = f"{provider}.m3u"
|
||||
|
||||
# Try to read cached file
|
||||
cached_content = self.vfs.read_text(cache_file)
|
||||
|
||||
if cached_content:
|
||||
logger.info(f"Serving cached M3U playlist for provider '{provider}'")
|
||||
response.content_type = 'audio/x-mpegurl; charset=utf-8'
|
||||
response.headers['Content-Disposition'] = f'attachment; filename="{provider}_playlist.m3u8"'
|
||||
return cached_content
|
||||
|
||||
# Cache doesn't exist or is corrupt, generate new M3U
|
||||
logger.info(f"No valid cache found, generating M3U playlist for provider '{provider}'")
|
||||
return self._generate_m3u_provider(provider, save_to_cache=True)
|
||||
|
||||
except ValueError as val_err:
|
||||
logger.error(f"API Error in /api/providers/{provider}/m3u: {str(val_err)}")
|
||||
response.status = 404
|
||||
return {'error': str(val_err)}
|
||||
except Exception as api_err:
|
||||
logger.error(f"API Error in /api/providers/{provider}/m3u: {str(api_err)}")
|
||||
response.status = 500
|
||||
return {'error': f'Internal server error: {str(api_err)}'}
|
||||
|
||||
@self.app.route('/api/providers/<provider>/m3u/generate')
|
||||
def generate_m3u_provider(provider):
|
||||
"""
|
||||
Forces regeneration of M3U playlist for a specific provider and saves to cache.
|
||||
|
||||
Example: http://localhost:7777/api/providers/rtlplus/m3u/generate
|
||||
"""
|
||||
try:
|
||||
logger.info(f"Force generating M3U playlist for provider '{provider}'")
|
||||
return self._generate_m3u_provider(provider, save_to_cache=True)
|
||||
|
||||
except ValueError as val_err:
|
||||
logger.error(f"API Error in /api/providers/{provider}/m3u/generate: {str(val_err)}")
|
||||
response.status = 404
|
||||
return {'error': str(val_err)}
|
||||
except Exception as api_err:
|
||||
logger.error(f"API Error in /api/providers/{provider}/m3u/generate: {str(api_err)}")
|
||||
response.status = 500
|
||||
return {'error': f'Internal server error: {str(api_err)}'}
|
||||
|
||||
@self.app.route('/api/cache/mpd/clear')
|
||||
def clear_mpd_cache():
|
||||
"""Clear all cached MPD manifests"""
|
||||
try:
|
||||
self.mpd_cache.clear_all()
|
||||
return {'success': True, 'message': 'MPD cache cleared'}
|
||||
except Exception as e:
|
||||
logger.error(f"Error clearing MPD cache: {e}")
|
||||
response.status = 500
|
||||
return {'error': str(e)}
|
||||
|
||||
@self.app.route('/api/cache/mpd/clear-expired')
|
||||
def clear_expired_mpd_cache():
|
||||
"""Clear expired MPD cache entries"""
|
||||
try:
|
||||
cleared = self.mpd_cache.clear_expired()
|
||||
return {'success': True, 'cleared': cleared}
|
||||
except Exception as e:
|
||||
logger.error(f"Error clearing expired MPD cache: {e}")
|
||||
response.status = 500
|
||||
return {'error': str(e)}
|
||||
|
||||
@self.app.route('/api/cache/mpd/<provider>/<channel_id>')
|
||||
def get_mpd_cache_info(provider, channel_id):
|
||||
"""Get cache information for a specific channel"""
|
||||
try:
|
||||
info = self.mpd_cache.get_cache_info(provider, channel_id)
|
||||
if info:
|
||||
return {'success': True, 'cache_info': info}
|
||||
else:
|
||||
response.status = 404
|
||||
return {'success': False, 'message': 'No cache found'}
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting cache info: {e}")
|
||||
response.status = 500
|
||||
return {'error': str(e)}
|
||||
|
||||
@self.app.route('/api/cache/mpd/<provider>/<channel_id>/delete')
|
||||
def delete_mpd_cache(provider, channel_id):
|
||||
"""Delete cached MPD for a specific channel"""
|
||||
try:
|
||||
self.mpd_cache.delete(provider, channel_id)
|
||||
return {'success': True, 'message': f'Cache deleted for {provider}/{channel_id}'}
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting cache: {e}")
|
||||
response.status = 500
|
||||
return {'error': str(e)}
|
||||
|
||||
def _generate_m3u_all(self, save_to_cache: bool = False) -> str:
|
||||
"""
|
||||
Internal method to generate M3U for all providers.
|
||||
|
||||
Args:
|
||||
save_to_cache: Whether to save generated M3U to cache file
|
||||
|
||||
Returns:
|
||||
M3U content as string
|
||||
"""
|
||||
# Get base URL for absolute stream URLs
|
||||
base_url = f"{request.urlparts.scheme}://{request.urlparts.netloc}"
|
||||
|
||||
# Start M3U content
|
||||
m3u_content = "#EXTM3U\n"
|
||||
|
||||
# Get all providers
|
||||
providers = self.manager.list_providers()
|
||||
|
||||
for provider_name in providers:
|
||||
try:
|
||||
# Get channels for this provider
|
||||
channels = self.manager.get_channels(
|
||||
provider_name=provider_name,
|
||||
fetch_manifests=False
|
||||
)
|
||||
|
||||
# Add each channel to M3U
|
||||
for channel in channels:
|
||||
# Access StreamingChannel attributes directly
|
||||
channel_id = channel.channel_id
|
||||
channel_name = channel.name
|
||||
channel_logo = channel.logo_url or ''
|
||||
|
||||
# Build stream URL
|
||||
stream_url = f"{base_url}/api/providers/{provider_name}/channels/{channel_id}/stream"
|
||||
|
||||
# Add M3U entry with extended info first
|
||||
m3u_content += f'#EXTINF:-1 tvg-logo="{channel_logo}" group-title="{provider_name}",{channel_name}\n'
|
||||
|
||||
# Get DRM configs and add KODIPROP directives
|
||||
try:
|
||||
drm_configs = self.manager.get_channel_drm_configs(
|
||||
provider_name=provider_name,
|
||||
channel_id=channel_id
|
||||
)
|
||||
|
||||
if drm_configs:
|
||||
# Prioritize: clearkey > widevine > playready
|
||||
selected_drm = None
|
||||
priority_order = ['org.w3.clearkey', 'com.widevine.alpha', 'com.microsoft.playready']
|
||||
|
||||
for priority_system in priority_order:
|
||||
for drm in drm_configs:
|
||||
drm_dict = drm.to_dict() if hasattr(drm, 'to_dict') else drm
|
||||
if priority_system in drm_dict:
|
||||
selected_drm = (priority_system, drm_dict[priority_system])
|
||||
break
|
||||
if selected_drm:
|
||||
break
|
||||
|
||||
if selected_drm:
|
||||
drm_system, drm_data = selected_drm
|
||||
|
||||
# Add KODIPROP directives
|
||||
m3u_content += "#KODIPROP:inputstream=inputstream.adaptive\n"
|
||||
m3u_content += "#KODIPROP:inputstream.adaptive.manifest_type=mpd\n"
|
||||
|
||||
# Build DRM legacy string
|
||||
drm_legacy_parts = [drm_system]
|
||||
|
||||
license_info = drm_data.get('license', {})
|
||||
|
||||
# Add license server URL or keyids
|
||||
if drm_system == 'org.w3.clearkey' and license_info.get('keyids'):
|
||||
# ClearKey: format as kid:key,kid:key
|
||||
keyids = license_info['keyids']
|
||||
keys_str = ','.join([f"{kid}:{key}" for kid, key in keyids.items()])
|
||||
drm_legacy_parts.append(keys_str)
|
||||
elif license_info.get('server_url'):
|
||||
# Widevine/PlayReady: add license server URL
|
||||
drm_legacy_parts.append(license_info['server_url'])
|
||||
|
||||
# Add headers if present (URL-encoded)
|
||||
if license_info.get('req_headers'):
|
||||
req_headers = license_info['req_headers']
|
||||
# If req_headers is a string, try to parse it
|
||||
if isinstance(req_headers, str):
|
||||
# Assume it's already in key=value&key=value format or similar
|
||||
# Just ensure it's URL-encoded
|
||||
if '&' in req_headers or '=' in req_headers:
|
||||
# Parse and re-encode to ensure proper encoding
|
||||
try:
|
||||
headers_dict = dict(parse_qsl(req_headers))
|
||||
req_headers = urlencode(headers_dict)
|
||||
except:
|
||||
# If parsing fails, use as-is
|
||||
pass
|
||||
elif isinstance(req_headers, dict):
|
||||
# Convert dict to URL-encoded string
|
||||
req_headers = urlencode(req_headers)
|
||||
|
||||
drm_legacy_parts.append(req_headers)
|
||||
|
||||
# Join parts with pipe separator
|
||||
drm_legacy = '|'.join(drm_legacy_parts)
|
||||
m3u_content += f"#KODIPROP:inputstream.adaptive.drm_legacy={drm_legacy}\n"
|
||||
|
||||
except Exception as drm_err:
|
||||
logger.debug(f"Could not get DRM for {provider_name}/{channel_id}: {str(drm_err)}")
|
||||
|
||||
# Add stream URL
|
||||
m3u_content += f'{stream_url}\n'
|
||||
|
||||
except Exception as provider_err:
|
||||
logger.warning(f"Failed to get channels for provider '{provider_name}': {str(provider_err)}")
|
||||
continue
|
||||
|
||||
# Save to cache if requested
|
||||
if save_to_cache:
|
||||
cache_file = "playlist.m3u"
|
||||
if self.vfs.write_text(cache_file, m3u_content):
|
||||
logger.info(f"M3U playlist cached to {cache_file}")
|
||||
else:
|
||||
logger.warning(f"Failed to cache M3U playlist to {cache_file}")
|
||||
|
||||
# Set appropriate headers for M3U
|
||||
response.content_type = 'audio/x-mpegurl; charset=utf-8'
|
||||
response.headers['Content-Disposition'] = 'attachment; filename="playlist.m3u8"'
|
||||
|
||||
return m3u_content
|
||||
|
||||
def _generate_m3u_provider(self, provider: str, save_to_cache: bool = False) -> str:
|
||||
"""
|
||||
Internal method to generate M3U for a specific provider.
|
||||
|
||||
Args:
|
||||
provider: Provider name
|
||||
save_to_cache: Whether to save generated M3U to cache file
|
||||
|
||||
Returns:
|
||||
M3U content as string
|
||||
"""
|
||||
# Get base URL for absolute stream URLs
|
||||
base_url = f"{request.urlparts.scheme}://{request.urlparts.netloc}"
|
||||
|
||||
# Start M3U content
|
||||
m3u_content = "#EXTM3U\n"
|
||||
|
||||
# Get channels for this provider
|
||||
channels = self.manager.get_channels(
|
||||
provider_name=provider,
|
||||
fetch_manifests=False
|
||||
)
|
||||
|
||||
# Add each channel to M3U
|
||||
for channel in channels:
|
||||
# Access StreamingChannel attributes directly
|
||||
channel_id = channel.channel_id
|
||||
channel_name = channel.name
|
||||
channel_logo = channel.logo_url or ''
|
||||
|
||||
# Build stream URL
|
||||
stream_url = f"{base_url}/api/providers/{provider}/channels/{channel_id}/stream"
|
||||
|
||||
# Add M3U entry with extended info first
|
||||
m3u_content += f'#EXTINF:-1 tvg-logo="{channel_logo}" group-title="{provider}",{channel_name}\n'
|
||||
|
||||
# Get DRM configs and add KODIPROP directives
|
||||
try:
|
||||
drm_configs = self.manager.get_channel_drm_configs(
|
||||
provider_name=provider,
|
||||
channel_id=channel_id
|
||||
)
|
||||
|
||||
if drm_configs:
|
||||
# Prioritize: clearkey > widevine > playready
|
||||
selected_drm = None
|
||||
priority_order = ['org.w3.clearkey', 'com.widevine.alpha', 'com.microsoft.playready']
|
||||
|
||||
for priority_system in priority_order:
|
||||
for drm in drm_configs:
|
||||
drm_dict = drm.to_dict() if hasattr(drm, 'to_dict') else drm
|
||||
if priority_system in drm_dict:
|
||||
selected_drm = (priority_system, drm_dict[priority_system])
|
||||
break
|
||||
if selected_drm:
|
||||
break
|
||||
|
||||
if selected_drm:
|
||||
drm_system, drm_data = selected_drm
|
||||
|
||||
# Add KODIPROP directives
|
||||
m3u_content += "#KODIPROP:inputstream=inputstream.adaptive\n"
|
||||
m3u_content += "#KODIPROP:inputstream.adaptive.manifest_type=mpd\n"
|
||||
|
||||
# Build DRM legacy string
|
||||
drm_legacy_parts = [drm_system]
|
||||
|
||||
license_info = drm_data.get('license', {})
|
||||
|
||||
# Add license server URL or keyids
|
||||
if drm_system == 'org.w3.clearkey' and license_info.get('keyids'):
|
||||
# ClearKey: format as kid:key,kid:key
|
||||
keyids = license_info['keyids']
|
||||
keys_str = ','.join([f"{kid}:{key}" for kid, key in keyids.items()])
|
||||
drm_legacy_parts.append(keys_str)
|
||||
elif license_info.get('server_url'):
|
||||
# Widevine/PlayReady: add license server URL
|
||||
drm_legacy_parts.append(license_info['server_url'])
|
||||
|
||||
# Add headers if present (URL-encoded)
|
||||
if license_info.get('req_headers'):
|
||||
req_headers = license_info['req_headers']
|
||||
# If req_headers is a string, try to parse it
|
||||
if isinstance(req_headers, str):
|
||||
# Assume it's already in key=value&key=value format or similar
|
||||
# Just ensure it's URL-encoded
|
||||
if '&' in req_headers or '=' in req_headers:
|
||||
# Parse and re-encode to ensure proper encoding
|
||||
try:
|
||||
headers_dict = dict(parse_qsl(req_headers))
|
||||
req_headers = urlencode(headers_dict)
|
||||
except:
|
||||
# If parsing fails, use as-is
|
||||
pass
|
||||
elif isinstance(req_headers, dict):
|
||||
# Convert dict to URL-encoded string
|
||||
req_headers = urlencode(req_headers)
|
||||
|
||||
drm_legacy_parts.append(req_headers)
|
||||
|
||||
# Join parts with pipe separator
|
||||
drm_legacy = '|'.join(drm_legacy_parts)
|
||||
m3u_content += f"#KODIPROP:inputstream.adaptive.drm_legacy={drm_legacy}\n"
|
||||
|
||||
except Exception as drm_err:
|
||||
logger.debug(f"Could not get DRM for {provider}/{channel_id}: {str(drm_err)}")
|
||||
|
||||
# Add stream URL
|
||||
m3u_content += f'{stream_url}\n'
|
||||
|
||||
# Save to cache if requested
|
||||
if save_to_cache:
|
||||
cache_file = f"{provider}.m3u"
|
||||
if self.vfs.write_text(cache_file, m3u_content):
|
||||
logger.info(f"M3U playlist for '{provider}' cached to {cache_file}")
|
||||
else:
|
||||
logger.warning(f"Failed to cache M3U playlist for '{provider}' to {cache_file}")
|
||||
|
||||
# Set appropriate headers for M3U
|
||||
response.content_type = 'audio/x-mpegurl; charset=utf-8'
|
||||
response.headers['Content-Disposition'] = f'attachment; filename="{provider}_playlist.m3u8"'
|
||||
|
||||
return m3u_content
|
||||
|
||||
def run_service():
|
||||
service = UltimateService()
|
||||
port = int(ADDON.getSetting("server_port") or 7777)
|
||||
logger.info(f"Starting server on port {port}")
|
||||
run(service.app, host='0.0.0.0', port=port, quiet=True, debug=True)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
logger.info("Starting service...")
|
||||
|
||||
# Give Kodi time to initialize
|
||||
import time
|
||||
|
||||
time.sleep(5)
|
||||
|
||||
try:
|
||||
service_thread = threading.Thread(
|
||||
target=run_service,
|
||||
name="UltimateBackendService"
|
||||
)
|
||||
service_thread.daemon = True
|
||||
service_thread.start()
|
||||
|
||||
monitor = xbmc.Monitor()
|
||||
while not monitor.abortRequested():
|
||||
if monitor.waitForAbort(5):
|
||||
break
|
||||
|
||||
logger.info("Service stopped")
|
||||
except Exception as startup_err:
|
||||
logger.error(f"Failed to start - {str(startup_err)}")
|
||||
raise
|
||||
Reference in New Issue
Block a user