mirror of
https://github.com/nirvana-7777/script.service.ultimate.git
synced 2026-10-09 01:12:31 +02:00
582 lines
23 KiB
Python
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
|