Migrate hrti

This commit is contained in:
Nirvana
2026-10-15 09:16:23 +02:00
parent 1fb228bd46
commit 214511de0e
12 changed files with 1297 additions and 1537 deletions
+3 -1
View File
@@ -13,6 +13,8 @@
</component>
<component name="PyDocumentationSettings">
<option name="format" value="PLAIN" />
<option name="myDocStringFormat" value="Plain" />
</component>
<component name="TestRunnerService">
<option name="PROJECT_TEST_RUNNER" value="py.test" />
</component>
</module>
@@ -1,11 +1,11 @@
# [file name]: __init__.py
# [file content begin]
# lib/streaming_providers/providers/hrti/__init__.py
# streaming_providers/providers/hrti/__init__.py
"""
HRTi streaming provider module
HRTi (Croatian Radio Television Internet) provider.
Public API:
from streaming_providers.providers.hrti import HRTiProvider
"""
from .provider import HRTiProvider
__all__ = ["HRTiProvider"]
# [file content end]
__all__ = ["HRTiProvider"]
@@ -0,0 +1,116 @@
# streaming_providers/providers/hrti/api.py
"""
Shared API-call helper for the HRTi managers (one place for the error
contract instead of a try/except in every method).
* endpoint URL from the lazily loaded dynamic config
* authorization/device headers from the session (a fresh token per request)
* transport / HTTP-status errors become typed errors (base.errors);
works whether the HTTP manager raises or returns the response
* HTTP 401 / AuthError -> ONE session refresh + exactly one retry
* API body ``ErrorCode != 0``: raises ServerError for required endpoints,
returns None for optional ones (``required=False``)
"""
import json
from typing import Any, Dict, Optional
from ...base.errors import (
AuthError,
ProviderError,
ServerError,
TransportError,
default_error_for_status_heuristic,
)
from ...base.utils.logger import logger
from .constants import HRTiConfig
def _snippet(response: Any) -> str:
try:
return (response.text or "")[:200]
except Exception: # response object without text
return ""
def typed_error_for(exc: BaseException, url: Optional[str] = None) -> ProviderError:
"""Typed error for an exception raised by the HTTP layer: an exception that
carries a response is mapped by its status, anything else is a transport
fault."""
failed = getattr(exc, "response", None)
status = getattr(failed, "status_code", None)
if isinstance(status, int):
return default_error_for_status_heuristic(
status, f"HRTi HTTP {status}: {exc}", url=url, body_snippet=_snippet(failed)
)
return TransportError(f"HRTi request failed: {exc}", url=url)
def _post_json(http_manager: Any, url: str, headers: Dict[str, str], payload: Dict) -> Dict:
try:
response = http_manager.post(
url, operation="api", headers=headers, data=json.dumps(payload)
)
except ProviderError:
raise
except Exception as exc: # HTTP manager raising for 4xx/5xx or transport faults
raise typed_error_for(exc, url) from exc
status = getattr(response, "status_code", None)
if isinstance(status, int) and status >= 400:
raise default_error_for_status_heuristic(
status, f"HRTi HTTP {status}", url=url, body_snippet=_snippet(response)
)
try:
data = response.json()
except ValueError as exc:
raise ServerError("HRTi: response is not valid JSON", url=url) from exc
if not isinstance(data, dict):
raise ServerError("HRTi: unexpected response shape", url=url)
return data
def api_post(
*,
http_manager: Any,
session: Any,
config: HRTiConfig,
endpoint_key: str,
payload: Dict,
referer_path: str = "/",
required: bool = True,
) -> Optional[Any]:
"""POST to a named endpoint and return the ``Result`` field."""
url = config.endpoint(endpoint_key)
data: Dict = {}
for attempt in (1, 2):
# Token and device state are read OUTSIDE the try: a login failure is
# already a typed error and must not trigger the 401 recovery.
headers = config.api_headers(
token=session.get_access_token(),
device_id=session.device_id,
ip_address=session.ip_address,
referer_path=referer_path,
)
try:
data = _post_json(http_manager, url, headers, payload)
break
except AuthError:
if attempt == 1:
logger.info(f"HRTi: {endpoint_key} answered 401, refreshing the session once")
session.refresh()
continue
raise
code = data.get("ErrorCode", 0)
if code not in (0, None):
description = data.get("ErrorDescription")
if required:
raise ServerError(
f"HRTi API error for {endpoint_key}: [{code}] {description}",
url=url,
code=str(code),
)
logger.debug(f"HRTi optional endpoint {endpoint_key}: [{code}] {description}")
return None
return data.get("Result")
+145 -179
View File
@@ -7,54 +7,37 @@ from datetime import datetime, timedelta
from typing import Any, Dict, Optional
from ...base.auth.base_auth import BaseAuthenticator, BaseAuthToken, TokenAuthLevel
from ...base.models.proxy_models import ProxyConfig
from ...base.errors import AuthError, ProviderError, ServerError
from ...base.utils.logger import logger
from .constants import HRTiConfig
from .api import typed_error_for
from .constants import HRTiConfig, HRTiDefaults
from .models import HRTiAuthToken, HRTiCredentials
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
def __init__(self, *, http_manager, config: HRTiConfig, credentials=None, config_dir=None):
"""
``config`` is the ONE HRTiConfig of the provider (shared with session
and managers; never build a second one here). Storage keys stay FLAT
(country=None): credentials, persisted session and device id of
existing installations are stored without a country.
"""
if http_manager is None:
raise ValueError("http_manager is required for HRTiAuthenticator")
self._config = config
self._http_manager = http_manager
# Initialize HRTi-specific properties BEFORE calling parent init
# HRTi-specific state BEFORE the parent init (it loads the session)
self._ip_address = None
self._device_id = None
self._user_id = None # Initialize _user_id here
self._user_id = None
# Call parent init WITHOUT proxy_config and http_manager (like MagentaEU)
super().__init__(
provider_name="hrti",
provider_name=HRTiDefaults.PROVIDER_NAME,
credentials=credentials,
country=None, # Changed from 'HR' to None for flat credential structure
country=None,
config_dir=config_dir,
# NO proxy_config or http_manager passed to parent
)
# Load or initialize device ID
self._initialize_device()
@property
@@ -80,7 +63,7 @@ class HRTiAuthenticator(BaseAuthenticator):
@property
def auth_endpoint(self) -> str:
"""HRTi authentication endpoint"""
return self.config.api_endpoints.get("grant_access", "")
return self.config.endpoint("grant_access")
def _get_auth_headers(self) -> Dict[str, str]:
"""Get headers specifically for authentication endpoint"""
@@ -176,7 +159,7 @@ class HRTiAuthenticator(BaseAuthenticator):
token = HRTiAuthToken(
access_token=access_token,
token_type="Client",
expires_in=86400, # 24 hours default
expires_in=HRTiDefaults.TOKEN_LIFETIME_SECONDS, # GrantAccess carries no lifetime
issued_at=time.time(),
user_id=self._user_id or "", # Ensure user_id is never None
valid_from=result.get("ValidFrom", ""),
@@ -194,7 +177,10 @@ class HRTiAuthenticator(BaseAuthenticator):
def get_fallback_credentials(self):
"""Get fallback credentials (anonymous access)"""
return HRTiCredentials(username="anonymoushrt", password="an0nPasshrt")
return HRTiCredentials(
username=HRTiDefaults.ANONYMOUS_USERNAME,
password=HRTiDefaults.ANONYMOUS_PASSWORD,
)
def _perform_authentication(self) -> HRTiAuthToken:
"""Perform HRTi custom authentication flow"""
@@ -250,24 +236,18 @@ class HRTiAuthenticator(BaseAuthenticator):
except Exception as e:
logger.debug(f"Could not read configured IP from settings: {e}")
# Priority 2: Fall back to API fetch if no configured IP
# Priority 2: ask the API (endpoint resolved lazily from the dynamic config)
try:
logger.debug("No configured IP found, fetching from API...")
get_ip_endpoint = self.config.api_endpoints.get("get_ip", "")
if not get_ip_endpoint:
logger.warning("No get_ip endpoint configured, using fallback")
self._ip_address = "0.0.0.0"
return self._ip_address
response = self.http_manager.get(get_ip_endpoint, operation="api")
response = self.http_manager.get(self.config.endpoint("get_ip"), operation="api")
response.raise_for_status()
self._ip_address = response.text.strip().strip('"') # Remove quotes if present
logger.info(f"Retrieved IP address from API: {self._ip_address}")
return self._ip_address
except Exception as e:
# Not cached: the next call may succeed
logger.error(f"Error getting IP address from API: {e}")
self._ip_address = "0.0.0.0" # Fallback
return self._ip_address
return "0.0.0.0"
def _get_configured_ip_from_settings(self) -> Optional[str]:
"""
@@ -297,26 +277,9 @@ class HRTiAuthenticator(BaseAuthenticator):
return None
def _get_environment_config(self):
"""Get HRTi environment configuration"""
try:
# 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}")
# Ensure API endpoints are built even if config fetch fails
self.config._build_fallback_endpoints()
"""Dynamic endpoints (env.json / config.production.json), loaded once by
the shared config -- also used by the managers, independent of login."""
self.config.ensure_loaded()
def _perform_grant_access(self) -> Dict[str, Any]:
"""Perform grant access authentication with proper headers and debugging"""
@@ -346,9 +309,7 @@ class HRTiAuthenticator(BaseAuthenticator):
safe_payload["Password"] = "***" if safe_payload["Password"] else "<empty>"
logger.debug(f"HRTi Auth Payload: {safe_payload}")
auth_endpoint = self.auth_endpoint
if not auth_endpoint:
raise Exception("No grant_access endpoint configured")
auth_endpoint = self.auth_endpoint # ConfigurationError when missing
response = self.http_manager.post(
auth_endpoint,
@@ -362,7 +323,10 @@ class HRTiAuthenticator(BaseAuthenticator):
result = response.json()
if "Result" not in result:
raise Exception("No result in grant access response")
raise AuthError(
"HRTi: GrantAccess response has no Result "
f"(ErrorCode={result.get('ErrorCode')}: {result.get('ErrorDescription')})"
)
customer_id = result.get("Result", {}).get("Customer", {}).get("CustomerId", "unknown")
logger.debug(f"Grant access successful - user: {customer_id}")
@@ -373,13 +337,15 @@ class HRTiAuthenticator(BaseAuthenticator):
# Only fallback to anonymous if we're not already using it
if (
not isinstance(self.credentials, HRTiCredentials)
or self.credentials.username != "anonymoushrt"
or self.credentials.username != HRTiDefaults.ANONYMOUS_USERNAME
):
logger.info("Falling back to anonymous credentials")
self.credentials = self.get_fallback_credentials()
return self._perform_grant_access()
else:
raise e
if isinstance(e, ProviderError):
raise
raise ServerError(f"HRTi login failed: {e}") from e
def _register_device(self):
"""Register device with HRTi using API headers with proper authorization"""
@@ -412,7 +378,7 @@ class HRTiAuthenticator(BaseAuthenticator):
logger.debug(f"HRTi Device Registration Payload: {payload}")
register_endpoint = self.config.api_endpoints.get("register_device", "")
register_endpoint = self.config.endpoint("register_device")
if not register_endpoint:
logger.warning("No register_device endpoint configured")
return
@@ -443,7 +409,7 @@ class HRTiAuthenticator(BaseAuthenticator):
headers["referer"] = f"{self.config.base_website}/"
# Get content ratings
content_endpoint = self.config.api_endpoints.get("content_ratings", "")
content_endpoint = self.config.endpoint("content_ratings")
if content_endpoint:
content_response = self.http_manager.post(
content_endpoint,
@@ -454,7 +420,7 @@ class HRTiAuthenticator(BaseAuthenticator):
content_response.raise_for_status()
# Get profiles
profiles_endpoint = self.config.api_endpoints.get("profiles", "")
profiles_endpoint = self.config.endpoint("profiles")
if profiles_endpoint:
profiles_response = self.http_manager.post(
profiles_endpoint,
@@ -500,27 +466,62 @@ class HRTiAuthenticator(BaseAuthenticator):
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
"""Get IP address (cached; "0.0.0.0" fallback is not cached)."""
return self._get_ip_address()
def _load_session(self) -> None:
"""Override to ensure _user_id is set when loading session"""
"""
Restore the persisted token. The stored dict is FLAT (``to_dict``), so
it is rebuilt with HRTiAuthToken.from_dict. It must NOT go through
_create_token_from_response (the base default), which expects a
GrantAccess response and used to produce a token with an EMPTY
access_token and a fresh issued_at -- "valid" for 24 h and never
refreshed.
"""
self._current_token = None
try:
# Call parent method first
super()._load_session()
# If we have a current token and it's an HRTiAuthToken, set _user_id
if self._current_token and isinstance(self._current_token, HRTiAuthToken):
self._user_id = self._current_token.user_id
logger.debug(f"Set _user_id from loaded session: {self._user_id}")
token_data = self.settings_manager.load_token_data(self.provider_name, self.country)
if token_data and token_data.get("access_token"):
token = HRTiAuthToken.from_dict(token_data)
token.auth_level = self._classify_token(token)
self._current_token = token
self._user_id = token.user_id or self._user_id or ""
logger.info("Restored persisted HRTi session")
else:
logger.info("No existing HRTi session found")
except Exception as e:
logger.error(f"Error in HRTi session loading: {e}")
# Ensure _user_id is at least an empty string
logger.error(f"Error loading HRTi session: {e}")
self._current_token = None
self._user_id = self._user_id or ""
# ------------------------------------------------------------------
# State read by HRTiSession
# ------------------------------------------------------------------
def get_current_token(self) -> Optional[BaseAuthToken]:
return self._current_token
def get_user_id(self) -> str:
return self._user_id or ""
def has_user_credentials(self) -> bool:
"""True when real (non-anonymous) user credentials are stored or set."""
from ...base.auth.credentials import UserPasswordCredentials
try:
stored = self.settings_manager.get_provider_credentials(
self.provider_name, self.country
)
except TypeError:
stored = self.settings_manager.get_provider_credentials(self.provider_name)
for creds in (stored, self.credentials):
if (
isinstance(creds, UserPasswordCredentials)
and creds.validate()
and creds.username != HRTiDefaults.ANONYMOUS_USERNAME
):
return True
return False
def authorize_session(
self,
content_type: str,
@@ -530,69 +531,61 @@ class HRTiAuthenticator(BaseAuthenticator):
channel_id: str = None,
start_time: str = None,
end_time: str = None,
) -> Optional[Dict[str, Any]]:
"""Authorize a playback session"""
) -> Dict[str, Any]:
"""Authorize a playback session. Raises typed errors (AuthError on 401,
ServerError otherwise); returns the raw ``Result`` dict -- the caller
checks ``Authorized``."""
bearer_token = self._current_token.access_token if self._current_token else ""
headers = self._get_api_headers(bearer_token)
# Set referer based on content type
if channel_id is None:
headers["referer"] = f"{self.config.base_website}/videostore"
elif content_type == "tlive":
headers["referer"] = f"{self.config.base_website}/live/tv?channel={channel_id}"
elif content_type == "rlive":
headers["referer"] = f"{self.config.base_website}/live/radio"
else:
headers["referer"] = f"{self.config.base_website}/live/"
payload = {
"ContentType": content_type,
"ContentReferenceId": content_ref_id,
"ContentDrmId": content_drm_id or f"{content_ref_id}_drm",
"VideostoreReferenceIds": video_store_ids,
"ChannelReferenceId": channel_id,
"StartTime": start_time,
"EndTime": end_time,
}
logger.debug(
f"Authorizing session - type: {content_type}, ref: {content_ref_id}, drm: {content_drm_id}"
)
auth_endpoint = self.config.endpoint("authorize_session")
try:
bearer_token = self._current_token.access_token if self._current_token else ""
headers = self._get_api_headers(bearer_token)
# Set referer based on content type
if channel_id is None:
headers["referer"] = f"{self.config.base_website}/videostore"
else:
if content_type == "tlive":
headers["referer"] = f"{self.config.base_website}/live/tv?channel={channel_id}"
elif content_type == "rlive":
headers["referer"] = f"{self.config.base_website}/live/radio"
else:
headers["referer"] = f"{self.config.base_website}/live/"
payload = {
"ContentType": content_type,
"ContentReferenceId": content_ref_id,
"ContentDrmId": content_drm_id or f"{content_ref_id}_drm",
"VideostoreReferenceIds": video_store_ids,
"ChannelReferenceId": channel_id,
"StartTime": start_time,
"EndTime": end_time,
}
logger.debug(
f"Authorizing session - type: {content_type}, ref: {content_ref_id}, drm: {content_drm_id}"
)
auth_endpoint = self.config.api_endpoints.get("authorize_session", "")
if not auth_endpoint:
logger.error("No authorize_session endpoint configured")
return None
response = self.http_manager.post(
auth_endpoint,
operation="api",
headers=headers,
data=json.dumps(payload),
auth_endpoint, operation="api", headers=headers, data=json.dumps(payload)
)
response.raise_for_status()
result = response.json()
session_result = result.get("Result")
if session_result:
authorized = session_result.get("Authorized", False)
session_id = session_result.get("SessionId") or session_result.get("DrmId")
logger.debug(
f"Session authorization result - authorized: {authorized}, session_id: {session_id}"
)
return session_result
else:
logger.warning(
f"No result in session authorization response "
f"(ErrorCode={result.get('ErrorCode')}: {result.get('ErrorDescription')})"
)
return None
except ProviderError:
raise
except Exception as exc:
raise typed_error_for(exc, auth_endpoint) from exc
except Exception as e:
logger.error(f"HRTi session authorization failed: {e}")
return None
session_result = result.get("Result")
if not session_result:
raise ServerError(
"HRTi: no result in AuthorizeSession response "
f"(ErrorCode={result.get('ErrorCode')}: {result.get('ErrorDescription')})",
url=auth_endpoint,
code=str(result.get("ErrorCode")),
)
logger.debug(
f"Session authorization result - authorized: {session_result.get('Authorized', False)}, "
f"session_id: {session_result.get('SessionId') or session_result.get('DrmId')}"
)
return session_result
def report_session_event(self, session_id: str, channel_id: str = None) -> bool:
"""Report session event (like play start)"""
@@ -608,7 +601,7 @@ class HRTiAuthenticator(BaseAuthenticator):
payload = {"SessionEventId": 1, "SessionId": session_id} # 1 = play start
report_endpoint = self.config.api_endpoints.get("report_session", "")
report_endpoint = self.config.endpoint("report_session")
if not report_endpoint:
logger.warning("No report_session endpoint configured")
return False
@@ -628,33 +621,6 @@ class HRTiAuthenticator(BaseAuthenticator):
logger.warning(f"Session event reporting failed: {e}")
return False
def get_license_data(self, session_id: str) -> str:
"""Generate license data for DRM - returns base64 encoded string"""
try:
# Use merchant from config (now dynamically loaded)
merchant = self.config.merchant or "aviion2"
drm_license = {
"userId": self._user_id or "",
"sessionId": session_id,
"merchant": merchant,
}
logger.debug(
f"Creating license data - userId: {self._user_id}, sessionId: {session_id}, merchant: {merchant}"
)
# Encode to JSON then to base64
license_bytes = json.dumps(drm_license).encode("utf-8")
license_b64 = base64.b64encode(license_bytes).decode("utf-8")
logger.debug(f"License data (base64, first 30 chars): {license_b64[:30]}...")
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"""
@@ -669,7 +635,7 @@ class HRTiAuthenticator(BaseAuthenticator):
# Check if it's an HRTiAuthToken and has user_id attribute
if isinstance(token, HRTiAuthToken):
# Check if it's an anonymous token by user_id
if token.user_id == "anonymoushrt":
if token.user_id == HRTiDefaults.ANONYMOUS_USERNAME:
return TokenAuthLevel.ANONYMOUS
# Check if we have user credentials available
@@ -695,18 +661,18 @@ class HRTiAuthenticator(BaseAuthenticator):
)
# If we have user credentials and token has a real user_id, it's user authenticated
if has_user_creds and token.user_id and token.user_id != "anonymoushrt":
if has_user_creds and token.user_id and token.user_id != HRTiDefaults.ANONYMOUS_USERNAME:
return TokenAuthLevel.USER_AUTHENTICATED
# Check if using anonymous credentials
if hasattr(self.credentials, "username") and self.credentials.username == "anonymoushrt":
if hasattr(self.credentials, "username") and self.credentials.username == HRTiDefaults.ANONYMOUS_USERNAME:
return TokenAuthLevel.ANONYMOUS
# If we have user credentials, consider it user authenticated
if (
hasattr(self.credentials, "username")
and self.credentials.username
and self.credentials.username != "anonymoushrt"
and self.credentials.username != HRTiDefaults.ANONYMOUS_USERNAME
):
return TokenAuthLevel.USER_AUTHENTICATED
@@ -0,0 +1,160 @@
# streaming_providers/providers/hrti/channel_manager.py
"""
HRTi live channel manager (live TV + radio).
Playback flow (unchanged from v1, now one shared authorised session):
AuthorizeSession(rlive|tlive, channel id, drm id derived from the stream URL)
-> report the session start -> manifest = the channel's StreamingURL
-> DRM from the same session (DrmId -> DRMToday upfront token).
Manifest and DRM share ONE authorised session through the provider-owned
playout cache (5 s TTL), so a zap costs one AuthorizeSession, not two.
Return conventions (ChannelManager ABC):
* handles_content_id is ids.is_live (digits only); anything else is not ours
* unknown live id -> NotFoundError; session not authorised -> EntitlementError
* transport / auth / server failures propagate typed (base.errors)
"""
import threading
import time
from typing import Any, Dict, List, Optional, Tuple
from ...base.errors import EntitlementError, NotFoundError
from ...base.managers import ChannelManager
from ...base.models import Channel, DRMConfig
from ...base.utils.logger import logger
from . import ids
from .api import api_post
from .constants import HRTiDefaults
from .drm import build_widevine, derive_live_drm_id
from .models import LiveChannelInfo
_LIVE_REFERER = "/"
class HRTiChannelManager(ChannelManager):
def __init__(
self,
*,
http_manager: Any,
auth: Any,
country: str,
config: Any,
playout_cache: Optional[Dict[str, Tuple[Dict, float]]] = None,
):
super().__init__(
http_manager=http_manager, auth=auth, country=country, config=config
)
# Provider-owned dict, borrowed by reference (cleared on credential change).
self._playout_cache = playout_cache if playout_cache is not None else {}
# channel id -> API record (stream URL, radio flag); filled by get_channels()
self._records: Dict[str, LiveChannelInfo] = {}
self._records_lock = threading.Lock()
# ------------------------------------------------------------------
# Routing
# ------------------------------------------------------------------
def handles_content_id(self, content_id: str) -> bool:
return ids.is_live(content_id)
# ------------------------------------------------------------------
# Channels
# ------------------------------------------------------------------
def get_channels(self, **kw: Any) -> List[Channel]:
result = api_post(
http_manager=self.http_manager,
session=self.auth,
config=self.config,
endpoint_key="channels",
payload={},
referer_path=_LIVE_REFERER,
)
if not result:
logger.warning("HRTi: no channels in the GetChannels response")
return []
records: Dict[str, LiveChannelInfo] = {}
channels: List[Channel] = []
for raw in result:
try:
record = LiveChannelInfo.from_api_response(raw)
channel = record.to_channel(HRTiDefaults.PROVIDER_NAME, self.country.upper())
except (KeyError, TypeError, AttributeError, ValueError) as exc:
logger.warning(f"HRTi: skipping malformed channel entry: {exc!r}")
continue
records[record.content_id] = record
channels.append(channel)
with self._records_lock:
self._records = records
logger.info(f"HRTi: {len(channels)} channels loaded")
return channels
def clear_caches(self) -> None:
"""Called by the provider when the credentials / session change."""
self._playout_cache.clear()
with self._records_lock:
self._records = {}
def _record_for(self, content_id: str) -> LiveChannelInfo:
with self._records_lock:
record = self._records.get(content_id)
if record is None:
self.get_channels() # fills self._records
with self._records_lock:
record = self._records.get(content_id)
if record is None:
raise NotFoundError(f"HRTi: channel {content_id} is not in the channel list")
return record
# ------------------------------------------------------------------
# Authorised playback session (shared by manifest and DRM)
# ------------------------------------------------------------------
def _session(self, content_id: str) -> Dict[str, Any]:
now = time.time()
cached = self._playout_cache.get(content_id)
if cached and (now - cached[1]) < HRTiDefaults.PLAYOUT_CACHE_TTL:
return cached[0]
record = self._record_for(content_id)
content_type = "rlive" if record.is_radio else "tlive"
session_data = self.auth.authorize_session(
content_type=content_type,
content_ref_id=content_id,
content_drm_id=derive_live_drm_id(record.stream_url),
video_store_ids=None,
channel_id=content_id,
start_time=None,
end_time=None,
)
if not session_data.get("Authorized", False):
raise EntitlementError(
f"HRTi: session for channel {content_id} was not authorised"
)
session_id = session_data.get("SessionId")
if session_id:
self.auth.report_session_event(session_id, content_id) # best effort
self._playout_cache[content_id] = (session_data, now)
return session_data
# ------------------------------------------------------------------
# Manifest / DRM
# ------------------------------------------------------------------
def get_channel_manifest(self, content_id: str, **kw: Any) -> Optional[str]:
self._session(content_id) # authorise first (v1 behaviour)
record = self._record_for(content_id)
if not record.stream_url:
logger.warning(f"HRTi: no streaming URL for channel {content_id}")
return None
return record.stream_url
def get_channel_drm(self, content_id: str, **kw: Any) -> List[DRMConfig]:
# The backend passes drm_variant / preferred_* kwargs: accepted, unused.
session_data = self._session(content_id)
return build_widevine(
self.config,
self.auth.user_id,
session_data,
content_id,
referer_path=_LIVE_REFERER,
)
@@ -1,26 +1,47 @@
# streaming_providers/providers/hrti/constants.py
"""
HRTi provider constants and default configurations
HRTi constants -- the single source of truth for URLs, header values,
identifiers and defaults. Managers, session, drm.py and auth.py read from here.
Identity (ONE id everywhere): directory == registry key == provider_name ==
PROVIDER_NAME == credentials / session key == "hrti". PROVIDER_LABEL ("HRTi")
is only the UI label.
Dynamic endpoints
-----------------
The API hosts are NOT hardcoded: ``env.json`` and ``config.production.json``
on hrti.hrt.hr name them (``apiUrl``, ``webApiUrl``, merchant, ...). ONE
HRTiConfig instance is shared by authenticator, session and managers and
loads them lazily (``ensure_loaded``) -- independent of the login, so it also
works after a restart with a persisted session. If the bootstrap fails the
defaults below are used (they equal the live values).
"""
import threading
from typing import Any, Dict, Optional
from ...base.errors import ConfigurationError
from ...base.utils.logger import logger
class HRTiDefaults:
"""Default values for HRTi provider"""
# Provider information
PROVIDER_NAME = "hrti"
PROVIDER_LABEL = "HRTi"
PROVIDER_LOGO = "https://upload.wikimedia.org/wikipedia/en/thumb/9/9e/Logo_of_the_HRT.svg/960px-Logo_of_the_HRT.svg.png"
PROVIDER_NAME = "HRTi"
SUPPORTED_COUNTRIES = ("HR",)
# Website and base URLs
# Website / bootstrap
BASE_WEBSITE = "https://hrti.hrt.hr"
BASE_URL = "https://hrti.hrt.hr"
# Configuration endpoints
ENV_ENDPOINT = f"{BASE_URL}/assets/config/env.json"
CONFIG_ENDPOINT = f"{BASE_URL}/assets/config/config.production.json"
# API endpoint path templates (will be combined with dynamic base URLs)
# These are the path parts only - base URLs come from API config
# Fallbacks, used only when the bootstrap above fails
FALLBACK_HSAPI_URL = "https://hsapi.aviion.tv/client.svc/json"
FALLBACK_WEB_API_URL = "api/api/ott"
FALLBACK_MERCHANT = "aviion2"
# Endpoint path templates; base URLs come from the bootstrap
API_ENDPOINT_PATHS = {
"get_ip": "/getIPAddress",
"grant_access": "/GrantAccess",
@@ -38,186 +59,196 @@ class HRTiDefaults:
"watch_later": "/GetWatchLater",
"editors_choice": "/GetEditorsChoice",
}
# served by the web API (webApiUrl) / by the HSAPI (apiUrl)
WEB_API_ENDPOINTS = (
"get_ip", "grant_access", "channels", "programme", "authorize_session",
"report_session", "catalogue_structure", "catalogue", "vod_details",
"episodes", "watch_later", "editors_choice",
)
HSAPI_ENDPOINTS = ("register_device", "content_ratings", "profiles")
# Device information (static defaults, may be overridden by config)
DEVICE_REFERENCE_ID = "6" # String '6' as required by headers
# Device information (overridable through the config dict)
DEVICE_REFERENCE_ID = "6" # string '6' as required by the headers
OPERATOR_REFERENCE_ID = "hrt"
CONNECTION_TYPE = "LAN/WiFi"
APPLICATION_VERSION = "5.97.6"
APPLICATION_VERSION = "5.97.6" # replaced by env.json's applicationVersion
OS_VERSION = "Linux"
CLIENT_TYPE = "Chrome 142"
# User Agent
USER_AGENT = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36"
# One browser version -> User-Agent AND client type (no drift between them)
CHROME_MAJOR = 142
CLIENT_TYPE = f"Chrome {CHROME_MAJOR}"
USER_AGENT = (
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) "
f"Chrome/{CHROME_MAJOR}.0.0.0 Safari/537.36"
)
# HTTP settings
# Anonymous (client-credentials) access -- the deliberate fallback
ANONYMOUS_USERNAME = "anonymoushrt"
ANONYMOUS_PASSWORD = "an0nPasshrt"
# Auth / session
TOKEN_LIFETIME_SECONDS = 86400 # GrantAccess carries no lifetime
SESSION_BACKOFF_BASE = 2.0 # transient login failures: base * 2**(n-1) s
SESSION_BACKOFF_CAP = 60.0
# HTTP / VOD
DEFAULT_TIMEOUT = 30
# VOD settings
VOD_ITEMS_PER_PAGE = 24
MAX_VOD_PAGES = 100
SERIES_PAGE_SIZE = 100
# DRM (DRMToday, token travels in this header; priority was the lib default)
DRM_AUTH_HEADER = "dt-custom-data"
WIDEVINE_PRIORITY = 3
# Caches
PLAYOUT_CACHE_TTL = 5.0 # manifest + DRM share one authorised session
class HRTiConfig:
"""Configuration class for HRTi provider"""
"""Per-instance configuration. ONE instance, shared by every layer."""
def __init__(self, config_dict: dict = None):
"""Initialize with optional configuration overrides"""
def __init__(self, config_dict: Optional[dict] = None):
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)
# Dynamic URLs - will be populated from API
self.hsapi_base_url = config.get("hsapi_base_url", None)
self.web_api_url = config.get("web_api_url", None)
# Dynamic values, filled by the bootstrap
self.hsapi_base_url = config.get("hsapi_base_url")
self.web_api_url = config.get("web_api_url")
self.merchant = config.get("merchant")
self.player_license_key = config.get("player_license_key")
self.selfcare_url = None
self.homepage_url = None
# Configuration endpoints
self.env_endpoint = config.get("env_endpoint", HRTiDefaults.ENV_ENDPOINT)
self.config_endpoint = config.get("config_endpoint", HRTiDefaults.CONFIG_ENDPOINT)
# API endpoints - start with paths only, build full URLs after API call
self.api_endpoint_paths = config.get(
"api_endpoint_paths", HRTiDefaults.API_ENDPOINT_PATHS.copy()
)
self.api_endpoints = {} # Will be populated in update_from_api
self.api_endpoints: Dict[str, str] = {}
# 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", None) # Will be set from API
self.player_license_key = config.get("player_license_key", None) # Will be set from API
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.connection_type = config.get("connection_type", HRTiDefaults.CONNECTION_TYPE)
self.application_version = config.get(
"application_version", HRTiDefaults.APPLICATION_VERSION
)
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)
# VOD settings
self.vod_items_per_page = config.get("vod_items_per_page", HRTiDefaults.VOD_ITEMS_PER_PAGE)
def _build_api_endpoints(self):
"""Build full API endpoints from dynamic base URLs and static paths"""
endpoints = {}
self._http: Any = None
self._loaded = False
self.loaded_from_fallback = False
self._lock = threading.RLock()
# Build endpoints using webApiUrl (for OTT endpoints)
if self.web_api_url:
base_api_url = f"{self.base_url}/{self.web_api_url}"
ott_endpoints = [
"get_ip", "grant_access", "channels", "programme",
"authorize_session", "report_session", "catalogue_structure",
"catalogue", "vod_details", "episodes", "watch_later",
"editors_choice"
]
for key in ott_endpoints:
if key in self.api_endpoint_paths:
endpoints[key] = f"{base_api_url}{self.api_endpoint_paths[key]}"
# ------------------------------------------------------------------
# Lazy dynamic endpoints
# ------------------------------------------------------------------
def attach_http(self, http_manager: Any) -> None:
"""Give the config the HTTP manager used for the bootstrap fetch."""
self._http = http_manager
# Build endpoints using hsapi_base_url (for HSAPI endpoints)
if self.hsapi_base_url:
hsapi_endpoints = ["register_device", "content_ratings", "profiles"]
for key in hsapi_endpoints:
if key in self.api_endpoint_paths:
endpoints[key] = f"{self.hsapi_base_url}{self.api_endpoint_paths[key]}"
@property
def is_loaded(self) -> bool:
return self._loaded
return endpoints
def ensure_loaded(self) -> None:
"""Fetch env.json + config.production.json once; fall back to defaults."""
if self._loaded:
return
with self._lock:
if self._loaded:
return
if self._http is None:
raise ConfigurationError(
"HRTi: HTTP manager not attached to the config (attach_http)"
)
try:
env_resp = self._http.get(self.env_endpoint, operation="api")
env_resp.raise_for_status()
cfg_resp = self._http.get(self.config_endpoint, operation="api")
cfg_resp.raise_for_status()
self.update_from_api(env_resp.json(), cfg_resp.json())
self.loaded_from_fallback = False
logger.debug("HRTi environment configuration loaded")
except Exception as exc: # deliberate: bootstrap failure -> documented defaults
logger.warning(f"HRTi: environment config unavailable ({exc}); using defaults")
self._build_fallback_endpoints()
self.loaded_from_fallback = True
self._loaded = True
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"]
def endpoint(self, key: str) -> str:
"""Full URL of a named endpoint (loads the dynamic config on first use)."""
self.ensure_loaded()
url = self.api_endpoints.get(key)
if not url:
raise ConfigurationError(f"HRTi: no endpoint configured for '{key}'")
return url
# 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 operator-specific settings
if "operators" in config_data and config_data["operators"]:
operator = config_data["operators"][0]
if "playerMerchant" in operator:
self.merchant = operator["playerMerchant"]
if "playerLicenseKey" in operator:
self.player_license_key = operator["playerLicenseKey"]
if "selfcareUrl" in operator:
self.selfcare_url = operator["selfcareUrl"]
if "homepageUrl" in operator:
self.homepage_url = operator["homepageUrl"]
# Build API endpoints from dynamic URLs
self.api_endpoints = self._build_api_endpoints()
# Log what we got from API (debug)
import logging
logging.debug(f"HRTi config updated from API - hsapi_url: {self.hsapi_base_url}, "
f"web_api_url: {self.web_api_url}, merchant: {self.merchant}")
except Exception as e:
import logging
logging.debug(f"Error updating HRTi config from API: {e}")
# Fallback to build endpoints with defaults if API update fails
self._build_fallback_endpoints()
def _build_fallback_endpoints(self):
"""Build fallback API endpoints if dynamic config fails"""
# Use default hsapi URL if not set
if not self.hsapi_base_url:
self.hsapi_base_url = "https://hsapi.aviion.tv/client.svc/json"
# Use default web API URL if not set
if not self.web_api_url:
self.web_api_url = "api/api/ott"
# Use default merchant if not set
if not self.merchant:
self.merchant = "aviion2"
def get_merchant(self) -> str:
self.ensure_loaded()
return self.merchant or HRTiDefaults.FALLBACK_MERCHANT
def update_from_api(self, env_data: dict, config_data: dict) -> None:
if "applicationVersion" in env_data:
self.application_version = env_data["applicationVersion"]
if "apiUrl" in config_data:
self.hsapi_base_url = config_data["apiUrl"]
if "webApiUrl" in config_data:
self.web_api_url = config_data["webApiUrl"]
operators = config_data.get("operators") or []
if operators:
operator = operators[0]
self.merchant = operator.get("playerMerchant", self.merchant)
self.player_license_key = operator.get("playerLicenseKey", self.player_license_key)
self.selfcare_url = operator.get("selfcareUrl", self.selfcare_url)
self.homepage_url = operator.get("homepageUrl", self.homepage_url)
self.api_endpoints = self._build_api_endpoints()
def get_base_headers(self) -> dict:
"""Get base HTTP headers"""
return {
def _build_fallback_endpoints(self) -> None:
self.hsapi_base_url = self.hsapi_base_url or HRTiDefaults.FALLBACK_HSAPI_URL
self.web_api_url = self.web_api_url or HRTiDefaults.FALLBACK_WEB_API_URL
self.merchant = self.merchant or HRTiDefaults.FALLBACK_MERCHANT
self.api_endpoints = self._build_api_endpoints()
def _build_api_endpoints(self) -> Dict[str, str]:
endpoints: Dict[str, str] = {}
if self.web_api_url:
base = f"{self.base_url}/{self.web_api_url}"
for key in HRTiDefaults.WEB_API_ENDPOINTS:
if key in self.api_endpoint_paths:
endpoints[key] = f"{base}{self.api_endpoint_paths[key]}"
if self.hsapi_base_url:
for key in HRTiDefaults.HSAPI_ENDPOINTS:
if key in self.api_endpoint_paths:
endpoints[key] = f"{self.hsapi_base_url}{self.api_endpoint_paths[key]}"
return endpoints
# ------------------------------------------------------------------
# Header builders
# ------------------------------------------------------------------
def api_headers(
self,
token: Optional[str] = None,
device_id: Optional[str] = None,
ip_address: Optional[str] = None,
referer_path: str = "/",
) -> Dict[str, str]:
"""Headers of the manager API calls (NOT for CDN / manifest requests)."""
headers = {
"deviceid": device_id or "",
"devicetypeid": self.device_reference_id,
"ipaddress": ip_address or "",
"operatorreferenceid": self.operator_reference_id,
"origin": self.base_website,
"referer": f"{self.base_website}{referer_path}",
"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(
{
"devicetypeid": self.device_reference_id,
"operatorreferenceid": self.operator_reference_id,
"origin": self.base_website,
"referer": self.base_website,
}
)
return headers
return headers
@@ -0,0 +1,81 @@
# streaming_providers/providers/hrti/drm.py
"""
Shared DRM building for the live and the VOD manager (one place to fix a
header / license-data bug).
HRTi licences go to DRMToday. The "upfront token" is NOT a bearer token: it is
base64(JSON{userId, sessionId, merchant}) bound to an authorised playback
session (AuthorizeSession -> DrmId). Sent in the ``dt-custom-data`` header.
Known limitation (README §11): the DRMConfig lives as long as inputstream.
adaptive caches it; a fresh zap re-invokes get_drm and authorises again.
"""
import base64
import json
from typing import Any, Dict, List, NamedTuple, Optional
from urllib.parse import urlparse
from ...base.errors import ServerError
from ...base.models import DRMConfig
from ...base.utils.logger import logger
from ..lib_drmtoday import create_drmtoday_widevine_config
from .constants import HRTiConfig, HRTiDefaults
class VodSessionParams(NamedTuple):
content_ref_id: str
content_drm_id: str
def derive_live_drm_id(streaming_url: str) -> Optional[str]:
"""First two path segments joined by '_' (/cdn1oiv/hrtliveorigin/... ->
"cdn1oiv_hrtliveorigin"); None when the path is too short."""
parts = urlparse(streaming_url).path.strip("/").split("/")
if len(parts) >= 2:
return f"{parts[0]}_{parts[1]}"
return None
def derive_vod_session_params(file_name: str) -> VodSessionParams:
"""
FileName = https://cdn1oiv.akamaized.net/hrtvodorigin/REF.smil/manifest.mpd
ContentReferenceId = "REF" (stem without .smil)
ContentDrmId = "hrtvodorigin_REF.smil"
"""
parts = urlparse(file_name).path.strip("/").split("/")
if len(parts) < 2:
raise ServerError(f"HRTi: cannot derive session parameters from FileName {file_name!r}")
origin, ref_with_ext = parts[0], parts[1]
ref_id = ref_with_ext[: -len(".smil")] if ref_with_ext.endswith(".smil") else ref_with_ext
return VodSessionParams(content_ref_id=ref_id, content_drm_id=f"{origin}_{ref_with_ext}")
def build_license_data(user_id: str, session_id: str, merchant: str) -> str:
payload = {"userId": user_id or "", "sessionId": session_id, "merchant": merchant}
return base64.b64encode(json.dumps(payload).encode("utf-8")).decode("utf-8")
def build_widevine(
config: HRTiConfig,
user_id: str,
session_data: Dict[str, Any],
content_id: str,
referer_path: str,
) -> List[DRMConfig]:
"""Widevine config from an AUTHORISED session. [] when the session has no
DrmId (logged; the stream cannot be licensed)."""
drm_id = session_data.get("DrmId")
if not drm_id:
logger.error(f"HRTi: no DrmId in session data for {content_id}")
return []
license_data = build_license_data(user_id, drm_id, config.get_merchant())
drm_config = create_drmtoday_widevine_config(
upfront_token=license_data,
origin=config.base_website,
referer=f"{config.base_website}{referer_path}",
user_agent=config.user_agent,
priority=HRTiDefaults.WIDEVINE_PRIORITY,
auth_header_name=HRTiDefaults.DRM_AUTH_HEADER,
)
return [drm_config]
@@ -0,0 +1,46 @@
# streaming_providers/providers/hrti/ids.py
"""
The ONE place that knows the HRTi content-id grammar. Both managers'
handles_content_id() call these predicates, so they are complementary by
construction (README §7). Pure functions, no I/O.
Grammar
-------
"" VOD root (navigation, not playable)
"catalogue_<ref>" VOD folder (navigation)
"series_<sid>--<ssid>" VOD series season (navigation)
"special_watch_later" VOD special list (navigation)
"special_editors_choice" VOD special list (navigation)
"details_<ref>" one playable VOD item (explicit)
"<ref>" not purely digits one playable VOD item (UUID or "1870699590_delin")
"<digits>" live channel / radio (e.g. "40013")
Navigation ids are claimed by NEITHER manager for playback: they are browsed
through get_vod_category, which the backend calls directly.
"""
NAV_PREFIXES = ("catalogue_", "series_", "special_")
DETAILS_PREFIX = "details_"
def is_nav(content_id: str) -> bool:
return any(content_id.startswith(p) for p in NAV_PREFIXES)
def is_live(content_id: str) -> bool:
return bool(content_id) and content_id.isdigit()
def is_vod(content_id: str) -> bool:
if not content_id or is_nav(content_id):
return False
if content_id.startswith(DETAILS_PREFIX):
return True
return not content_id.isdigit()
def vod_ref(content_id: str) -> str:
"""The bare ReferenceId of a playable VOD id (strips ``details_``)."""
if content_id.startswith(DETAILS_PREFIX):
return content_id[len(DETAILS_PREFIX):]
return content_id
+65 -164
View File
@@ -1,26 +1,28 @@
# [file name]: models.py
# [file content begin]
# streaming_providers/providers/hrti/models.py
from dataclasses import dataclass
from typing import Any, Dict, Optional
"""
Provider-local models: credentials, auth token, and strict parsers for the
live-channel and VOD-details responses. Parsing is STRICT (KeyError /
ValueError on missing required fields); the managers parse entry by entry and
skip malformed entries, so one bad entry cannot take a whole list down.
"""
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional
from ...base.auth.base_auth import BaseAuthToken
from ...base.auth.credentials import UserPasswordCredentials
from ...base.models import Channel
from .constants import HRTiDefaults
@dataclass
class HRTiCredentials(UserPasswordCredentials):
"""
HRTi specific username/password credentials
"""
"""HRTi username/password credentials."""
def __init__(self, username: str, password: str):
super().__init__(username=username, password=password)
# credential_type is not a property in base class, so we don't set it
def to_auth_payload(self) -> Dict[str, Any]:
"""Convert to authentication payload for HRTi"""
return {
"Username": self.username,
"Password": self.password,
@@ -28,14 +30,11 @@ class HRTiCredentials(UserPasswordCredentials):
}
def validate(self) -> bool:
"""Validate HRTi credentials"""
return bool(self.username and self.password)
class HRTiAuthToken(BaseAuthToken):
"""
HRTi specific authentication token
"""
"""HRTi authentication token (persisted flat via to_dict / from_dict)."""
def __init__(
self,
@@ -48,7 +47,6 @@ class HRTiAuthToken(BaseAuthToken):
valid_to: str = "",
refresh_token: Optional[str] = None,
):
super().__init__(
access_token=access_token,
token_type=token_type,
@@ -56,198 +54,101 @@ class HRTiAuthToken(BaseAuthToken):
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
# Don't set credential_type - it's not in base class
def to_dict(self) -> Dict[str, Any]:
"""Convert token to dictionary"""
base_dict = {
"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 or "",
"user_id": self.user_id,
"valid_from": self.valid_from,
"valid_to": self.valid_to,
}
# Add HRTi specific fields
base_dict.update(
{
"user_id": self.user_id,
"valid_from": self.valid_from,
"valid_to": self.valid_to,
}
)
# Add auth_level if available
if hasattr(self, "auth_level") and self.auth_level:
base_dict["auth_level"] = self.auth_level.value
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),
expires_in=data.get("expires_in", HRTiDefaults.TOKEN_LIFETIME_SECONDS),
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"),
refresh_token=data.get("refresh_token") or None,
)
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
"""
class LiveChannelInfo:
"""One entry of GetChannels. The record stays in the ChannelManager."""
id: str
content_id: str
name: str
reference_id: str
streaming_url: str
icon_url: str
stream_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")
icon_url: str = ""
@classmethod
def from_api_response(cls, data: Dict[str, Any]) -> "HRTiChannel":
"""Create channel from API response data"""
def from_api_response(cls, data: Dict[str, Any]) -> "LiveChannelInfo":
# The API spells it 'ReferenceID' (capital ID) here.
name = data["Name"]
content_id = data["ReferenceID"]
if not name or not content_id:
raise ValueError("channel entry without name or ReferenceID")
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),
content_id=str(content_id),
name=name,
stream_url=data.get("StreamingURL") or "",
is_radio=bool(data.get("Radio", False)),
icon_url=data.get("Icon") or "",
)
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,
}
def to_channel(self, provider_name: str, country: str) -> Channel:
"""Same fields as v1: a session-manifest channel whose manifest_script
carries the stream URL; radio keeps content_type "AUDIO" (the clients
and the v1 code distinguish radio by it)."""
return Channel(
name=self.name,
content_id=self.content_id,
provider=provider_name,
logo_url=self.icon_url,
mode="live",
session_manifest=True,
manifest=None,
manifest_script=self.stream_url,
content_type="AUDIO" if self.is_radio else "LIVE",
country=country,
is_radio=self.is_radio,
language="hr",
use_cdm=True,
cdm_type="widevine",
)
@dataclass
class HRTiEpgEntry:
"""
HRTi EPG (Electronic Program Guide) entry
"""
class VodDetails:
"""Playback-relevant part of GetVodDetails."""
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
ref_id: str
file_name: str = "" # the DASH manifest URL
svod_stores: List[str] = field(default_factory=list)
@classmethod
def from_api_response(cls, data: Dict[str, Any], channel_id: str) -> "HRTiEpgEntry":
"""Create EPG entry from API response"""
def from_api_response(cls, data: Dict[str, Any]) -> "VodDetails":
ref_id = data.get("ReferenceID") or data.get("ReferenceId")
if not ref_id:
raise ValueError("GetVodDetails response without ReferenceID")
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"),
ref_id=str(ref_id),
file_name=data.get("FileName") or "",
svod_stores=list(data.get("SVODVideostores") or []),
)
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]
@@ -1,44 +1,59 @@
# streaming_providers/providers/hrti/provider.py
import json
import datetime
import traceback
from urllib.parse import urlparse
from typing import ClassVar, Dict, List, Optional, Union
"""
HRTi provider -- orchestrator only.
import requests
Owns the SHARED resources (ONE config, http_manager, authenticator + session,
caches) and builds the managers; everything generic (capability flags,
content-id routing, manifest / DRM delegation, legacy surface) comes from
ManagedProvider.
from ...base.models import DRMConfig, StreamingChannel, Event
Capabilities in this step: live channels + radio, VOD. EPG is deliberately
NOT registered yet (v1 had implements_epg False; HRTiEpgManager follows as a
second step and needs a real GetProgramme sample). The unwired
epg_manager.py stays untouched in the package until then.
"""
from typing import ClassVar, Dict, List, Optional
from ...base.managed_provider import ManagedProvider
from ...base.models.proxy_models import ProxyConfig
from ...base.provider import AuthType, StreamingProvider
from ...base.utils import logger
from ..lib_drmtoday import create_drmtoday_widevine_config
from .auth import HRTiAuthenticator
from .channel_manager import HRTiChannelManager
from .constants import HRTiConfig, HRTiDefaults
from .session import HRTiSession
from .vod_manager import HRTiVodManager
# Navigation-only prefixes — these content_ids are category/series containers.
# They are never directly playable and must never be sent to get_manifest/get_drm.
_NAV_PREFIXES = ("catalogue_", "series_", "special_")
# Playable VOD prefix — "details_{ref_id}" is an explicit single-item request.
_DETAILS_PREFIX = "details_"
# Only the provider is public (discovery takes the FIRST StreamingProvider
# subclass in the package namespace -- never export ManagedProvider).
__all__ = ["HRTiProvider"]
class HRTiProvider(StreamingProvider):
"""
HRTi (Croatian Radio Television Internet) provider implementation.
"""
# ============================================================================
# STATIC METADATA
# ============================================================================
PROVIDER_LABEL: ClassVar[str] = "HRTi"
SUPPORTED_AUTH_TYPES: ClassVar[List[str]] = [
"client_credentials",
"user_credentials",
]
class HRTiProvider(ManagedProvider):
PROVIDER_LABEL: ClassVar[str] = HRTiDefaults.PROVIDER_LABEL
PROVIDER_LOGO: ClassVar[str] = HRTiDefaults.PROVIDER_LOGO
SUPPORTED_COUNTRIES: ClassVar[List[str]] = ["HR"]
SUPPORTED_AUTH_TYPES: ClassVar[List[str]] = ["client_credentials", "user_credentials"]
SUPPORTED_COUNTRIES: ClassVar[List[str]] = list(HRTiDefaults.SUPPORTED_COUNTRIES)
# Manifest and DRM share one authorised session -> DRM lives in the managers.
DRM_IN_MANAGERS: ClassVar[bool] = True
# v1 had no manager header hooks and sent {} for manifest/segments; the DRM
# config carries Origin/Referer/UA itself. Decided: keep {}.
HEADERS_FROM_MANAGERS: ClassVar[bool] = False
@property
def provider_name(self) -> str:
"""Must equal the directory name / registry key."""
return HRTiDefaults.PROVIDER_NAME
@property
def uses_dynamic_manifests(self) -> bool:
# v1 value (True) differs from the legacy default (False): keep.
return True
@property
def provider_logo(self) -> str:
# v1: the logo can be overridden through the config dict.
return self.hrti_config.logo
def __init__(
self,
@@ -46,551 +61,65 @@ class HRTiProvider(StreamingProvider):
config: Optional[Dict] = None,
proxy_config: Optional[ProxyConfig] = None,
):
super().__init__(country)
super().__init__(country=country)
# Registry passes UPPERCASE for single-country providers; the proxy
# lookup keys by lowercase. NOTE: the authenticator keeps its FLAT
# storage keys (country=None) -- existing credentials/session/device
# id are stored without a country and must stay reachable.
self.country = self.country.lower()
# Initialize configuration with overrides
# 1. ONE config object, shared by authenticator, session and managers.
self.hrti_config = HRTiConfig(config)
self.channels_cache = None
# Short-lived session cache: {channel_id: session_data}.
# Populated by _get_live_manifest so that a subsequent _get_live_drm
# call for the same channel (same playback request) can reuse the
# already-authorized session instead of making a second round trip.
self._session_cache: Dict[str, dict] = {}
# Setup HTTP manager using abstraction
# 2. HTTP manager (proxy: ctor arg -> ProxyConfigManager -> global).
self.http_manager = self._setup_http_manager(
provider_name="hrti",
provider_name=HRTiDefaults.PROVIDER_NAME,
proxy_config=proxy_config,
user_agent=self.hrti_config.user_agent,
timeout=self.hrti_config.timeout,
)
# The dynamic endpoints (env.json / config.production.json) are fetched
# lazily through this manager on first use -- not in __init__.
self.hrti_config.attach_http(self.http_manager)
# Initialize authenticator and share HTTP manager
# 3. Auth: stateful authenticator (kept: the legacy auth mixin reads
# self.authenticator) + the session adapter the managers use.
self.authenticator = HRTiAuthenticator(
proxy_config=proxy_config, http_manager=self.http_manager
http_manager=self.http_manager, config=self.hrti_config
)
self.auth = HRTiSession(authenticator=self.authenticator, config=self.hrti_config)
# 4. Provider-owned caches, borrowed by the managers by reference.
self._playout_cache: Dict = {}
self.auth.on_invalidate(self._clear_caches)
# 5. Managers. self.channels is the ChannelManager (never a list).
self._init_managers()
def _clear_caches(self) -> None:
self._playout_cache.clear()
if self.channels is not None:
self.channels.clear_caches()
# ------------------------------------------------------------------
# Manager factories (None = capability not offered)
# ------------------------------------------------------------------
def _build_channels(self):
return HRTiChannelManager(
http_manager=self.http_manager,
auth=self.auth,
country=self.country,
config=self.hrti_config,
playout_cache=self._playout_cache,
)
# Share HTTP manager
self.http_manager = self._share_http_manager_with_authenticator(self.authenticator)
# Initialize VOD manager
self._vod_manager = HRTiVodManager(self)
try:
self.authenticator.get_bearer_token()
logger.debug("HRTi authentication successful during initialization")
except Exception as e:
logger.warning(f"HRTi could not authenticate during initialization: {e}")
# ============================================================================
# Provider properties
# ============================================================================
@property
def provider_name(self) -> str:
return "hrti"
@property
def provider_label(self) -> str:
return self.get_static_label(self.country)
@property
def provider_logo(self) -> str:
return self.hrti_config.logo or self.PROVIDER_LOGO
@property
def uses_dynamic_manifests(self) -> bool:
return True
@property
def implements_epg(self) -> bool:
return False
@property
def implements_vod(self) -> bool:
return True
@property
def supported_auth_types(self) -> List[str]:
return self.SUPPORTED_AUTH_TYPES
# ============================================================================
# Internal helpers
# ============================================================================
def _get_hrti_authenticated_headers(self) -> Dict[str, str]:
"""Build authenticated headers for HRTi API requests."""
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 _build_vod(self):
return HRTiVodManager(
http_manager=self.http_manager,
auth=self.auth,
country=self.country,
config=self.hrti_config,
playout_cache=self._playout_cache,
)
@staticmethod
def _is_nav_node(content_id: str) -> bool:
"""Return True for navigation-only ids (category/series/season/special).
These are never playable and must not be passed to get_manifest/get_drm."""
return any(content_id.startswith(p) for p in _NAV_PREFIXES)
@staticmethod
def _is_playable_vod(content_id: str) -> bool:
"""Return True for ids that represent a playable VOD item.
Two forms are playable:
- ``details:{ref_id}`` — explicit single-item request
- bare ref-id (no prefix) — raw ReferenceId from catalogue listing
(UUIDs like "85b273d1-..." or legacy ids like "1870699590_delin")
Live channel ids are also bare numeric strings (e.g. "40013") so this
method alone does not distinguish VOD from live — callers must first
exclude nav nodes, then check against the live channel list.
"""
if any(content_id.startswith(p) for p in _NAV_PREFIXES):
return False
# "details_" prefix is an explicit VOD request
if content_id.startswith(_DETAILS_PREFIX):
return True
# Bare id — could be VOD or live; live ids are short numerics.
# We treat any non-numeric bare id as VOD; live channel lookup
# will confirm or deny for numeric ids.
return not content_id.isdigit()
@staticmethod
def _derive_content_drm_id(streaming_url: str) -> Optional[str]:
"""
Derive the content DRM ID from a streaming URL.
HRTi expects the first two path segments joined by '_'.
Example: /cdn1oiv/hrtliveorigin/... → "cdn1oiv_hrtliveorigin"
"""
parts = urlparse(streaming_url).path.strip("/").split("/")
if len(parts) >= 2:
return f"{parts[0]}_{parts[1]}"
return None
def _find_channel(self, content_id: str) -> Optional[StreamingChannel]:
"""Return the cached channel for *content_id*, fetching if needed."""
channels = self.channels if (hasattr(self, "channels") and self.channels) else self.get_channels()
for ch in channels:
# StreamingChannel is constructed with content_id= kwarg; some base-class
# versions also alias it as channel_id — check both to be safe.
ch_id = getattr(ch, "content_id", None) or getattr(ch, "channel_id", None)
if ch_id == content_id:
return ch
return None
def _authorize_live_session(self, content_id: str) -> Optional[dict]:
"""
Authorize a live playback session for *content_id*.
Looks up the channel to determine the correct content_type
(``rlive`` for radio, ``tlive`` for TV) and derives the DRM ID
from the streaming URL. Reports the session-start event on
success and stores the result in ``_session_cache``.
Returns the raw session dict, or None on failure.
"""
channel = self._find_channel(content_id)
if not channel:
logger.error(f"Channel {content_id} not found for session authorization")
return None
content_type = "rlive" if channel.content_type == "AUDIO" else "tlive"
content_drm_id = self._derive_content_drm_id(channel.manifest_script or "")
logger.debug(
f"Authorizing session — channel: {content_id}, "
f"content_type: {content_type}, drm_id: {content_drm_id}"
)
session_data = self.authenticator.authorize_session(
content_type=content_type,
content_ref_id=content_id,
content_drm_id=content_drm_id,
video_store_ids=None,
channel_id=content_id,
start_time=None,
end_time=None,
)
if not session_data or not session_data.get("Authorized", False):
logger.error(f"Session authorization failed for channel {content_id}")
return None
logger.debug(f"Session authorized for channel {content_id}")
session_id = session_data.get("SessionId")
if session_id:
self.authenticator.report_session_event(session_id, content_id)
# Cache so a same-request get_drm() call can skip a second auth round trip
self._session_cache[content_id] = session_data
return session_data
def _build_widevine_drm_config(
self,
session_data: dict,
content_id: str,
referer_path: str,
) -> List[DRMConfig]:
"""
Build a Widevine DRMConfig list from an already-authorized session.
Args:
session_data: Authorized session dict containing at least ``DrmId``.
content_id: Used only for log messages.
referer_path: Appended to base_website for the Referer header
(e.g. ``"/"`` for live, ``"/videostore"`` for VOD).
"""
drm_id = session_data.get("DrmId")
if not drm_id:
logger.error(f"No DrmId in session data for {content_id}")
return []
license_data = self.authenticator.get_license_data(drm_id)
if not license_data:
logger.error(f"Failed to generate license data for {content_id}")
return []
drm_config = create_drmtoday_widevine_config(
upfront_token=license_data,
origin=self.hrti_config.base_website,
referer=f"{self.hrti_config.base_website}{referer_path}",
user_agent=self.hrti_config.user_agent,
auth_header_name="dt-custom-data",
)
if not drm_config:
logger.error(f"create_drmtoday_widevine_config returned nothing for {content_id}")
return []
logger.debug(f"Created Widevine DRM config for {content_id}")
return [drm_config]
# ============================================================================
# Channel fetching
# ============================================================================
def _fetch_channels_once(self) -> List[StreamingChannel]:
"""Single attempt to fetch and parse the channel list."""
headers = self._get_hrti_authenticated_headers()
logger.debug(
"Fetching HRTi channels with authorization: "
f"{'Client ...' if 'authorization' in headers else 'NO AUTHORIZATION'}"
)
channels_endpoint = self.hrti_config.api_endpoints.get("channels", "")
if not channels_endpoint:
logger.error("No channels endpoint configured")
return []
response = self.http_manager.post(
channels_endpoint,
operation="api",
headers=headers,
data=json.dumps({}),
)
response.raise_for_status()
channels_data = response.json()
if not channels_data.get("Result"):
logger.warning("No channels found in HRTi response")
logger.debug(f"HRTi channels response: {channels_data}")
return []
channels = []
for raw in channels_data["Result"]:
ch = self._parse_channel_data(raw)
if ch:
channels.append(ch)
self.channels = channels
return channels
def get_channels(self, **kwargs) -> List[StreamingChannel]:
"""Fetch channels from HRTi API, with one auth-refresh retry on failure."""
try:
channels = self._fetch_channels_once()
logger.info(f"Successfully fetched {len(channels)} channels from HRTi")
return channels
except requests.RequestException as e:
logger.error(f"Error fetching HRTi channels: {e}")
logger.info("Attempting to refresh authentication and retry...")
self.authenticator.invalidate_token()
try:
channels = self._fetch_channels_once()
logger.info(f"Successfully fetched {len(channels)} channels from HRTi on retry")
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 get_events(
self,
start_time: Optional[datetime.datetime] = None,
end_time: Optional[datetime.datetime] = None,
**kwargs,
) -> List[Event]:
return []
def _parse_channel_data(self, channel_data: Dict) -> Optional[StreamingChannel]:
"""Parse a raw HRTi channel dict into a StreamingChannel."""
try:
name = channel_data.get("Name", "")
# 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
channel = StreamingChannel(
name=name,
content_id=channel_id,
provider=self.provider_name,
logo_url=icon_url,
mode="live",
session_manifest=True,
manifest=None,
manifest_script=streaming_url,
content_type="AUDIO" if is_radio else "LIVE",
country=self.country,
is_radio=is_radio,
language="hr",
)
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
# ============================================================================
# VOD
# ============================================================================
def get_vod_category(
self,
content_id: str = "",
cursor: Optional[str] = None,
page_size: int = 24,
**kwargs,
) -> Union[List, Dict]:
"""
Get VOD category children. Delegates to HRTiVodManager.
Args:
content_id: Opaque node identifier (see HRTiVodManager docstring)
cursor: Pagination cursor
page_size: Items per page
Returns:
Dict with ``entries``, ``next_cursor``, ``total`` keys.
"""
result = self._vod_manager.get_category(content_id, cursor, page_size)
if isinstance(result, dict):
return result
if isinstance(result, list):
return {"entries": result, "next_cursor": None, "total": None}
return {"entries": [], "next_cursor": None, "total": None}
# ============================================================================
# Manifest
# ============================================================================
def get_manifest(self, content_id: str, **kwargs) -> Optional[str]:
"""
Return the playback manifest URL for *content_id*.
Routing:
- Navigation nodes (catalogue:/series:/season:/special:) → error, not playable
- details_{ref} or bare non-numeric id → VOD
- bare numeric id → live channel
"""
if self._is_nav_node(content_id):
logger.error(
f"get_manifest called with navigation node '{content_id}' — "
f"this is a category/series/season container, not a playable item"
)
return None
# Strip "details_" prefix before passing to VOD manager
vod_ref = content_id[len(_DETAILS_PREFIX):] if content_id.startswith(_DETAILS_PREFIX) else content_id
if self._is_playable_vod(content_id):
return self._vod_manager.get_vod_streaming_url(vod_ref)
return self._get_live_manifest(content_id, **kwargs)
def _get_live_manifest(self, content_id: str, **kwargs) -> Optional[str]:
"""Authorize a session and return the streaming URL for a live channel."""
try:
session_data = self._authorize_live_session(content_id)
if not session_data:
return None
channel = self._find_channel(content_id)
if channel and channel.manifest_script:
return channel.manifest_script
logger.warning(f"No streaming URL found for channel {content_id}")
return None
except Exception as e:
logger.error(f"Error getting manifest for channel {content_id}: {e}")
return None
# ============================================================================
# DRM
# ============================================================================
def get_drm(self, content_id: str, **kwargs) -> List[DRMConfig]:
"""
Return Widevine DRM config(s) for *content_id*.
Routing mirrors get_manifest() — nav nodes are rejected, playable VOD
goes to _get_vod_drm, live channels go to _get_live_drm.
"""
if self._is_nav_node(content_id):
logger.error(
f"get_drm called with navigation node '{content_id}' — "
f"this is a category/series/season container, not a playable item"
)
return []
vod_ref = content_id[len(_DETAILS_PREFIX):] if content_id.startswith(_DETAILS_PREFIX) else content_id
if self._is_playable_vod(content_id):
return self._get_vod_drm(vod_ref, **kwargs)
return self._get_live_drm(content_id, **kwargs)
def _get_live_drm(self, content_id: str, **kwargs) -> List[DRMConfig]:
"""Get Widevine DRM config for a live channel."""
try:
# Prefer session data passed explicitly by the caller, then the
# cache populated by _get_live_manifest, then authorize fresh.
session_data = (
kwargs.get("session_data")
or self._session_cache.get(content_id)
or self._authorize_live_session(content_id)
)
if not session_data:
return []
return self._build_widevine_drm_config(
session_data=session_data,
content_id=content_id,
referer_path="/",
)
except Exception as e:
logger.error(f"Error getting DRM config for channel {content_id}: {e}")
logger.error(traceback.format_exc())
return []
def _get_vod_drm(self, content_id: str, **kwargs) -> List[DRMConfig]:
"""Get Widevine DRM config for a VOD item."""
try:
session_data = self._vod_manager.get_vod_session_data(content_id)
if not session_data or not session_data.get("Authorized", False):
logger.error(f"Failed to authorize session for VOD DRM: {content_id}")
return []
return self._build_widevine_drm_config(
session_data=session_data,
content_id=content_id,
referer_path="/videostore",
)
except Exception as e:
logger.error(f"Error getting DRM for VOD {content_id}: {e}")
logger.error(traceback.format_exc())
return []
# ============================================================================
# EPG and ancillary methods
# ============================================================================
def get_epg(self, channel_id: str, **kwargs) -> List[Dict]:
"""Get EPG data for a channel."""
try:
headers = self._get_hrti_authenticated_headers()
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})/",
}
programme_endpoint = self.hrti_config.api_endpoints.get("programme", "")
if not programme_endpoint:
logger.error("No programme endpoint configured")
return []
response = self.http_manager.post(
programme_endpoint,
operation="api",
headers=headers,
data=json.dumps(payload),
)
response.raise_for_status()
epg_data = response.json()
if "Result" in epg_data:
return [
{
"title": entry.get("Title", ""),
"description": entry.get("Description", ""),
"start": entry.get("StartTime", ""),
"end": entry.get("EndTime", ""),
"genre": entry.get("Genre", ""),
}
for entry in epg_data["Result"]
]
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]:
"""Return the Widevine license server URL for *channel*."""
drm_configs = self.get_drm(channel.channel_id, **kwargs)
if drm_configs:
return drm_configs[0].license.server_url
return None
@property
def catchup_window(self) -> int:
"""HRTi does not support live-channel catchup."""
return 0
def get_epg_xmltv(self, **kwargs) -> Optional[str]:
"""HRTi does not provide XMLTV natively."""
return None
def get_dynamic_manifest_params(self, channel: StreamingChannel, **kwargs) -> Optional[str]:
"""Session authorization is handled inside get_manifest(); nothing extra needed here."""
return None
# _build_epg(): step 2 (HRTiEpgManager, native batch GetProgramme).
@@ -0,0 +1,193 @@
# streaming_providers/providers/hrti/session.py
"""
HRTiSession -- VARIANT B auth adapter in front of the stateful HRTiAuthenticator.
Managers see ``auth`` (= this session) satisfying AuthProtocol
(get_access_token / build_headers / invalidate) plus the auth-derived state
they need (device_id, ip_address, user_id) and the two session calls of the
playback flow (authorize_session, report_session_event). They never touch the
raw authenticator. The provider keeps BOTH objects (``self.authenticator``
for the legacy auth mixin, ``self.auth`` for the managers).
Behaviour
---------
* LAZY: nothing here makes a network call at construction.
* ``get_access_token`` / ``require`` raise typed errors; ``ensure`` never
raises (opportunistic pre-login, settings UI); ``last_error`` keeps the
ORIGINAL exception.
* Retry policy: PERMANENT failures (AuthError incl. CredentialsError,
ConfigurationError) are not retried until ``reset()`` / ``invalidate()``;
TRANSIENT ones (network, 5xx, rate limit, anything untyped -> ServerError)
back off exponentially, base * 2**(n-1) s, capped. Injectable clock.
* The anonymous fallback of the authenticator is deliberate and unchanged:
rejected USER credentials degrade to an anonymous token (they do not raise).
* ``invalidate()`` clears memory AND persisted storage (authenticator) and
runs the registered callbacks (the provider clears its caches there).
``refresh()`` is the 401 recovery: invalidate + one forced fresh login.
"""
import threading
import time
from typing import Any, Callable, Dict, List, Optional
from ...base.errors import AuthError, ConfigurationError, ProviderError, ServerError
from ...base.utils.logger import logger
from .constants import HRTiConfig, HRTiDefaults
_PERMANENT = (AuthError, ConfigurationError)
class HRTiSession:
def __init__(
self,
*,
authenticator: Any,
config: HRTiConfig,
clock: Callable[[], float] = time.monotonic,
backoff_base: float = HRTiDefaults.SESSION_BACKOFF_BASE,
backoff_cap: float = HRTiDefaults.SESSION_BACKOFF_CAP,
):
self._authenticator = authenticator
self._config = config
self._clock = clock
self._backoff_base = backoff_base
self._backoff_cap = backoff_cap
self._lock = threading.RLock()
self._callbacks: List[Callable[[], None]] = []
self._last_error: Optional[BaseException] = None
self._typed_error: Optional[ProviderError] = None
self._permanent = False
self._failures = 0
self._retry_at = 0.0
# ------------------------------------------------------------------
# AuthProtocol
# ------------------------------------------------------------------
def get_access_token(self, force_refresh: bool = False) -> str:
with self._lock:
self._raise_if_blocked()
try:
token = self._authenticator.get_bearer_token(force_refresh=force_refresh)
except Exception as exc:
typed = self._record_failure(exc)
if typed is exc:
raise
raise typed from exc
if not token:
error = AuthError("HRTi: login returned an empty access token")
raise self._record_failure(error) from None
self._clear_failure()
return token
def build_headers(self) -> Dict[str, str]:
"""API headers incl. the token (not for CDN requests)."""
return self._config.api_headers(
token=self.get_access_token(),
device_id=self.device_id,
ip_address=self.ip_address,
)
def invalidate(self) -> None:
"""Drop the session (memory + persisted) and everything derived from it."""
with self._lock:
self._authenticator.invalidate_token()
self._clear_failure()
for callback in list(self._callbacks):
try:
callback()
except Exception as exc: # a cache callback must not break the recovery
logger.warning(f"HRTi: on_invalidate callback failed: {exc!r}")
# ------------------------------------------------------------------
# Session API for the UI and the 401 recovery
# ------------------------------------------------------------------
def refresh(self) -> str:
"""401 recovery: invalidate, then one forced fresh login."""
self.invalidate()
return self.get_access_token(force_refresh=True)
def require(self) -> str:
return self.get_access_token()
def ensure(self) -> bool:
try:
self.get_access_token()
return True
except Exception:
return False
def reset(self) -> None:
"""Forget failures (credentials changed): the next call logs in again."""
with self._lock:
self._clear_failure()
def on_invalidate(self, callback: Callable[[], None]) -> None:
self._callbacks.append(callback)
@property
def last_error(self) -> Optional[BaseException]:
return self._last_error
# ------------------------------------------------------------------
# Auth-derived state read by the managers
# ------------------------------------------------------------------
@property
def device_id(self) -> str:
return self._authenticator.get_device_id()
@property
def ip_address(self) -> str:
return self._authenticator.get_ip_address()
@property
def user_id(self) -> str:
return self._authenticator.get_user_id() or ""
def has_user_credentials(self) -> bool:
return self._authenticator.has_user_credentials()
# ------------------------------------------------------------------
# Playback-session calls (authenticator owns the protocol details)
# ------------------------------------------------------------------
def authorize_session(self, **kw: Any) -> Dict[str, Any]:
self.get_access_token() # typed login errors, backoff, lazy login
try:
return self._authenticator.authorize_session(**kw)
except AuthError: # stale token: one refresh, one retry
self.refresh()
return self._authenticator.authorize_session(**kw)
def report_session_event(self, session_id: str, channel_id: Optional[str] = None) -> bool:
return self._authenticator.report_session_event(session_id, channel_id)
# ------------------------------------------------------------------
# Failure bookkeeping
# ------------------------------------------------------------------
def _raise_if_blocked(self) -> None:
if self._typed_error is None:
return
if self._permanent or self._clock() < self._retry_at:
raise self._typed_error
def _record_failure(self, exc: BaseException) -> ProviderError:
typed = exc if isinstance(exc, ProviderError) else ServerError(f"HRTi login failed: {exc}")
self._last_error = exc
self._typed_error = typed
if isinstance(typed, _PERMANENT):
self._permanent = True
else:
self._failures += 1
delay = min(self._backoff_cap, self._backoff_base * (2 ** (self._failures - 1)))
self._retry_at = self._clock() + delay
logger.warning(
f"HRTi: login failed ({type(typed).__name__}, "
f"{'permanent' if self._permanent else f'retry in {self._retry_at - self._clock():.0f}s'}): {typed}"
)
return typed
def _clear_failure(self) -> None:
self._last_error = None
self._typed_error = None
self._permanent = False
self._failures = 0
self._retry_at = 0.0
@@ -1,327 +1,181 @@
# streaming_providers/providers/hrti/vod_manager.py
"""
HRTi VOD Manager - Handles all VOD catalogue operations.
HRTi VOD manager -- catalogue navigation and VOD playback.
Endpoints actually available (confirmed from API):
GetCatalogueStructure → full nested tree (Children arrays), root nav
GetCatalogue → paginated items for a catalogue node
GetSeries → episodes for a series season
GetVodDetails → full metadata + FileName for a single item
GetEditorsChoice → editor's picks
GetWatchLater → user watch later (requires user auth)
Endpoints (confirmed from the API): GetCatalogueStructure (root tree),
GetCatalogue (paged items), GetSeries (episodes of a season), GetVodDetails
(metadata + FileName), GetEditorsChoice, GetWatchLater (user auth).
There is NO GetSeasons / GetEpisodes.
NOT available: GetSeasons, GetEpisodes — these do not exist on this API.
content_id grammar (see ids.py):
"" root -> GetCatalogueStructure
"catalogue_<ref>" folder items -> GetCatalogue (paged)
"series_<sid>--<ssid>" episodes -> GetSeries
"special_watch_later" / "special_editors_choice"
"details_<ref>" | "<ref>" one playable item (VodManager.handles_content_id)
Navigation model
----------------
GetCatalogueStructure returns the full tree with Children nesting.
Every navigable node at every level uses ``catalogue:{ReferenceId}``.
There is no separate seasons API — seasons appear as Children of a
series node inside GetCatalogueStructure and are therefore already
handled by the catalogue_ routing.
Return conventions
------------------
Navigation always returns a VodPage. next_cursor (the next page number) is set
ONLY for GetCatalogue, which honours PageNumber/ItemsPerPage; every other
endpoint returns the full list (next_cursor None). Failures raise typed
errors; GetWatchLater / GetEditorsChoice are optional lists: an API error
there yields an empty page.
Type-3 series items in GetCatalogue listings link to GetSeries via:
content_id = "series_{SeriesReferenceId}--{SeasonReferenceId}"
The default SeasonReferenceId is "{SeriesReferenceId}_S01".
Content ID scheme
-----------------
"" root (GetCatalogueStructure)
"catalogue_{ref_id}" items (GetCatalogue, paginated)
"series_{series_ref}--{season_ref}" episodes (GetSeries)
"special_watch_later" GetWatchLater
"special_editors_choice" GetEditorsChoice
Field mapping (verified from real API responses)
-------------------------------------------------
GetCatalogueStructure nodes:
Name, ReferenceId, Children (list), PosterLandscape, ParentReferenceId
GetCatalogue items:
Type 2 → playable VOD (VodData.Duration ms, VodData.ProductionYear,
VodData.ContentRating)
Type 3 → series (SeriesData.SeriesReferenceId, SeriesData.SeriesName,
SeriesData.LastSeasonNumber)
Type 5 → episode (EpisodeData.EpisodeNr, EpisodeData.SeasonNr,
EpisodeData.Duration ms, EpisodeData.ContentRating)
other → navigable folder
GetVodDetails:
ReferenceID (capital), Title, FileName (streaming URL — NOT StreamingURL),
DurationInFrames (frames / 25 ≈ seconds), ProductionYear, ContentRating,
AssetCategory, Directors, Actors (plain strings, not lists),
SeriesReferenceId, SeriesName, SeasonNr, EpisodeNr,
SVODVideostores (list → "svod" auth when non-empty),
PosterLandscape, PosterPortrait
AuthorizeSession parameters derived from FileName:
FileName = https://cdn1oiv.akamaized.net/hrtvodorigin/REF.smil/manifest.mpd
ContentReferenceId = path[1] without ".smil" e.g. "1870699590_delin"
ContentDrmId = "{path[0]}_{path[1]}" e.g. "hrtvodorigin_1870699590_delin.smil"
ContentType = "svod" if SVODVideostores else "vod"
VideostoreReferenceIds = SVODVideostores or null
Token: _current_token.access_token (no get_access_token() method exists).
Pagination: NumberOfItems field (not TotalCount); no HasMore field.
Manifest and DRM share one GetVodDetails through the provider-owned playout
cache (5 s TTL); the AuthorizeSession of the DRM call is cached the same way.
"""
import json
from typing import Dict, List, Optional, Union
from urllib.parse import urlparse
import time
from typing import Any, Dict, List, Optional, Tuple, Union
from ...base.errors import BadRequestError, EntitlementError, NotFoundError
from ...base.managers import VodManager
from ...base.models import DRMConfig
from ...base.models.vod import VodCategory, VodItem
from ...base.utils.logger import logger
from ...base.vod import VodPage
from . import ids
from .api import api_post
from .constants import HRTiDefaults
from .drm import build_widevine, derive_vod_session_params
from .models import VodDetails
# GetCatalogue / GetSeries Type discriminator values
# GetCatalogue / GetSeries "Type" discriminator values
_TYPE_VOD = 2
_TYPE_SERIES = 3
_TYPE_EPISODE = 5
# DurationInFrames in GetVodDetails is at ~25 fps
# DurationInFrames in GetVodDetails is ~25 fps
_ASSUMED_FPS = 25
_VOD_REFERER = "/videostore"
_PROVIDER = HRTiDefaults.PROVIDER_NAME
_PARSE_ERRORS = (KeyError, TypeError, AttributeError, ValueError)
class HRTiVodManager:
"""Manages HRTi VOD catalogue operations."""
def __init__(self, provider):
self.provider = provider
self._config = provider.hrti_config
self._http_manager = provider.http_manager
self._authenticator = provider.authenticator
class HRTiVodManager(VodManager):
def __init__(
self,
*,
http_manager: Any,
auth: Any,
country: str,
config: Any,
playout_cache: Optional[Dict[str, Tuple[Any, float]]] = None,
):
super().__init__(
http_manager=http_manager, auth=auth, country=country, config=config
)
# Provider-owned dict, borrowed by reference. Keys: "vod:<ref>" ->
# (VodDetails, ts) and "vodsess:<ref>" -> (session dict, ts).
self._playout_cache = playout_cache if playout_cache is not None else {}
# -------------------------------------------------------------------------
# Public API (called by provider.get_vod_category)
# -------------------------------------------------------------------------
# ------------------------------------------------------------------
# Routing
# ------------------------------------------------------------------
def handles_content_id(self, content_id: str) -> bool:
return ids.is_vod(content_id)
def get_category(
# ------------------------------------------------------------------
# Navigation
# ------------------------------------------------------------------
def get_vod_category(
self,
content_id: str = "",
cursor: Optional[str] = None,
page_size: int = 24,
) -> Union[List, Dict]:
"""
Return children for a VOD navigation node.
Routing by content_id prefix:
"" → GetCatalogueStructure (root, top-level categories)
"catalogue_{ref_id}" → GetCatalogue items (paginated)
"series_{sid}--{ssid}" → GetSeries episodes
"special_*" → special collections
"""
page_size: int = HRTiDefaults.VOD_ITEMS_PER_PAGE,
**kw: Any,
) -> VodPage:
if not content_id:
return self._fetch_catalogue_structure()
if content_id.startswith("catalogue_"):
ref_id = content_id[len("catalogue_"):]
return self._fetch_catalogue_items(ref_id, cursor, page_size)
return self._fetch_catalogue_items(content_id[len("catalogue_"):], cursor, page_size)
if content_id.startswith("series_"):
# Format: series:{series_ref_id}:{season_ref_id}
parts = content_id[len("series_"):].split("--", 1)
if len(parts) == 2:
return self._fetch_series_episodes(parts[0], parts[1])
logger.warning(f"Malformed series content_id (expected series_SID--SSID): {content_id}")
return []
if content_id == "special_watch_later":
return self._fetch_watch_later()
if content_id == "special_editors_choice":
return self._fetch_editors_choice()
logger.warning(f"Unknown VOD content_id format: {content_id}")
return []
# -------------------------------------------------------------------------
# HTTP helpers
# -------------------------------------------------------------------------
def _get_headers(self, referer_path: str = "/videostore") -> Dict[str, str]:
"""Build authenticated headers for VOD API calls."""
headers = {
"deviceid": self._authenticator.get_device_id(),
"devicetypeid": self._config.device_reference_id,
"ipaddress": self._authenticator.get_ip_address(),
"operatorreferenceid": self._config.operator_reference_id,
"origin": self._config.base_website,
"referer": f"{self._config.base_website}{referer_path}",
"User-Agent": self._config.user_agent,
"Accept": "application/json",
"Content-Type": "application/json",
}
token = (
self._authenticator._current_token.access_token
if self._authenticator._current_token
else ""
)
if token:
headers["authorization"] = f"Client {token}"
return headers
def _post(
self,
endpoint_key: str,
payload: Dict,
referer_path: str = "/videostore",
) -> Optional[Union[Dict, List]]:
"""
POST to a named endpoint and return the Result field.
Returns None on HTTP error, API error (ErrorCode != 0), or missing Result.
Result type varies by endpoint:
GetCatalogueStructure → list
GetCatalogue → dict with Items, NumberOfItems, Name
GetSeries → dict with Items, LastWatchedEpisodeReferenceId
GetVodDetails → dict (single item)
"""
url = self._config.api_endpoints.get(endpoint_key)
if not url:
logger.error(f"Unknown endpoint key: {endpoint_key}")
return None
try:
resp = self._http_manager.post(
url,
operation="api",
headers=self._get_headers(referer_path),
data=json.dumps(payload),
)
resp.raise_for_status()
data = resp.json()
if data.get("ErrorCode", 0) != 0:
logger.warning(
f"HRTi API error for {endpoint_key}: "
f"[{data.get('ErrorCode')}] {data.get('ErrorDescription')}"
if len(parts) != 2 or not all(parts):
raise BadRequestError(
f"HRTi: malformed series id (expected series_SID--SSID): {content_id}"
)
return None
return data.get("Result")
except Exception as e:
logger.error(f"Error calling {endpoint_key}: {e}")
return None
return self._fetch_series_episodes(parts[0], parts[1])
if content_id == "special_watch_later":
return self._fetch_special("watch_later", "/watch_later")
if content_id == "special_editors_choice":
return self._fetch_special("editors_choice", "/editors_choice")
raise BadRequestError(f"HRTi: unknown VOD content_id format: {content_id}")
# -------------------------------------------------------------------------
# GetCatalogueStructure — root navigation tree
# -------------------------------------------------------------------------
def _post(self, endpoint_key: str, payload: Dict, referer_path: str = _VOD_REFERER,
required: bool = True) -> Optional[Union[Dict, List]]:
return api_post(
http_manager=self.http_manager,
session=self.auth,
config=self.config,
endpoint_key=endpoint_key,
payload=payload,
referer_path=referer_path,
required=required,
)
def _fetch_catalogue_structure(self) -> List[VodCategory]:
"""
Fetch top-level navigation categories from GetCatalogueStructure.
The API returns a flat list of root nodes, each with a Children array
containing the full subtree. We parse only the top level here —
children are lazy-loaded when the user navigates via their
catalogue:{ReferenceId} content_id.
child_count is set from len(Children) since the API provides no
separate count field. A non-zero child_count signals the UI that
this node is drillable.
"""
result = self._post("catalogue_structure", {}, "/videostore")
if not result or not isinstance(result, list):
logger.warning("No catalogue structure returned from HRTi")
return []
categories = []
for node in result:
cat = self._parse_structure_node(node)
if cat:
categories.append(cat)
logger.info(f"Retrieved {len(categories)} root VOD categories from HRTi")
return categories
# --- GetCatalogueStructure: root tree -------------------------------
def _fetch_catalogue_structure(self) -> VodPage:
result = self._post("catalogue_structure", {})
if not isinstance(result, list):
logger.warning("HRTi: no catalogue structure returned")
return VodPage()
categories = [c for c in (self._parse_structure_node(n) for n in result) if c]
logger.info(f"HRTi: {len(categories)} root VOD categories")
return VodPage(entries=categories)
def _parse_structure_node(self, node: Dict) -> Optional[VodCategory]:
"""
Parse one node from GetCatalogueStructure.
Fields: Name, ReferenceId, Children (list), PosterLandscape,
ParentReferenceId (ignored — content_id encodes the path).
"""
try:
ref_id = node.get("ReferenceId")
name = (node.get("Name") or "").strip()
if not ref_id or not name:
logger.debug(f"Skipping structure node — missing id or name")
return None
children = node.get("Children") or []
return VodCategory(
name=name,
content_id=f"catalogue_{ref_id}",
provider=self.provider.provider_name,
provider=_PROVIDER,
logo_url=node.get("PosterLandscape"),
description=None,
# child_count from Children length; None if empty (may still have
# items via GetCatalogue even with no sub-categories)
child_count=len(children) if children else None,
)
except Exception as e:
logger.error(f"Error parsing structure node: {e}")
except _PARSE_ERRORS as exc:
logger.warning(f"HRTi: skipping malformed structure node: {exc!r}")
return None
# -------------------------------------------------------------------------
# GetCatalogue — paginated items for a catalogue node
# -------------------------------------------------------------------------
def _fetch_catalogue_items(
self,
ref_id: str,
cursor: Optional[str] = None,
page_size: int = 24,
) -> Dict:
"""
Fetch paginated items from GetCatalogue for a given ReferenceId.
Response: { "Name": "...", "NumberOfItems": N, "Items": [...] }
Pagination is derived from NumberOfItems — no HasMore field exists.
"""
# --- GetCatalogue: paged items ---------------------------------------
def _fetch_catalogue_items(self, ref_id: str, cursor: Optional[str], page_size: int) -> VodPage:
page = 1
if cursor:
try:
page = max(1, int(cursor))
except ValueError:
logger.warning(f"Invalid cursor '{cursor}', using page 1")
logger.warning(f"HRTi: invalid cursor '{cursor}', using page 1")
if page > HRTiDefaults.MAX_VOD_PAGES:
return {"entries": [], "next_cursor": None, "total": None}
return VodPage()
result = self._post(
"catalogue",
{"ReferenceId": ref_id, "ItemsPerPage": page_size, "PageNumber": page},
"catalogue", {"ReferenceId": ref_id, "ItemsPerPage": page_size, "PageNumber": page}
)
if not isinstance(result, dict):
return VodPage()
if not result or not isinstance(result, dict):
return {"entries": [], "next_cursor": None, "total": None}
raw_items = result.get("Items") or []
total_count = result.get("NumberOfItems")
entries = []
for item in raw_items:
parsed = self._parse_catalogue_item(item)
if parsed:
entries.append(parsed)
entries = [e for e in (self._parse_catalogue_item(i) for i in result.get("Items") or []) if e]
total = result.get("NumberOfItems")
has_more = (
total_count is not None
and page * page_size < total_count
total is not None
and page * page_size < total
and page < HRTiDefaults.MAX_VOD_PAGES
)
logger.debug(
f"catalogue {ref_id} page {page}: {len(entries)} entries, "
f"total {total_count}, next_cursor {page + 1 if has_more else None}"
return VodPage(
entries=entries,
next_cursor=str(page + 1) if has_more else None,
total=total,
)
return {
"entries": entries,
"next_cursor": str(page + 1) if has_more else None,
"total": total_count,
}
def _parse_catalogue_item(self, item: Dict) -> Optional[Union[VodCategory, VodItem]]:
"""Dispatch catalogue item parsing by Type field."""
try:
item_type = item.get("Type")
if item_type == _TYPE_VOD:
@@ -331,12 +185,12 @@ class HRTiVodManager:
if item_type == _TYPE_EPISODE:
return self._parse_episode_catalogue_item(item)
return self._parse_folder_catalogue_item(item)
except Exception as e:
logger.error(f"Error parsing catalogue item: {e}")
except _PARSE_ERRORS as exc:
logger.warning(f"HRTi: skipping malformed catalogue item: {exc!r}")
return None
def _parse_folder_catalogue_item(self, item: Dict) -> Optional[VodCategory]:
"""No Type — navigable sub-folder node."""
"""No Type: a navigable sub-folder."""
ref_id = item.get("ReferenceId")
name = (item.get("Title") or item.get("Name") or "").strip()
if not ref_id or not name:
@@ -344,67 +198,48 @@ class HRTiVodManager:
return VodCategory(
name=name,
content_id=f"catalogue_{ref_id}",
provider=self.provider.provider_name,
provider=_PROVIDER,
logo_url=item.get("PosterLandscape") or item.get("PosterPortrait"),
description=None,
child_count=item.get("ChildCount"),
)
def _parse_series_catalogue_item(self, item: Dict) -> Optional[VodCategory]:
"""
Type-3 series item.
Navigation goes to GetSeries which requires SeriesReferenceId and
SeasonReferenceId. We default to the _S01 suffix since there is no
GetSeasons endpoint. Multi-season series have their seasons listed as
Children in GetCatalogueStructure, so they are browsed via catalogue:
nodes and never reach this path.
"""
"""Type 3: navigation goes to GetSeries (needs series + season ids);
there is no GetSeasons, so the default season is ``<series>_S01``."""
series_data = item.get("SeriesData") or {}
series_ref_id = series_data.get("SeriesReferenceId") or item.get("ReferenceId")
name = (item.get("Title") or "").strip()
if not series_ref_id or not name:
return None
default_season_ref = f"{series_ref_id}_S01"
season_count = series_data.get("LastSeasonNumber")
return VodCategory(
name=name,
content_id=f"series_{series_ref_id}--{default_season_ref}",
provider=self.provider.provider_name,
content_id=f"series_{series_ref_id}--{series_ref_id}_S01",
provider=_PROVIDER,
logo_url=item.get("PosterLandscape") or item.get("PosterPortrait"),
description=None,
child_count=season_count,
child_count=series_data.get("LastSeasonNumber"),
)
def _parse_vod_catalogue_item(self, item: Dict) -> Optional[VodItem]:
"""
Type-2 playable item from a GetCatalogue listing.
manifest_script is None — FileName only available via GetVodDetails.
content_id is the raw ReferenceId (no prefix) so provider routing works.
"""
"""Type 2: playable item. manifest_script stays None (FileName only
comes from GetVodDetails); content_id is the raw ReferenceId."""
ref_id = item.get("ReferenceId")
title = (item.get("Title") or "").strip()
if not ref_id or not title:
return None
vod_data = item.get("VodData") or {}
duration_ms = vod_data.get("Duration")
duration_sec = int(duration_ms / 1000) if duration_ms else None
category_str = item.get("VodCategoryNames") or ""
genres = [g.strip() for g in category_str.split(",") if g.strip()] or None
genres = [g.strip() for g in (item.get("VodCategoryNames") or "").split(",") if g.strip()] or None
return VodItem(
name=title,
content_id=ref_id,
provider=self.provider.provider_name,
provider=_PROVIDER,
logo_url=item.get("PosterLandscape") or item.get("PosterPortrait"),
description=None,
long_description=None,
original_title=None,
duration_seconds=duration_sec,
duration_seconds=int(duration_ms / 1000) if duration_ms else None,
release_year=vod_data.get("ProductionYear"),
rating=vod_data.get("ContentRating"),
genre=genres[0] if genres else None,
@@ -422,34 +257,24 @@ class HRTiVodManager:
)
def _parse_episode_catalogue_item(self, item: Dict) -> Optional[VodItem]:
"""
Type-5 episode item from GetCatalogue or GetSeries.
EpisodeData sub-object: EpisodeNr, SeasonNr, Duration (ms), ContentRating.
SeriesData sub-object: SeriesReferenceId, SeriesName.
content_id is the raw ReferenceId.
"""
"""Type 5: episode (EpisodeData / SeriesData sub-objects)."""
ref_id = item.get("ReferenceId")
title = (item.get("Title") or "").strip()
if not ref_id or not title:
return None
ep_data = item.get("EpisodeData") or {}
series_data = item.get("SeriesData") or {}
duration_ms = ep_data.get("Duration")
duration_sec = int(duration_ms / 1000) if duration_ms else None
category_str = item.get("VodCategoryNames") or ""
genres = [g.strip() for g in category_str.split(",") if g.strip()] or None
genres = [g.strip() for g in (item.get("VodCategoryNames") or "").split(",") if g.strip()] or None
return VodItem(
name=title,
content_id=ref_id,
provider=self.provider.provider_name,
provider=_PROVIDER,
logo_url=item.get("PosterLandscape") or item.get("PosterPortrait"),
description=None,
long_description=None,
original_title=None,
duration_seconds=duration_sec,
duration_seconds=int(duration_ms / 1000) if duration_ms else None,
release_year=None,
rating=ep_data.get("ContentRating"),
genre=genres[0] if genres else None,
@@ -466,109 +291,57 @@ class HRTiVodManager:
manifest_script=None,
)
# -------------------------------------------------------------------------
# GetSeries — episodes for a specific series season
# -------------------------------------------------------------------------
def _fetch_series_episodes(
self,
series_ref_id: str,
season_ref_id: str,
) -> Dict:
"""
Fetch all episodes from GetSeries for a given series + season.
Payload: SeriesReferenceId, SeasonReferenceId, PageSize, PageNumber.
Response: { "Items": [...], "LastWatchedEpisodeReferenceId": ..., ... }
Each item uses Type 5 / EpisodeData schema.
"""
# --- GetSeries: episodes of one season --------------------------------
def _fetch_series_episodes(self, series_ref_id: str, season_ref_id: str) -> VodPage:
result = self._post(
"episodes",
{
"SeriesReferenceId": series_ref_id,
"SeasonReferenceId": season_ref_id,
"PageSize": 100,
"PageSize": HRTiDefaults.SERIES_PAGE_SIZE,
"PageNumber": 1,
},
)
if not isinstance(result, dict):
logger.warning(f"HRTi: no episodes for series {series_ref_id} season {season_ref_id}")
return VodPage()
episodes = [e for e in (self._parse_episode_catalogue_item(i) for i in result.get("Items") or []) if e]
return VodPage(entries=episodes, total=len(episodes))
if not result or not isinstance(result, dict):
logger.warning(
f"No episodes returned for series {series_ref_id} "
f"season {season_ref_id}"
)
return {"entries": [], "next_cursor": None, "total": None}
raw_items = result.get("Items") or []
episodes = []
for ep in raw_items:
parsed = self._parse_episode_catalogue_item(ep)
if parsed:
episodes.append(parsed)
logger.info(
f"Retrieved {len(episodes)} episodes for series {series_ref_id} "
f"season {season_ref_id}"
)
return {"entries": episodes, "next_cursor": None, "total": len(episodes)}
# -------------------------------------------------------------------------
# GetVodDetails — full metadata for one item
# -------------------------------------------------------------------------
def _fetch_vod_details(self, ref_id: str) -> Optional[VodItem]:
"""Fetch full metadata for a single VOD item from GetVodDetails."""
result = self._post("vod_details", {"ReferenceId": ref_id})
if not result:
logger.warning(f"No details returned for VOD item {ref_id}")
return None
return self._parse_vod_details_item(result)
# --- Special lists (optional endpoints) --------------------------------
def _fetch_special(self, endpoint_key: str, referer_path: str) -> VodPage:
result = self._post(endpoint_key, {}, referer_path, required=False)
if not isinstance(result, list):
return VodPage()
items = [i for i in (self._parse_vod_details_item(r) for r in result) if i]
return VodPage(entries=items)
def _parse_vod_details_item(self, item: Dict) -> Optional[VodItem]:
"""
Parse a rich VOD item from GetVodDetails.
Key differences from catalogue listing schema:
- ReferenceID (capital ID, not ReferenceId)
- FileName (streaming URL, not StreamingURL)
- DurationInFrames (divide by 25 for seconds)
- Directors / Actors are plain strings, not lists
- SVODVideostores list controls auth type
"""
"""Rich item as returned by GetVodDetails / the special lists: ReferenceID
(capital ID), FileName (the stream URL), DurationInFrames (/25 = s),
Directors / Actors are plain strings."""
try:
ref_id = item.get("ReferenceID") or item.get("ReferenceId")
title = (item.get("Title") or "").strip()
if not ref_id or not title:
return None
frames = item.get("DurationInFrames")
duration_sec = int(frames / _ASSUMED_FPS) if frames else None
actors_str = item.get("Actors")
cast = (
[a.strip() for a in actors_str.split(",") if a.strip()]
if actors_str else None
)
item_type_str = (item.get("Type") or "").lower()
content_type = "SERIES" if item_type_str == "episode" else "MOVIE"
actors = item.get("Actors")
genre = item.get("AssetCategory") or None
svod_videostores = item.get("SVODVideostores") or []
vod_item = VodItem(
return VodItem(
name=title,
content_id=ref_id,
provider=self.provider.provider_name,
provider=_PROVIDER,
logo_url=item.get("PosterLandscape") or item.get("PosterPortrait"),
description=item.get("Description"),
long_description=None,
original_title=item.get("OriginalTitle"),
duration_seconds=duration_sec,
duration_seconds=int(frames / _ASSUMED_FPS) if frames else None,
release_year=item.get("ProductionYear"),
rating=item.get("ContentRating"),
genre=genre,
genres=[genre] if genre else None,
cast=cast,
cast=[a.strip() for a in actors.split(",") if a.strip()] if actors else None,
director=item.get("Directors") or None,
season_number=item.get("SeasonNr"),
episode_number=item.get("EpisodeNr"),
@@ -576,110 +349,72 @@ class HRTiVodManager:
series_title=item.get("SeriesName"),
trailer_url=item.get("TrailerUrl"),
is_highlight=False,
content_type=content_type,
content_type="SERIES" if (item.get("Type") or "").lower() == "episode" else "MOVIE",
manifest_script=item.get("FileName") or None,
)
vod_item._svod_videostores = svod_videostores
logger.debug(f"Parsed VOD details: {title} ({ref_id}), svod={bool(svod_videostores)}")
return vod_item
except Exception as e:
logger.error(f"Error parsing VOD details item: {e}")
except _PARSE_ERRORS as exc:
logger.warning(f"HRTi: skipping malformed VOD details item: {exc!r}")
return None
# -------------------------------------------------------------------------
# Special collections
# -------------------------------------------------------------------------
def _fetch_watch_later(self) -> List[VodItem]:
"""User's Watch Later list (requires user auth)."""
result = self._post("watch_later", {}, "/watch_later")
if not result or not isinstance(result, list):
return []
items = [self._parse_vod_details_item(i) for i in result]
return [i for i in items if i]
def _fetch_editors_choice(self) -> List[VodItem]:
"""Editor's Choice list."""
result = self._post("editors_choice", {}, "/editors_choice")
if not result or not isinstance(result, list):
return []
items = [self._parse_vod_details_item(i) for i in result]
return [i for i in items if i]
# -------------------------------------------------------------------------
# Provider-facing helpers (called by provider.py)
# -------------------------------------------------------------------------
def get_vod_streaming_url(self, content_id: str) -> Optional[str]:
"""
Return the DASH manifest URL for a playable VOD item.
Always calls GetVodDetails — FileName is only available there,
never in catalogue listings.
"""
details = self._fetch_vod_details(content_id)
if details and details.manifest_script:
return details.manifest_script
logger.warning(f"No FileName found for VOD item {content_id}")
# ------------------------------------------------------------------
# Playback: details + authorised session, cached per request
# ------------------------------------------------------------------
def _cached(self, key: str) -> Optional[Any]:
entry = self._playout_cache.get(key)
if entry and (time.time() - entry[1]) < HRTiDefaults.PLAYOUT_CACHE_TTL:
return entry[0]
return None
def get_vod_session_data(self, content_id: str) -> Optional[Dict]:
"""
Authorize a VOD playback session and return the session dict.
Derives all AuthorizeSession parameters from the FileName in GetVodDetails:
FileName = https://cdn1oiv.akamaized.net/hrtvodorigin/REF.smil/manifest.mpd
path[0] = "hrtvodorigin" (origin)
path[1] = "REF.smil"
ContentReferenceId = "REF" (stem without .smil)
ContentDrmId = "hrtvodorigin_REF.smil"
ContentType = "svod" if SVODVideostores non-empty else "vod"
VideostoreReferenceIds = SVODVideostores or null
"""
def _details(self, ref_id: str) -> VodDetails:
cached = self._cached(f"vod:{ref_id}")
if cached is not None:
return cached
result = self._post("vod_details", {"ReferenceId": ref_id})
if not isinstance(result, dict) or not result:
raise NotFoundError(f"HRTi: VOD item {ref_id} not found")
try:
details = self._fetch_vod_details(content_id)
if not details or not details.manifest_script:
logger.error(f"Cannot authorize VOD session — no FileName for {content_id}")
return None
details = VodDetails.from_api_response(result)
except (KeyError, TypeError, AttributeError, ValueError) as exc:
raise NotFoundError(f"HRTi: VOD item {ref_id} has no usable details: {exc}") from exc
self._playout_cache[f"vod:{ref_id}"] = (details, time.time())
return details
filename = details.manifest_script
svod_stores = getattr(details, "_svod_videostores", []) or []
def _session(self, ref_id: str) -> Dict[str, Any]:
cached = self._cached(f"vodsess:{ref_id}")
if cached is not None:
return cached
details = self._details(ref_id)
if not details.file_name:
raise EntitlementError(f"HRTi: no FileName (not entitled?) for VOD item {ref_id}")
params = derive_vod_session_params(details.file_name)
stores = details.svod_stores
session_data = self.auth.authorize_session(
content_type="svod" if stores else "vod",
content_ref_id=params.content_ref_id,
content_drm_id=params.content_drm_id,
video_store_ids=stores or None,
channel_id=None,
start_time=None,
end_time=None,
)
if not session_data.get("Authorized", False):
raise EntitlementError(f"HRTi: VOD session for {ref_id} was not authorised")
self._playout_cache[f"vodsess:{ref_id}"] = (session_data, time.time())
return session_data
path_parts = urlparse(filename).path.strip("/").split("/")
if len(path_parts) < 2:
logger.error(f"Cannot parse FileName path for {content_id}: {filename}")
return None
def get_vod_manifest(self, content_id: str, **kw: Any) -> Optional[str]:
details = self._details(ids.vod_ref(content_id))
if not details.file_name:
logger.warning(f"HRTi: no FileName for VOD item {content_id}")
return None
return details.file_name
origin = path_parts[0]
ref_with_ext = path_parts[1]
ref_id = (
ref_with_ext.removesuffix(".smil")
if ref_with_ext.endswith(".smil")
else ref_with_ext
)
content_drm_id = f"{origin}_{ref_with_ext}"
content_type = "svod" if svod_stores else "vod"
video_store_ids = svod_stores if svod_stores else None
logger.debug(
f"Authorizing VOD session — content_id: {content_id}, "
f"ref: {ref_id}, drm: {content_drm_id}, "
f"type: {content_type}, stores: {video_store_ids}"
)
return self._authenticator.authorize_session(
content_type=content_type,
content_ref_id=ref_id,
content_drm_id=content_drm_id,
video_store_ids=video_store_ids,
channel_id=None,
start_time=None,
end_time=None,
)
except Exception as e:
logger.error(f"Error getting VOD session data for {content_id}: {e}")
return None
def get_vod_drm(self, content_id: str, **kw: Any) -> List[DRMConfig]:
ref_id = ids.vod_ref(content_id)
return build_widevine(
self.config,
self.auth.user_id,
self._session(ref_id),
ref_id,
referer_path=_VOD_REFERER,
)