Add HRTi EPG

This commit is contained in:
Nirvana
2026-05-05 22:59:17 +02:00
parent 6e98b4e752
commit 926082d3fd
2 changed files with 68 additions and 41 deletions
@@ -3,7 +3,7 @@ import json
import datetime
import traceback
from urllib.parse import urlparse
from typing import ClassVar, Dict, List, Optional, Union, Tuple
from typing import ClassVar, Dict, List, Optional, Union
import requests
@@ -15,14 +15,13 @@ from ..lib_drmtoday import create_drmtoday_widevine_config
from .auth import HRTiAuthenticator
from .constants import HRTiConfig, HRTiDefaults
from .vod_manager import HRTiVodManager
from .epg_manager import HRTiEPGManager
# 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:")
_NAV_PREFIXES = ("catalogue_", "series_", "special_")
# Playable VOD prefix — "details:{ref_id}" is an explicit single-item request.
_DETAILS_PREFIX = "details:"
# Playable VOD prefix — "details_{ref_id}" is an explicit single-item request.
_DETAILS_PREFIX = "details_"
class HRTiProvider(StreamingProvider):
@@ -78,9 +77,6 @@ class HRTiProvider(StreamingProvider):
# Initialize VOD manager
self._vod_manager = HRTiVodManager(self)
# Initialize EPG manager
self._epg_manager = HRTiEPGManager(self)
try:
self.authenticator.get_bearer_token()
logger.debug("HRTi authentication successful during initialization")
@@ -159,7 +155,7 @@ class HRTiProvider(StreamingProvider):
"""
if any(content_id.startswith(p) for p in _NAV_PREFIXES):
return False
# "details:" prefix is an explicit VOD request
# "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.
@@ -421,7 +417,7 @@ class HRTiProvider(StreamingProvider):
Routing:
- Navigation nodes (catalogue:/series:/season:/special:) → error, not playable
- details:{ref} or bare non-numeric id → VOD
- details_{ref} or bare non-numeric id → VOD
- bare numeric id → live channel
"""
if self._is_nav_node(content_id):
@@ -431,7 +427,7 @@ class HRTiProvider(StreamingProvider):
)
return None
# Strip "details:" prefix before passing to VOD manager
# 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):
@@ -531,19 +527,50 @@ class HRTiProvider(StreamingProvider):
# ============================================================================
def get_epg(self, channel_id: str, **kwargs) -> List[Dict]:
"""
Get EPG data for a channel.
"""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)
Returns list of dictionaries for backward compatibility.
"""
entries = self._epg_manager.get_epg_for_channel(channel_id)
# Convert EPGEntry objects to dict for backward compatibility
return [entry.to_dict() for entry in entries]
payload = {
"ChannelReferenceIds": [channel_id],
"StartTime": f"/Date({start_time})/",
"EndTime": f"/Date({end_time})/",
}
@property
def epg_window(self) -> Tuple[int, int]:
"""Return EPG window as (past_days, future_days)."""
return self._epg_manager.get_epg_window()
response = self.http_manager.post(
self.hrti_config.api_endpoints["programme"],
operation="api",
headers=headers,
data=json.dumps(payload),
)
response.raise_for_status()
epg_data = response.json()
if "Result" in epg_data:
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:
@@ -18,19 +18,19 @@ Navigation model
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.
handled by the catalogue_ routing.
Type-3 series items in GetCatalogue listings link to GetSeries via:
content_id = "series:{SeriesReferenceId}:{SeasonReferenceId}"
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
"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)
-------------------------------------------------
@@ -106,29 +106,29 @@ class HRTiVodManager:
Routing by content_id prefix:
"" → GetCatalogueStructure (root, top-level categories)
"catalogue:{ref_id}" → GetCatalogue items (paginated)
"series:{sid}:{ssid}" → GetSeries episodes
"special:*" → special collections
"catalogue_{ref_id}" → GetCatalogue items (paginated)
"series_{sid}--{ssid}" → GetSeries episodes
"special_*" → special collections
"""
if not content_id:
return self._fetch_catalogue_structure()
if content_id.startswith("catalogue:"):
ref_id = content_id[len("catalogue:"):]
if content_id.startswith("catalogue_"):
ref_id = content_id[len("catalogue_"):]
return self._fetch_catalogue_items(ref_id, cursor, page_size)
if content_id.startswith("series:"):
if content_id.startswith("series_"):
# Format: series:{series_ref_id}:{season_ref_id}
parts = content_id[len("series:"):].split(":", 1)
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}")
logger.warning(f"Malformed series content_id (expected series_SID--SSID): {content_id}")
return []
if content_id == "special:watch_later":
if content_id == "special_watch_later":
return self._fetch_watch_later()
if content_id == "special:editors_choice":
if content_id == "special_editors_choice":
return self._fetch_editors_choice()
logger.warning(f"Unknown VOD content_id format: {content_id}")
@@ -249,7 +249,7 @@ class HRTiVodManager:
children = node.get("Children") or []
return VodCategory(
name=name,
content_id=f"catalogue:{ref_id}",
content_id=f"catalogue_{ref_id}",
provider=self.provider.provider_name,
logo_url=node.get("PosterLandscape"),
description=None,
@@ -343,7 +343,7 @@ class HRTiVodManager:
return None
return VodCategory(
name=name,
content_id=f"catalogue:{ref_id}",
content_id=f"catalogue_{ref_id}",
provider=self.provider.provider_name,
logo_url=item.get("PosterLandscape") or item.get("PosterPortrait"),
description=None,
@@ -371,7 +371,7 @@ class HRTiVodManager:
return VodCategory(
name=name,
content_id=f"series:{series_ref_id}:{default_season_ref}",
content_id=f"series_{series_ref_id}--{default_season_ref}",
provider=self.provider.provider_name,
logo_url=item.get("PosterLandscape") or item.get("PosterPortrait"),
description=None,