Add discovery

This commit is contained in:
Nirvana
2026-03-07 15:19:07 +01:00
parent 6b45d04e22
commit 6d872891da
3 changed files with 219 additions and 3 deletions
@@ -47,13 +47,31 @@ class RTLPlusDefaults:
# HTTP settings
DEFAULT_TIMEOUT = 30
# GraphQL query parameters
# GraphQL query parameters — live TV channels
CHANNELS_QUERY_PARAMS = {
"operationName": "LiveTvStations",
"variables": '{"epgCount":4,"filter":{"channelTypes":["BROADCAST","FAST"]}}',
"extensions": '{"persistedQuery":{"version":1,"sha256Hash":"845cf56a2a78110a0f978c1a2af2bc7f9a1c937d0f324ffaf852a9a4414c8485"}}',
}
# GraphQL query parameters — editorial home view (contains LiveEventWidget nodes)
EVENTS_QUERY_PARAMS = {
"operationName": "ExploreWidgetWatch",
"variables": '{"area":"home","offset":0,"take":15}',
"extensions": '{"persistedQuery":{"version":1,"sha256Hash":"724f21ab86aa3f8c57673a3b346cf119f6a155bf82bb0201fd3b18e28e44f1ed"}}',
}
# Custom header carrying the signed profile JWT (required by the events endpoint)
PROFILE_HEADER = "Rtlplus-Profile"
# requiredPermission values returned by the events API
PERMISSION_FREE_TV = "liveeventAccessToFreeTv"
PERMISSION_PAY_TV = "liveeventAccessToPayTv"
# GraphQL __typename values used during response parsing
TYPENAME_LIVE_EVENT_WIDGET = "LiveEventWidget"
TYPENAME_LIVE_EVENT = "LiveEvent"
class RTLPlusHeaders:
"""Standard header configurations for RTL+ requests"""
@@ -110,6 +128,45 @@ class RTLPlusHeaders:
return headers
@staticmethod
def get_events_headers(
access_token: str,
profile_token: str = None,
device_id: str = None,
client_version: str = None,
user_agent: str = None,
) -> dict:
"""
Get headers for the editorial GraphQL endpoint (events / ExploreWidgetWatch).
Extends the standard API headers with the optional ``Rtlplus-Profile``
JWT that the browser sends when a user profile is active. The header
is omitted when *profile_token* is ``None`` so that anonymous / client-
credential sessions work without modification.
Args:
access_token: Bearer token from the auth endpoint.
profile_token: Signed profile JWT (``Rtlplus-Profile`` header).
Pass ``None`` for anonymous / client-credential sessions.
device_id: Device UUID forwarded as ``X-Device-Id``.
client_version: Client version string; falls back to the default.
user_agent: UA string; falls back to the default.
Returns:
dict of HTTP headers ready for use with the GraphQL endpoint.
"""
headers = RTLPlusHeaders.get_api_headers(
access_token=access_token,
device_id=device_id,
client_version=client_version,
user_agent=user_agent,
)
if profile_token:
headers[RTLPlusDefaults.PROFILE_HEADER] = profile_token
return headers
@staticmethod
def get_drm_headers(access_token: str, device_id: str = None, user_agent: str = None) -> dict:
"""Get headers for Widevine/FairPlay DRM license requests"""
@@ -183,6 +240,25 @@ class RTLPlusConfig:
user_agent=self.user_agent,
)
def get_events_headers(self, access_token: str, profile_token: str = None) -> dict:
"""
Get headers for the editorial GraphQL endpoint with this config's settings.
Args:
access_token: Bearer token from the auth endpoint.
profile_token: Optional signed profile JWT (``Rtlplus-Profile``).
Returns:
dict of HTTP headers.
"""
return RTLPlusHeaders.get_events_headers(
access_token=access_token,
profile_token=profile_token,
device_id=self.device_id,
client_version=self.client_version,
user_agent=self.user_agent,
)
def get_drm_headers(self, access_token: str) -> dict:
"""Get DRM headers with this config's settings"""
return RTLPlusHeaders.get_drm_headers(
@@ -4,6 +4,7 @@ from typing import Any, Dict, Optional
from ...base.auth.base_auth import BaseAuthToken
from ...base.auth.credentials import ClientCredentials, UserPasswordCredentials
from ...base.models import Event, ContentType
from .constants import RTLPlusDefaults
@@ -178,6 +179,73 @@ class RTLPlusChannel:
"sort_order": self.sort_order,
}
@dataclass
class RTLPlusLiveEvent:
"""
RTL+ API representation of a live event before conversion to the
domain Event model. Keeps parsing logic isolated from the domain layer.
"""
id: str
title: str
stream_start: str # ISO string from API
stream_end: str # ISO string from API
location: Optional[str]
event_category: str
event_sub_category: str
description: Optional[str]
short_description: Optional[str]
logo_url: Optional[str]
required_permission: str
watch_path: str
@classmethod
def from_api_node(cls, node: Dict[str, Any]) -> "RTLPlusLiveEvent":
"""Parse a single LiveEvent GraphQL node."""
return cls(
id=node["id"],
title=node["title"],
stream_start=node["streamStart"],
stream_end=node["streamEnd"],
location=node.get("location"),
event_category=node.get("eventCategory", "GENERIC"),
event_sub_category=node.get("eventSubCategory", ""),
description=node.get("description"),
short_description=node.get("shortDescription"),
logo_url=(
node.get("liveImages", {})
.get("artworkLandscape", {})
.get("url")
),
required_permission=node.get(
"requiredPermission", RTLPlusDefaults.PERMISSION_PAY_TV
),
watch_path=node.get("urlData", {}).get("watchPath", ""),
)
def to_event(self, provider: str = "rtlplus") -> "Event":
"""Convert to the domain Event model."""
from dateutil.parser import isoparse # already likely a dep; fallback below
start = isoparse(self.stream_start)
end = isoparse(self.stream_end)
# Derive a stable content_id from the RRN (last segment after last colon)
content_id = self.id.split(":")[-1]
return Event(
name=self.title,
content_id=content_id,
provider=provider,
start_time=start,
end_time=end,
logo_url=self.logo_url,
description=self.description,
genre=self.event_sub_category,
content_type=ContentType.LIVE, # always live for RTL+ events
language="de",
country="DE",
venue=self.location,
)
@dataclass
class RTLPlusStreamInfo:
@@ -1,7 +1,7 @@
# lib/streaming_providers/providers/rtlplus/provider.py
import json
import datetime
from typing import ClassVar, Dict, List, Optional
from typing import ClassVar, Dict, List, Optional, Any
import requests
@@ -11,6 +11,7 @@ from ...base.provider import StreamingProvider
from ...base.utils import logger
from .auth import RTLPlusAuthenticator
from .constants import RTLPlusConfig, RTLPlusDefaults
from .models import RTLPlusLiveEvent
class RTLPlusProvider(StreamingProvider):
@@ -164,7 +165,78 @@ class RTLPlusProvider(StreamingProvider):
end_time: Optional[datetime] = None,
**kwargs,
) -> List[Event]:
return []
"""
Fetch upcoming / live RTL+ events from the editorial GraphQL endpoint.
Filters by start_time / end_time if provided (both timezone-aware).
"""
raw_events = self._fetch_live_events()
events: List[Event] = []
for raw in raw_events:
try:
event = raw.to_event(provider=self.provider_name)
except Exception as e:
logger.warning(f"RTL+: Could not convert event '{raw.id}': {e}")
continue
# Optional time-window filtering (delegate to caller semantics)
if start_time and event.end_time and event.end_time < start_time:
continue
if end_time and event.start_time and event.start_time > end_time:
continue
events.append(event)
logger.info(f"RTL+: Fetched {len(events)} events")
return events
def _fetch_live_events(self) -> List[RTLPlusLiveEvent]:
access_token = self.authenticator.get_bearer_token()
headers = self.rtl_config.get_events_headers(access_token=access_token)
try:
response = self.http_manager.get(
self.rtl_config.graphql_endpoint,
params=RTLPlusDefaults.EVENTS_QUERY_PARAMS,
headers=headers,
operation="api",
)
response.raise_for_status()
except Exception as e:
logger.error(f"RTL+: Failed to fetch events: {e}")
return []
return self._parse_live_events(response.json())
@staticmethod
def _parse_live_events(data: Dict[str, Any]) -> List[RTLPlusLiveEvent]:
"""
Extract LiveEvent nodes from the GraphQL response.
Static so it's unit-testable without provider state.
"""
events: List[RTLPlusLiveEvent] = []
widgets = (
data.get("data", {})
.get("editorialView", {})
.get("widgets", {})
.get("items", [])
)
for widget in widgets:
if widget.get("__typename") != "LiveEventWidget":
continue
for element in widget.get("elements", []):
if element is None:
continue
if element.get("__typename") != "LiveEvent":
continue # skip ExternalTeaser etc.
try:
events.append(RTLPlusLiveEvent.from_api_node(element))
except Exception as e:
logger.warning(f"RTL+: Skipping malformed event node: {e}")
return events
def _parse_station_to_channel(self, station: Dict) -> Optional[StreamingChannel]:
"""