Initial commit

This commit is contained in:
Nirvana
2025-10-29 20:23:50 +01:00
commit ee0848705d
52 changed files with 13824 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
# Default ignored files
/shelf/
/workspace.xml
+6
View File
@@ -0,0 +1,6 @@
<component name="InspectionProjectProfileManager">
<settings>
<option name="USE_PROJECT_PROFILE" value="false" />
<version value="1.0" />
</settings>
</component>
+7
View File
@@ -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>
+8
View File
@@ -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>
+12
View File
@@ -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
View File
@@ -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>
+26
View File
@@ -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>
+126
View File
@@ -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']
+19
View File
@@ -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")
+457
View File
@@ -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
+172
View File
@@ -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
+476
View File
@@ -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 ""
+79
View File
@@ -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
View File
@@ -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