From 6d872891da453666721d8c437171fbba99d08192 Mon Sep 17 00:00:00 2001 From: Nirvana Date: Sat, 7 Mar 2026 15:19:07 +0100 Subject: [PATCH] Add discovery --- .../providers/rtlplus/constants.py | 78 ++++++++++++++++++- .../providers/rtlplus/models.py | 68 ++++++++++++++++ .../providers/rtlplus/provider.py | 76 +++++++++++++++++- 3 files changed, 219 insertions(+), 3 deletions(-) diff --git a/lib/streaming_providers/providers/rtlplus/constants.py b/lib/streaming_providers/providers/rtlplus/constants.py index 5cdb3d8..89874b3 100644 --- a/lib/streaming_providers/providers/rtlplus/constants.py +++ b/lib/streaming_providers/providers/rtlplus/constants.py @@ -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( diff --git a/lib/streaming_providers/providers/rtlplus/models.py b/lib/streaming_providers/providers/rtlplus/models.py index 9cd8c4e..eb757cd 100644 --- a/lib/streaming_providers/providers/rtlplus/models.py +++ b/lib/streaming_providers/providers/rtlplus/models.py @@ -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: diff --git a/lib/streaming_providers/providers/rtlplus/provider.py b/lib/streaming_providers/providers/rtlplus/provider.py index c3b5d00..b622a63 100644 --- a/lib/streaming_providers/providers/rtlplus/provider.py +++ b/lib/streaming_providers/providers/rtlplus/provider.py @@ -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]: """