From 3e25328e28159a19b9d6d2cfae39074f12afe9c4 Mon Sep 17 00:00:00 2001 From: Nirvana Date: Thu, 27 Nov 2025 21:12:38 +0100 Subject: [PATCH] Add hrti --- .../providers/hrti/__init__.py | 10 + .../providers/hrti/auth.py | 406 ++++++++++++++++++ .../providers/hrti/constants.py | 157 +++++++ .../providers/hrti/models.py | 225 ++++++++++ .../providers/hrti/provider.py | 326 ++++++++++++++ 5 files changed, 1124 insertions(+) create mode 100644 lib/streaming_providers/providers/hrti/__init__.py create mode 100644 lib/streaming_providers/providers/hrti/auth.py create mode 100644 lib/streaming_providers/providers/hrti/constants.py create mode 100644 lib/streaming_providers/providers/hrti/models.py create mode 100644 lib/streaming_providers/providers/hrti/provider.py diff --git a/lib/streaming_providers/providers/hrti/__init__.py b/lib/streaming_providers/providers/hrti/__init__.py new file mode 100644 index 0000000..7162e0b --- /dev/null +++ b/lib/streaming_providers/providers/hrti/__init__.py @@ -0,0 +1,10 @@ +# [file name]: __init__.py +# [file content begin] +# lib/streaming_providers/providers/hrti/__init__.py +""" +HRTi streaming provider module +""" + +from .provider import HRTiProvider +__all__ = ["HRTiProvider"] +# [file content end] \ No newline at end of file diff --git a/lib/streaming_providers/providers/hrti/auth.py b/lib/streaming_providers/providers/hrti/auth.py new file mode 100644 index 0000000..10604b0 --- /dev/null +++ b/lib/streaming_providers/providers/hrti/auth.py @@ -0,0 +1,406 @@ +# [file name]: auth.py +# [file content begin] +# streaming_providers/providers/hrti/auth.py +import json +import base64 +import time +from typing import Dict, Any, Optional +from datetime import datetime, timedelta + +from ...base.auth.base_auth import BaseAuthenticator, BaseAuthToken, TokenAuthLevel +from ...base.utils.logger import logger +from .models import HRTiCredentials, HRTiAuthToken +from .constants import HRTiConfig +from ...base.models.proxy_models import ProxyConfig + + +class HRTiAuthenticator(BaseAuthenticator): + def __init__(self, credentials=None, config_dir=None, + proxy_config: Optional[ProxyConfig] = None, http_manager=None): + + # Initialize configuration FIRST + self._config = HRTiConfig() + + # 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('hrti') + + # Store HTTP manager and proxy config locally (like MagentaEU) + self._http_manager = http_manager + self._proxy_config = proxy_config + + # Require http_manager like MagentaEU does + if http_manager is None: + raise ValueError("http_manager is required for HRTiAuthenticator") + + # Call parent init WITHOUT proxy_config and http_manager (like MagentaEU) + super().__init__( + provider_name='hrti', + credentials=credentials, + country='HR', # Default country for HRTi + config_dir=config_dir + # NO proxy_config or http_manager passed to parent + ) + + # Initialize HRTi-specific properties + self._ip_address = None + self._device_id = None + self._user_id = None + + # Load or initialize device ID + self._initialize_device() + + @property + def config(self): + """Safe access to config""" + return self._config + + @property + def http_manager(self): + """Safe access to http_manager - required by provider""" + if self._http_manager is None: + # This should never happen since we validate in __init__ + raise ValueError("HTTP manager not available - this should have been set during initialization") + return self._http_manager + + @http_manager.setter + def http_manager(self, value): + """Allow setting http_manager""" + self._http_manager = value + + @property + def auth_endpoint(self) -> str: + """HRTi authentication endpoint""" + return self.config.api_endpoints['grant_access'] + + def _get_auth_headers(self) -> Dict[str, str]: + """Get headers for authentication requests""" + return self.config.get_base_headers() + + def _build_auth_payload(self) -> Dict[str, Any]: + """Build authentication payload from credentials""" + return self.credentials.to_auth_payload() + + def _create_token_from_response(self, response_data: Dict[str, Any]) -> HRTiAuthToken: + """Create HRTi-specific token from API response""" + result = response_data.get('Result', {}) + + # Store user ID if available + if 'Customer' in result: + self._user_id = result['Customer'].get('CustomerId', '') + + return HRTiAuthToken( + access_token=result.get('Token', ''), + token_type='Client', + expires_in=86400, # 24 hours default + issued_at=time.time(), + user_id=self._user_id, + valid_from=result.get('ValidFrom', ''), + valid_to=result.get('ValidTo', '') + ) + + def get_fallback_credentials(self): + """Get fallback credentials (anonymous access)""" + return HRTiCredentials( + username='anonymoushrt', + password='an0nPasshrt' + ) + + def _perform_authentication(self) -> HRTiAuthToken: + """Perform HRTi custom authentication flow""" + logger.info("Starting HRTi authentication flow") + + # Step 1: Get IP address + self._get_ip_address() + + # Step 2: Get environment configuration + self._get_environment_config() + + # Step 3: Perform grant access + token_data = self._perform_grant_access() + + # Step 4: Register device + self._register_device() + + # Step 5: Get content rating and profiles + self._get_initial_data() + + token = self._create_token_from_response(token_data) + logger.info("HRTi authentication successful") + return token + + def _get_ip_address(self) -> str: + """Get public IP address""" + if self._ip_address: + return self._ip_address + + try: + # Use the http_manager that's guaranteed to be available + response = self.http_manager.get( + self.config.api_endpoints['get_ip'], + operation='api' + ) + response.raise_for_status() + self._ip_address = response.text.strip() + logger.debug(f"Retrieved IP address: {self._ip_address}") + return self._ip_address + except Exception as e: + logger.error(f"Error getting IP address: {e}") + self._ip_address = "0.0.0.0" # Fallback + return self._ip_address + + def _get_environment_config(self): + """Get HRTi environment configuration""" + try: + # Ensure we have http_manager + if not self.http_manager: + raise Exception("HTTP manager not available") + + # Get env config + env_response = self.http_manager.get( + self.config.env_endpoint, + operation='api' + ) + env_response.raise_for_status() + env_data = env_response.json() + + # Get main config + config_response = self.http_manager.get( + self.config.config_endpoint, + operation='api' + ) + config_response.raise_for_status() + config_data = config_response.json() + + # Update config with retrieved values + self.config.update_from_api(env_data, config_data) + logger.debug("HRTi environment configuration loaded") + + except Exception as e: + logger.warning(f"Error loading HRTi environment config: {e}") + + def _perform_grant_access(self) -> Dict[str, Any]: + """Perform grant access authentication""" + try: + # Ensure we have http_manager + if not self.http_manager: + raise Exception("HTTP manager not available") + + headers = self._get_auth_headers() + payload = self._build_auth_payload() + + response = self.http_manager.post( + self.auth_endpoint, + operation='auth', + headers=headers, + data=json.dumps(payload) + ) + response.raise_for_status() + + result = response.json() + if 'Result' not in result: + raise Exception("No result in grant access response") + + return result + + except Exception as e: + logger.error(f"HRTi grant access failed: {e}") + # Try fallback credentials + if not isinstance(self.credentials, HRTiCredentials) or self.credentials.username != 'anonymoushrt': + logger.info("Falling back to anonymous credentials") + self.credentials = self.get_fallback_credentials() + return self._perform_grant_access() + else: + raise e + + def _register_device(self): + """Register device with HRTi""" + try: + # Ensure we have http_manager + if not self.http_manager: + raise Exception("HTTP manager not available") + + headers = self.config.get_auth_headers( + device_id=self._device_id, + ip_address=self._ip_address, + token=self._current_token.access_token if self._current_token else '' + ) + + payload = { + "DeviceSerial": self._device_id, + "DeviceReferenceId": self.config.device_reference_id, + "IpAddress": self._ip_address, + "ConnectionType": self.config.connection_type, + "ApplicationVersion": self.config.application_version, + "DrmId": self._device_id, + "OsVersion": self.config.os_version, + "ClientType": self.config.client_type + } + + response = self.http_manager.post( + self.config.api_endpoints['register_device'], + operation='api', + headers=headers, + data=json.dumps(payload) + ) + response.raise_for_status() + logger.debug("HRTi device registration successful") + + except Exception as e: + logger.warning(f"HRTi device registration failed: {e}") + + def _get_initial_data(self): + """Get initial content rating and profiles""" + try: + # Ensure we have http_manager + if not self.http_manager: + raise Exception("HTTP manager not available") + + headers = self.config.get_auth_headers( + device_id=self._device_id, + ip_address=self._ip_address, + token=self._current_token.access_token if self._current_token else '' + ) + + # Get content ratings + content_response = self.http_manager.post( + self.config.api_endpoints['content_ratings'], + operation='api', + headers=headers, + data=json.dumps({}) + ) + content_response.raise_for_status() + + # Get profiles + profiles_response = self.http_manager.post( + self.config.api_endpoints['profiles'], + operation='api', + headers=headers, + data=json.dumps({}) + ) + profiles_response.raise_for_status() + + logger.debug("HRTi initial data loaded") + + except Exception as e: + logger.debug(f"Error loading HRTi initial data: {e}") + + def _initialize_device(self): + """Initialize or load device ID""" + try: + self._device_id = self.settings_manager.get_device_id(self.provider_name, self.country) + if not self._device_id: + import uuid + self._device_id = str(uuid.uuid4()) + logger.debug(f"Generated new device ID: {self._device_id}") + except Exception as e: + logger.error(f"Error initializing device ID: {e}") + import uuid + self._device_id = str(uuid.uuid4()) + + def get_device_id(self) -> str: + """Get device ID""" + return self._device_id + + def get_ip_address(self) -> str: + """Get IP address""" + if not self._ip_address: + self._get_ip_address() + return self._ip_address + + def authorize_session(self, content_type: str, content_ref_id: str, + channel_id: str = None, **kwargs) -> Optional[Dict[str, Any]]: + """Authorize a playback session""" + try: + # Ensure we have http_manager + if not self.http_manager: + raise Exception("HTTP manager not available") + + headers = self.config.get_auth_headers( + device_id=self._device_id, + ip_address=self._ip_address, + token=self._current_token.access_token if self._current_token else '' + ) + + payload = { + "ContentType": content_type, + "ContentReferenceId": content_ref_id, + "ContentDrmId": f"{content_ref_id}_drm", + "VideostoreReferenceIds": kwargs.get('video_store_ids', []), + "ChannelReferenceId": channel_id, + "StartTime": kwargs.get('start_time'), + "EndTime": kwargs.get('end_time') + } + + response = self.http_manager.post( + self.config.api_endpoints['authorize_session'], + operation='api', + headers=headers, + data=json.dumps(payload) + ) + response.raise_for_status() + + result = response.json() + if 'Result' in result: + logger.debug("HRTi session authorization successful") + return result['Result'] + else: + logger.warning("No result in session authorization response") + return None + + except Exception as e: + logger.error(f"HRTi session authorization failed: {e}") + return None + + def get_license_data(self, session_id: str) -> str: + """Generate license data for DRM""" + try: + drm_license = { + 'userId': self._user_id or '', + 'sessionId': session_id, + 'merchant': self.config.merchant + } + + license_bytes = json.dumps(drm_license).encode('utf-8') + license_b64 = base64.b64encode(license_bytes).decode('utf-8') + return license_b64 + + except Exception as e: + logger.error(f"Error generating license data: {e}") + return "" + + @staticmethod + def get_time_offset(hours_offset: int) -> int: + """Get timestamp with offset in milliseconds""" + target_time = datetime.now() + timedelta(hours=hours_offset) + return int(target_time.timestamp() * 1000) + + def _classify_token(self, token: BaseAuthToken) -> TokenAuthLevel: + """Classify HRTi token authentication level""" + if not token or not token.access_token: + return TokenAuthLevel.ANONYMOUS + + # Check if using anonymous credentials + if (hasattr(self.credentials, 'username') and + self.credentials.username == 'anonymoushrt'): + return TokenAuthLevel.ANONYMOUS + + # If we have a valid token and user credentials, consider it user authenticated + if (hasattr(self.credentials, 'username') and + self.credentials.username and + self.credentials.username != 'anonymoushrt'): + return TokenAuthLevel.USER_AUTHENTICATED + + return TokenAuthLevel.CLIENT_CREDENTIALS + + def _refresh_token(self) -> Optional[BaseAuthToken]: + """Refresh HRTi token - reauthenticate since it's custom auth""" + logger.info("Refreshing HRTi token via reauthentication") + try: + return self._perform_authentication() + except Exception as e: + logger.error(f"HRTi token refresh failed: {e}") + return None +# [file content end] \ No newline at end of file diff --git a/lib/streaming_providers/providers/hrti/constants.py b/lib/streaming_providers/providers/hrti/constants.py new file mode 100644 index 0000000..36df4e2 --- /dev/null +++ b/lib/streaming_providers/providers/hrti/constants.py @@ -0,0 +1,157 @@ +# [file name]: constants.py +# [file content begin] +# streaming_providers/providers/hrti/constants.py +""" +HRTi provider constants and default configurations +""" + + +class HRTiDefaults: + """Default values for HRTi provider""" + + # Provider information + PROVIDER_LOGO = 'https://hrti.hrt.hr/assets/images/logo.png' + PROVIDER_NAME = 'HRTi' + + # Website and base URLs + BASE_WEBSITE = 'https://hrti.hrt.hr' + BASE_URL = 'https://hrti.hrt.hr' + HSAPI_BASE_URL = 'https://hsapi.aviion.tv/client.svc/json' + + # Configuration endpoints + ENV_ENDPOINT = f'{BASE_URL}/assets/config/env.json' + CONFIG_ENDPOINT = f'{BASE_URL}/assets/config/config.production.json' + + # API endpoints + API_ENDPOINTS = { + 'get_ip': f'{BASE_URL}/api/api/ott/getIPAddress', + 'grant_access': f'{BASE_URL}/api/api/ott/GrantAccess', + 'channels': f'{BASE_URL}/api/api/ott/GetChannels', + 'programme': f'{BASE_URL}/api/api/ott/GetProgramme', + 'authorize_session': f'{BASE_URL}/api/api/ott/AuthorizeSession', + 'register_device': f'{HSAPI_BASE_URL}/RegisterDevice', + 'content_ratings': f'{HSAPI_BASE_URL}/ContentRatingsGet', + 'profiles': f'{HSAPI_BASE_URL}/ProfilesGet' + } + + # DRM and License endpoints + LICENSE_URL = 'https://lic.drmtoday.com/license-proxy-widevine/cenc/' + + # Device information + DEVICE_REFERENCE_ID = '6' + OPERATOR_REFERENCE_ID = 'hrt' + MERCHANT = 'aviion2' + CONNECTION_TYPE = 'LAN/WiFi' + APPLICATION_VERSION = '5.62.5' + OS_VERSION = 'Linux' + CLIENT_TYPE = 'Chrome 96' + + # User Agent + USER_AGENT = 'kodi plugin for hrti.hrt.hr (python)' + + # HTTP settings + DEFAULT_TIMEOUT = 30 + + +class HRTiConfig: + """Configuration class for HRTi provider""" + + def __init__(self, config_dict: dict = None): + """Initialize with optional configuration overrides""" + config = config_dict or {} + + self.logo = config.get('logo', HRTiDefaults.PROVIDER_LOGO) + + # Website and base URLs + self.base_website = config.get('base_website', HRTiDefaults.BASE_WEBSITE) + self.base_url = config.get('base_url', HRTiDefaults.BASE_URL) + self.hsapi_base_url = config.get('hsapi_base_url', HRTiDefaults.HSAPI_BASE_URL) + self.env_endpoint = config.get('env_endpoint', HRTiDefaults.ENV_ENDPOINT) + self.config_endpoint = config.get('config_endpoint', HRTiDefaults.CONFIG_ENDPOINT) + + # API endpoints configuration + self.api_endpoints = config.get('api_endpoints', HRTiDefaults.API_ENDPOINTS.copy()) + + # DRM and License + self.license_url = config.get('license_url', HRTiDefaults.LICENSE_URL) + + # Device configuration + self.device_reference_id = config.get('device_reference_id', HRTiDefaults.DEVICE_REFERENCE_ID) + self.operator_reference_id = config.get('operator_reference_id', HRTiDefaults.OPERATOR_REFERENCE_ID) + self.merchant = config.get('merchant', HRTiDefaults.MERCHANT) + self.connection_type = config.get('connection_type', HRTiDefaults.CONNECTION_TYPE) + self.application_version = config.get('application_version', HRTiDefaults.APPLICATION_VERSION) + self.os_version = config.get('os_version', HRTiDefaults.OS_VERSION) + self.client_type = config.get('client_type', HRTiDefaults.CLIENT_TYPE) + + # HTTP settings + self.user_agent = config.get('user_agent', HRTiDefaults.USER_AGENT) + self.timeout = config.get('timeout', HRTiDefaults.DEFAULT_TIMEOUT) + + # Web API URL (can be updated from config) + self.web_api_url = config.get('web_api_url', 'api/api/ott') + + def update_from_api(self, env_data: dict, config_data: dict): + """Update configuration from API responses""" + try: + # Update from env data + if 'applicationVersion' in env_data: + self.application_version = env_data['applicationVersion'] + + # Update from config data + if 'apiUrl' in config_data: + self.hsapi_base_url = config_data['apiUrl'] + + if 'webApiUrl' in config_data: + self.web_api_url = config_data['webApiUrl'] + # Update API endpoints with new web API URL + base_api_url = f"{self.base_url}/{self.web_api_url}" + self.api_endpoints.update({ + 'get_ip': f"{base_api_url}/getIPAddress", + 'grant_access': f"{base_api_url}/GrantAccess", + 'channels': f"{base_api_url}/GetChannels", + 'programme': f"{base_api_url}/GetProgramme", + 'authorize_session': f"{base_api_url}/AuthorizeSession" + }) + + if 'operators' in config_data and config_data['operators']: + operator = config_data['operators'][0] + if 'playerMerchant' in operator: + self.merchant = operator['playerMerchant'] + if 'selfcareUrl' in operator: + # Store selfcare URL if needed + pass + + except Exception as e: + # Log error but don't raise - use defaults if update fails + import logging + logging.debug(f"Error updating HRTi config from API: {e}") + + def get_base_headers(self) -> dict: + """Get base HTTP headers""" + return { + 'User-Agent': self.user_agent, + 'Accept': 'application/json', + 'Content-Type': 'application/json' + } + + def get_auth_headers(self, device_id: str = None, ip_address: str = None, token: str = None) -> dict: + """Get authenticated headers for API requests""" + headers = self.get_base_headers() + + if device_id: + headers['deviceid'] = device_id + if ip_address: + headers['ipaddress'] = ip_address + if token: + headers['authorization'] = f'Client {token}' + + headers.update({ + 'operatorreferenceid': self.operator_reference_id, + 'devicetypeid': self.device_reference_id, + 'origin': self.base_website, + 'referer': self.base_website + }) + + return headers +# [file content end] \ No newline at end of file diff --git a/lib/streaming_providers/providers/hrti/models.py b/lib/streaming_providers/providers/hrti/models.py new file mode 100644 index 0000000..200a484 --- /dev/null +++ b/lib/streaming_providers/providers/hrti/models.py @@ -0,0 +1,225 @@ +# [file name]: models.py +# [file content begin] +# streaming_providers/providers/hrti/models.py +from dataclasses import dataclass +from typing import Dict, Any, Optional, List +from datetime import datetime + +from ...base.auth.base_auth import BaseAuthToken +from ...base.auth.credentials import UserPasswordCredentials +from .constants import HRTiDefaults + + +@dataclass +class HRTiCredentials(UserPasswordCredentials): + """ + HRTi specific username/password credentials + """ + + def __init__(self, username: str, password: str): + super().__init__(username=username, password=password) + self.credential_type = 'hrti_user' + + def to_auth_payload(self) -> Dict[str, Any]: + """Convert to authentication payload for HRTi""" + return { + "Username": self.username, + "Password": self.password, + "OperatorReferenceId": HRTiDefaults.OPERATOR_REFERENCE_ID + } + + def validate(self) -> bool: + """Validate HRTi credentials""" + return bool(self.username and self.password) + + +class HRTiAuthToken(BaseAuthToken): + """ + HRTi specific authentication token + """ + + def __init__(self, access_token: str, token_type: str, expires_in: int, + issued_at: float, user_id: str = '', valid_from: str = '', + valid_to: str = '', refresh_token: Optional[str] = None): + super().__init__( + access_token=access_token, + token_type=token_type, + expires_in=expires_in, + issued_at=issued_at, + refresh_token=refresh_token + ) + + # HRTi specific fields + self.user_id = user_id + self.valid_from = valid_from + self.valid_to = valid_to + self.credential_type = 'hrti' + + def to_dict(self) -> Dict[str, Any]: + """Convert token to dictionary""" + base_dict = super().to_dict() + base_dict.update({ + 'user_id': self.user_id, + 'valid_from': self.valid_from, + 'valid_to': self.valid_to, + 'credential_type': self.credential_type + }) + return base_dict + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> 'HRTiAuthToken': + """Create token from dictionary""" + return cls( + access_token=data['access_token'], + token_type=data.get('token_type', 'Client'), + expires_in=data.get('expires_in', 86400), + issued_at=data.get('issued_at', 0), + user_id=data.get('user_id', ''), + valid_from=data.get('valid_from', ''), + valid_to=data.get('valid_to', ''), + refresh_token=data.get('refresh_token') + ) + + def is_valid(self) -> bool: + """Check if token is still valid""" + import time + if not self.access_token: + return False + + # Basic expiration check + current_time = time.time() + buffer_time = 300 # 5 minutes buffer + + return current_time < (self.issued_at + self.expires_in - buffer_time) + + +@dataclass +class HRTiChannel: + """ + HRTi channel information + """ + id: str + name: str + reference_id: str + streaming_url: str + icon_url: str + is_radio: bool = False + description: Optional[str] = None + sort_order: int = 0 + + def __post_init__(self): + """Validate channel data after initialization""" + if not self.id or not self.name or not self.reference_id: + raise ValueError("Channel must have id, name, and reference_id") + + @classmethod + def from_api_response(cls, data: Dict[str, Any]) -> 'HRTiChannel': + """Create channel from API response data""" + return cls( + id=data.get('ReferenceId', ''), + name=data.get('Name', ''), + reference_id=data.get('ReferenceId', ''), + streaming_url=data.get('StreamingURL', ''), + icon_url=data.get('Icon', ''), + is_radio=data.get('Radio', False), + description=data.get('Description'), + sort_order=data.get('SortOrder', 0) + ) + + def to_dict(self) -> Dict[str, Any]: + """Convert channel to dictionary""" + return { + 'id': self.id, + 'name': self.name, + 'reference_id': self.reference_id, + 'streaming_url': self.streaming_url, + 'icon_url': self.icon_url, + 'is_radio': self.is_radio, + 'description': self.description, + 'sort_order': self.sort_order + } + + +@dataclass +class HRTiEpgEntry: + """ + HRTi EPG (Electronic Program Guide) entry + """ + reference_id: str + title: str + description_short: str + description_long: str + start_time: str + end_time: str + image_url: str + channel_reference_id: str + content_rating: Optional[str] = None + episode_number: Optional[str] = None + season_number: Optional[str] = None + + @classmethod + def from_api_response(cls, data: Dict[str, Any], channel_id: str) -> 'HRTiEpgEntry': + """Create EPG entry from API response""" + return cls( + reference_id=data.get('ReferenceId', ''), + title=data.get('Title', ''), + description_short=data.get('DescriptionShort', ''), + description_long=data.get('DescriptionLong', ''), + start_time=data.get('TimeStart', ''), + end_time=data.get('TimeEnd', ''), + image_url=data.get('ImagePath', ''), + channel_reference_id=channel_id, + content_rating=data.get('ContentRating'), + episode_number=data.get('EpisodeNr'), + season_number=data.get('SeasonNr') + ) + + def to_dict(self) -> Dict[str, Any]: + """Convert EPG entry to dictionary""" + return { + 'reference_id': self.reference_id, + 'title': self.title, + 'description_short': self.description_short, + 'description_long': self.description_long, + 'start_time': self.start_time, + 'end_time': self.end_time, + 'image_url': self.image_url, + 'channel_reference_id': self.channel_reference_id, + 'content_rating': self.content_rating, + 'episode_number': self.episode_number, + 'season_number': self.season_number + } + + +@dataclass +class HRTiSession: + """ + HRTi playback session information + """ + session_id: str + authorized: bool + drm_id: str + channel_reference_id: str + content_reference_id: str + + @classmethod + def from_api_response(cls, data: Dict[str, Any]) -> 'HRTiSession': + """Create session from API response""" + return cls( + session_id=data.get('SessionId', ''), + authorized=data.get('Authorized', False), + drm_id=data.get('DrmId', ''), + channel_reference_id=data.get('ChannelReferenceId', ''), + content_reference_id=data.get('ContentReferenceId', '') + ) + + def to_dict(self) -> Dict[str, Any]: + """Convert session to dictionary""" + return { + 'session_id': self.session_id, + 'authorized': self.authorized, + 'drm_id': self.drm_id, + 'channel_reference_id': self.channel_reference_id, + 'content_reference_id': self.content_reference_id + } +# [file content end] \ No newline at end of file diff --git a/lib/streaming_providers/providers/hrti/provider.py b/lib/streaming_providers/providers/hrti/provider.py new file mode 100644 index 0000000..cd5471d --- /dev/null +++ b/lib/streaming_providers/providers/hrti/provider.py @@ -0,0 +1,326 @@ +# [file name]: provider.py +# [file content begin] +# lib/streaming_providers/providers/hrti/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 HRTiAuthenticator +from .constants import HRTiConfig +from ...base.utils import logger +from ...base.models.proxy_models import ProxyConfig +from ...base.network import HTTPManagerFactory + + +class HRTiProvider(StreamingProvider): + def __init__(self, country: str = 'HR', config: Optional[Dict] = None, proxy_config: Optional[ProxyConfig] = None): + super().__init__(country) + + # Initialize configuration with overrides + self.hrti_config = HRTiConfig(config) + self.channels_cache = None + + # Create HTTP manager + if proxy_config is None: + from ...base.network import ProxyConfigManager + proxy_mgr = ProxyConfigManager() + proxy_config = proxy_mgr.get_proxy_config('hrti') + + self.http_manager = HTTPManagerFactory.create_for_provider( + 'hrti', + proxy_config=proxy_config, + user_agent=self.hrti_config.user_agent, + timeout=self.hrti_config.timeout + ) + + # Initialize authenticator and share HTTP manager + self.auth = HRTiAuthenticator( + proxy_config=proxy_config, + http_manager=self.http_manager + ) + + # Share HTTP manager for consistency + self.http_manager = self.auth.http_manager + + try: + # Initialize authentication + bearer_token = self.auth.get_bearer_token() + logger.debug(f"HRTi authentication successful during initialization") + except Exception as e: + logger.warning(f"HRTi could not authenticate during initialization: {e}") + + @property + def provider_name(self) -> str: + return "hrti" + + @property + def provider_label(self) -> str: + return 'HRTi' + + @property + def provider_logo(self) -> str: + return self.hrti_config.logo + + @property + def uses_dynamic_manifests(self) -> bool: + # HRTi requires session authorization for manifests + return True + + def _get_authenticated_headers(self) -> Dict[str, str]: + """ + Get headers with HRTi authentication + """ + bearer_token = self.auth.get_bearer_token() + device_id = self.auth.get_device_id() + ip_address = self.auth.get_ip_address() + + return self.hrti_config.get_auth_headers( + device_id=device_id, + ip_address=ip_address, + token=bearer_token + ) + + def get_channels(self, **kwargs) -> List[StreamingChannel]: + """ + Fetch channels from HRTi API + """ + try: + headers = self._get_authenticated_headers() + + response = self.http_manager.post( + self.hrti_config.api_endpoints['channels'], + operation='api', + headers=headers, + data=json.dumps({}) + ) + response.raise_for_status() + + channels_data = response.json() + if 'Result' in channels_data: + channels = [] + for channel in channels_data['Result']: + streaming_channel = self._parse_channel_data(channel) + if streaming_channel: + channels.append(streaming_channel) + + self.channels = channels + return channels + else: + logger.warning("No channels found in HRTi response") + return [] + + except requests.RequestException as e: + logger.error(f"Error fetching HRTi channels: {e}") + # Try to refresh auth and retry once + try: + logger.info("Attempting to refresh authentication and retry...") + self.auth.invalidate_token() + headers = self._get_authenticated_headers() + + response = self.http_manager.post( + self.hrti_config.api_endpoints['channels'], + operation='api', + headers=headers, + data=json.dumps({}) + ) + response.raise_for_status() + + channels_data = response.json() + if 'Result' in channels_data: + channels = [] + for channel in channels_data['Result']: + streaming_channel = self._parse_channel_data(channel) + if streaming_channel: + channels.append(streaming_channel) + + self.channels = channels + return channels + + except Exception as retry_e: + logger.error(f"Retry failed: {retry_e}") + return [] + + except Exception as e: + logger.error(f"Error parsing HRTi channels: {e}") + return [] + + def _parse_channel_data(self, channel_data: Dict) -> Optional[StreamingChannel]: + """ + Parse HRTi channel data to StreamingChannel + """ + try: + name = channel_data.get('Name', '') + channel_id = channel_data.get('ReferenceId', '') + streaming_url = channel_data.get('StreamingURL', '') + is_radio = channel_data.get('Radio', False) + icon_url = channel_data.get('Icon', '') + + if not name or not channel_id: + return None + + # Create channel object + channel = StreamingChannel( + name=name, + channel_id=channel_id, + provider=self.provider_name, + logo_url=icon_url, + mode="live", + session_manifest=True, # HRTi requires session authorization + manifest=None, # Will be set dynamically + manifest_script=streaming_url, # Store streaming URL for manifest fetching + content_type="AUDIO" if is_radio else "LIVE", + country=self.country, + language="hr" # Croatian + ) + + # HRTi uses DRM for most content + channel.use_cdm = True + channel.cdm_type = "widevine" + + return channel + + except Exception as e: + logger.error(f"Error parsing channel {channel_data}: {e}") + return None + + def enrich_channel_data(self, channel: StreamingChannel, **kwargs) -> Optional[StreamingChannel]: + """ + Enrich channel with manifest URL and additional data + """ + try: + # For HRTi, we need to authorize a session to get the manifest + manifest_url = self.get_manifest(channel.channel_id, **kwargs) + + if manifest_url: + # HRTi manifests are dynamic and session-based + channel.set_dynamic_manifest(manifest_url) + + # Set DRM configuration + drm_configs = self.get_drm(channel.channel_id, **kwargs) + if drm_configs: + channel.use_cdm = True + channel.cdm_type = "widevine" + # Set license URL from first Widevine config + for config in drm_configs: + if config.system == DRMSystem.WIDEVINE: + channel.license_url = config.license.server_url + break + else: + channel.use_cdm = False + channel.cdm_type = None + + return channel + else: + logger.warning(f"Could not fetch manifest for channel {channel.name} ({channel.channel_id})") + return channel + + except Exception as e: + logger.error(f"Error enriching channel data for {channel.name}: {e}") + return channel + + def get_manifest(self, channel_id: str, **kwargs) -> Optional[str]: + """ + Get manifest URL for a channel by authorizing a session + """ + try: + # Authorize session for this channel + session_data = self.auth.authorize_session( + content_type="tlive", # TV live + content_ref_id=channel_id, + channel_id=channel_id + ) + + if session_data and session_data.get('Authorized', False): + # For live channels, use the streaming URL from channel data + # The actual manifest will be resolved during playback with session authorization + channels = self.get_channels() + for channel in channels: + if channel.channel_id == channel_id: + return channel.manifest_script # This is the streaming URL + + logger.warning(f"Session authorization failed for channel {channel_id}") + return None + + except Exception as e: + logger.error(f"Error getting manifest for channel {channel_id}: {e}") + return None + + # In provider.py - fix the get_drm method: + + def get_drm(self, channel_id: str, **kwargs) -> List[DRMConfig]: + """ + Get DRM configurations for a channel + """ + try: + # Use license URL from constants + license_url = self.hrti_config.license_url + + drm_config = DRMConfig( + system=DRMSystem.WIDEVINE, + priority=1, + license=LicenseConfig( + server_url=license_url, + req_headers=json.dumps({ + 'User-Agent': self.hrti_config.user_agent, + 'Content-Type': 'text/plain', + 'origin': self.hrti_config.base_website, + 'referer': self.hrti_config.base_website + }), + req_data="{CHA-RAW}", + use_http_get_request=False + ) + ) + + return [drm_config] + + except Exception as e: + logger.error(f"Error getting DRM config for channel {channel_id}: {e}") + return [] + + + def get_epg_data(self, channel_id: str, **kwargs) -> Optional[Dict]: + """ + Get EPG data for a channel + """ + try: + headers = self._get_authenticated_headers() + + # Get current time range (4 hours before and after) + start_time = self.auth.get_time_offset(-4) + end_time = self.auth.get_time_offset(4) + + payload = { + "ChannelReferenceIds": [channel_id], + "StartTime": f"/Date({start_time})/", + "EndTime": f"/Date({end_time})/" + } + + response = self.http_manager.post( # FIXED: Use http_manager + self.hrti_config.api_endpoints['programme'], + operation='api', + headers=headers, + data=json.dumps(payload) + ) + response.raise_for_status() + + epg_data = response.json() + if 'Result' in epg_data: + return epg_data['Result'] + return None + + except Exception as e: + logger.error(f"Error getting EPG data for channel {channel_id}: {e}") + 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(channel.channel_id, **kwargs) + if drm_configs: + return drm_configs[0].license.server_url + return None +# [file content end] \ No newline at end of file