Files
script.service.ultimate/lib/streaming_providers/providers/hrti/provider.py
T
2026-01-16 12:24:41 +01:00

582 lines
23 KiB
Python

# lib/streaming_providers/providers/hrti/provider.py
import json
from typing import ClassVar, Dict, List, Optional
import requests
from ...base.models import DRMConfig, DRMSystem, LicenseConfig, LicenseUnwrapperParams
from ...base.models.proxy_models import ProxyConfig
from ...base.models.streaming_channel import StreamingChannel
from ...base.provider import AuthType, StreamingProvider
from ...base.utils import logger
from .auth import HRTiAuthenticator
from .constants import HRTiConfig, HRTiDefaults
class HRTiProvider(StreamingProvider):
"""
HRTi (Croatian Radio Television Internet) provider implementation.
"""
# ============================================================================
# STATIC METADATA (NEW)
# ============================================================================
PROVIDER_LABEL: ClassVar[str] = "HRTi"
SUPPORTED_AUTH_TYPES: ClassVar[List[str]] = [
"client_credentials",
"user_credentials",
]
PROVIDER_LOGO: ClassVar[str] = HRTiDefaults.PROVIDER_LOGO
SUPPORTED_COUNTRIES: ClassVar[List[str]] = ["HR"]
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
# Setup HTTP manager using abstraction
self.http_manager = self._setup_http_manager(
provider_name="hrti",
proxy_config=proxy_config,
user_agent=self.hrti_config.user_agent,
timeout=self.hrti_config.timeout,
)
# Initialize authenticator and share HTTP manager
self.authenticator = HRTiAuthenticator(
proxy_config=proxy_config, http_manager=self.http_manager
)
# Share HTTP manager for consistency
self.http_manager = self._share_http_manager_with_authenticator(self.authenticator)
try:
# Initialize authentication
bearer_token = self.authenticator.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:
# Override to use static metadata with country context
return self.get_static_label(self.country)
@property
def provider_logo(self) -> str:
# Use instance config for backward compatibility
return self.hrti_config.logo or self.PROVIDER_LOGO
@property
def uses_dynamic_manifests(self) -> bool:
# HRTi requires session authorization for manifests
return True
@property
def implements_epg(self) -> bool:
return False
@property
def supported_auth_types(self) -> List[str]:
return self.SUPPORTED_AUTH_TYPES # Use class attribute
def _get_hrti_authenticated_headers(self) -> Dict[str, str]:
"""
Get headers with HRTi authentication for API requests
This is a provider-specific wrapper that uses the base class abstraction.
"""
return self._build_provider_headers(
auth_type=AuthType.CLIENT,
token_key="authorization", # HRTi uses lowercase
provider_headers={
"deviceid": self.authenticator.get_device_id(),
"devicetypeid": self.hrti_config.device_reference_id,
"ipaddress": self.authenticator.get_ip_address(),
"operatorreferenceid": self.hrti_config.operator_reference_id,
"origin": self.hrti_config.base_website,
"referer": f"{self.hrti_config.base_website}/login",
},
)
def get_channels(self, **kwargs) -> List[StreamingChannel]:
"""
Fetch channels from HRTi API
"""
try:
# Use provider-specific method
headers = self._get_hrti_authenticated_headers()
# Log the request for debugging
logger.debug(
f"Fetching HRTi channels with authorization: {'Client ...' if 'authorization' in headers else 'NO AUTHORIZATION'}"
)
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 and channels_data["Result"]:
channels = []
for channel in channels_data["Result"]:
streaming_channel = self._parse_channel_data(channel)
if streaming_channel:
channels.append(streaming_channel)
self.channels = channels
logger.info(f"Successfully fetched {len(channels)} channels from HRTi")
return channels
else:
logger.warning("No channels found in HRTi response")
if "Result" in channels_data:
logger.debug(f"HRTi channels response: {channels_data}")
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.authenticator.invalidate_token()
headers = self._get_hrti_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 and channels_data["Result"]:
channels = []
for channel in channels_data["Result"]:
streaming_channel = self._parse_channel_data(channel)
if streaming_channel:
channels.append(streaming_channel)
self.channels = channels
logger.info(f"Successfully fetched {len(channels)} channels from HRTi on retry")
return channels
else:
logger.warning("No channels found in HRTi retry response")
return []
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", "")
# FIX: API returns 'ReferenceID' (capital ID), not 'ReferenceId'
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:
logger.debug(f"Skipping channel - missing name or ID: {channel_data}")
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,
is_radio=is_radio,
language="hr", # Croatian
)
# HRTi uses DRM for most content
channel.use_cdm = True
channel.cdm_type = "widevine"
logger.debug(f"Parsed HRTi channel: {name} ({channel_id}) - radio: {is_radio}")
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 DRM configuration.
This method pre-authorizes a session and passes session_data to get_drm().
"""
try:
logger.debug(f"Enriching channel: {channel.name} ({channel.channel_id})")
# For live channels, we need to authorize a session first
content_type = "rlive" if channel.content_type == "AUDIO" else "tlive"
# Parse the streaming URL to get content DRM ID
from urllib.parse import urlparse
parts = urlparse(channel.manifest_script)
path_parts = parts.path.strip("/").split("/")
# Content DRM ID format: directory1_directory2
# Example: /cdn1oiv/hrtliveorigin/... -> cdn1oiv_hrtliveorigin
content_drm_id = None
if len(path_parts) >= 2:
content_drm_id = f"{path_parts[0]}_{path_parts[1]}"
logger.debug(f"Content DRM ID for {channel.name}: {content_drm_id}")
# Authorize session
session_data = self.authenticator.authorize_session(
content_type=content_type,
content_ref_id=channel.channel_id,
content_drm_id=content_drm_id,
video_store_ids=None,
channel_id=channel.channel_id,
start_time=None,
end_time=None,
)
if not session_data:
logger.warning(f"Failed to authorize session for channel {channel.name}")
return channel
# Check if authorized
if not session_data.get("Authorized", False):
logger.warning(f"Session not authorized for channel {channel.name}")
return channel
logger.debug(f"Session authorized for {channel.name}")
# Report session event (play start) - use full SessionId
session_id = session_data.get("SessionId")
if session_id:
self.authenticator.report_session_event(session_id, channel.channel_id)
# Set the manifest URL - use the streaming URL from channel data
manifest_url = channel.manifest_script
if manifest_url:
channel.set_dynamic_manifest(manifest_url)
logger.debug(f"Set manifest for {channel.name}: {manifest_url}")
# Set DRM configuration with session data
# Pass session_data to avoid re-authorizing
drm_configs = self.get_drm(channel.channel_id, session_data=session_data, **kwargs)
if drm_configs:
channel.use_cdm = True
channel.cdm_type = "widevine"
# Set license URL from the first Widevine config
for config in drm_configs:
if config.system == DRMSystem.WIDEVINE:
channel.license_url = config.license.server_url
# Build the complete license key for inputstream.adaptive
# Format: server_url|req_headers|req_data|response_format
license_key_parts = [
config.license.server_url,
config.license.req_headers,
"R{SSM}", # Placeholder - inputstream will replace with actual challenge
"JBlicense", # Response is JSON, extract 'license' field
]
channel.license_key = "|".join(license_key_parts)
logger.debug(f"Set DRM config for {channel.name}")
logger.debug(f"License URL: {config.license.server_url}")
break
else:
logger.warning(f"No DRM config for {channel.name}")
channel.use_cdm = False
channel.cdm_type = None
return channel
except Exception as e:
logger.error(f"Error enriching channel data for {channel.name}: {e}")
import traceback
logger.error(f"Traceback: {traceback.format_exc()}")
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.authenticator.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
def get_drm(self, channel_id: str, session_data: Dict = None, **kwargs) -> List[DRMConfig]:
"""
Get DRM configurations for a channel with proper license data.
If session_data is not provided, will authorize a new session.
"""
try:
# If no session data provided, authorize a new session
if not session_data:
logger.debug(
f"No session data provided for DRM - authorizing new session for channel {channel_id}"
)
# Find the channel to get content type and streaming URL
channels = (
self.get_channels()
if not hasattr(self, "channels") or not self.channels
else self.channels
)
target_channel = None
for ch in channels:
if ch.channel_id == channel_id:
target_channel = ch
break
if not target_channel:
logger.error(f"Channel {channel_id} not found for DRM authorization")
return []
# Determine content type
content_type = "rlive" if target_channel.content_type == "AUDIO" else "tlive"
# Parse the streaming URL to get content DRM ID
from urllib.parse import urlparse
parts = urlparse(target_channel.manifest_script)
path_parts = parts.path.strip("/").split("/")
# Content DRM ID format: directory1_directory2
content_drm_id = None
if len(path_parts) >= 2:
content_drm_id = f"{path_parts[0]}_{path_parts[1]}"
logger.debug(
f"Authorizing session for DRM - channel: {channel_id}, content_type: {content_type}, drm_id: {content_drm_id}"
)
# Authorize session
session_data = self.authenticator.authorize_session(
content_type=content_type,
content_ref_id=channel_id,
content_drm_id=content_drm_id,
video_store_ids=None,
channel_id=channel_id,
start_time=None,
end_time=None,
)
if not session_data:
logger.error(f"Failed to authorize session for DRM - channel {channel_id}")
return []
# Check if authorized
if not session_data.get("Authorized", False):
logger.warning(f"Session not authorized for DRM - channel {channel_id}")
return []
logger.debug(f"Session authorized for DRM - channel {channel_id}")
# Report session event (use full SessionId, not DrmId)
session_id = session_data.get("SessionId")
if session_id:
self.authenticator.report_session_event(session_id, channel_id)
# IMPORTANT: For license data, use DrmId (not SessionId)
# DrmId is the short random string for DRM
# SessionId is the full identifier like "6:hrt:userid:uuid"
drm_id = session_data.get("DrmId")
session_id = session_data.get("SessionId") # Keep for session reporting
if not drm_id:
logger.error("No DRM ID in session data")
logger.debug(f"Session data keys: {list(session_data.keys())}")
return []
logger.debug(
f"Using DrmId for license: {drm_id[:20]}... (full SessionId: {session_id})"
)
# Generate license data (base64 encoded) - use DrmId not SessionId
license_data = self.authenticator.get_license_data(drm_id)
if not license_data:
logger.error("Failed to generate license data")
return []
logger.debug(f"Generated license data for session {session_id}")
# Build the license request headers in the format expected by inputstream.adaptive
# Format: Key1=Value1&Key2=Value2
license_headers = "&".join(
[
f"User-Agent={self.hrti_config.user_agent}",
f"origin={self.hrti_config.base_website}",
f"referer={self.hrti_config.base_website}/",
f"dt-custom-data={license_data}",
]
)
# Create the license configuration
# Use the class method to create LicenseConfig with base64 encoded req_data
license_config = LicenseConfig.create_with_base64_req_data(
req_data_template="{CHA-RAW}", # The placeholder string
server_url=self.hrti_config.license_url,
use_http_get_request=False,
req_headers=license_headers,
wrapper=None,
unwrapper="json,base64",
unwrapper_params=LicenseUnwrapperParams(path_data="license"),
)
# Create the DRM configuration
drm_config = DRMConfig(system=DRMSystem.WIDEVINE, priority=1, license=license_config)
logger.debug(f"Created DRM config for channel {channel_id} with DrmId {drm_id}")
return [drm_config]
except Exception as e:
logger.error(f"Error getting DRM config for channel {channel_id}: {e}")
import traceback
logger.error(f"Traceback: {traceback.format_exc()}")
return []
def get_epg(self, channel_id: str, **kwargs) -> List[Dict]:
"""
Get EPG data for a channel
"""
try:
headers = self._get_hrti_authenticated_headers()
# Get current time range (4 hours before and after)
start_time = self.authenticator.get_time_offset(-4)
end_time = self.authenticator.get_time_offset(4)
payload = {
"ChannelReferenceIds": [channel_id],
"StartTime": f"/Date({start_time})/",
"EndTime": f"/Date({end_time})/",
}
response = self.http_manager.post(
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:
# Convert to standard EPG format
epg_entries = []
for entry in epg_data["Result"]:
epg_entries.append(
{
"title": entry.get("Title", ""),
"description": entry.get("Description", ""),
"start": entry.get("StartTime", ""),
"end": entry.get("EndTime", ""),
"genre": entry.get("Genre", ""),
}
)
return epg_entries
return []
except Exception as e:
logger.error(f"Error getting EPG data for channel {channel_id}: {e}")
return []
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
# ============================================================================
# CATCHUP METHODS (Implementing abstract methods)
# ============================================================================
@property
def catchup_window(self) -> int:
"""
Return the catchup window in HOURS for HRTi.
Note: HRTi doesn't officially support catchup for live channels,
but may have some VOD content available.
"""
return 0 # No catchup support for live streams
def get_epg_xmltv(self, **kwargs) -> Optional[str]:
"""
Get complete EPG data for HRTi in XMLTV format.
Returns:
XMLTV formatted string, or None if not available
"""
# HRTi doesn't provide XMLTV format natively
return None
def get_dynamic_manifest_params(self, channel: StreamingChannel, **kwargs) -> Optional[str]:
"""
Get dynamic manifest parameters for HRTi channels.
Args:
channel: StreamingChannel to get parameters for
Returns:
Parameters string or None
"""
# HRTi requires session authorization which is handled in enrich_channel_data
return None