From f0e13c992d5b8ef36729d4f4b543dbfe31f20377 Mon Sep 17 00:00:00 2001 From: Nirvana Date: Tue, 10 Mar 2026 12:37:57 +0100 Subject: [PATCH] Add discovery --- .../providers/rtlplus/constants.py | 21 +++++- .../providers/rtlplus/models.py | 15 +++-- .../providers/rtlplus/provider.py | 30 +++++---- .../providers/rtlplus/vod_manager.py | 65 +++++++++++++++++-- 4 files changed, 102 insertions(+), 29 deletions(-) diff --git a/lib/streaming_providers/providers/rtlplus/constants.py b/lib/streaming_providers/providers/rtlplus/constants.py index 6837e4c..7bd84b2 100644 --- a/lib/streaming_providers/providers/rtlplus/constants.py +++ b/lib/streaming_providers/providers/rtlplus/constants.py @@ -114,9 +114,20 @@ class RTLPlusDefaults: "romance", "science-fiction", "thriller", ] + # Shows root / genre constants + VOD_SHOWS_ROOT_WATCH_PATH = "/video-tv/shows" + VOD_SHOWS_GENRE_WATCH_PATH = "/video-tv/shows/genre" + VOD_SHOWS_ROOT_ID = "/video-tv/shows" + + VOD_SHOW_GENRE_SLUGS = [ + "action", "comedy", "crime", "dokumentation", + "drama", "kinder", "reality", "romance", "thriller", + ] + # OverviewPage elementType values and pagination VOD_ELEMENT_TYPE_MOVIE = "MOVIE" VOD_ELEMENT_TYPE_SERIES = "SERIES" + VOD_ELEMENT_TYPE_SHOW = "SHOW" VOD_OVERVIEW_PAGE_LIMIT = 48 @@ -139,8 +150,9 @@ class RTLPlusGraphQL: "MRE": "0c77404637570adff548e329a48654498c54ce1c36a459d72586ff18999bebaa", "Episode": "87dbde15a0d269b11606f5ff458d555e98eb493bb4fb6ddc150d812d5e9a9cf8", "WatchPlayerConfigV3":"fea0311fb572b6fded60c5a1a9d652f97f55d182bc4cedbdad676354a8d2797c", - "SeoUrlData": "fcc4a812d6b93496f00c3068234db7722f553032bb760e09e5e6c74586c86f8d", - "OverviewPage": "28aad4e992bb63330bfcd40a6906af3119d8a2612fa9fd28dae9c19127e247ca", + "SeoUrlData": "fcc4a812d6b93496f00c3068234db7722f553032bb760e09e5e6c74586c86f8d", + "OverviewPage": "28aad4e992bb63330bfcd40a6906af3119d8a2612fa9fd28dae9c19127e247ca", + "LiveEventsOverview": "77a8f26d5de76daf801fcd7ae54bebd0ecabbeeb3ecb90889bc4a2e5590b7d20", } @classmethod @@ -172,6 +184,11 @@ class RTLPlusGraphQL: f'{{"area":"{area}","offset":{offset},"take":{take}}}', ) + @classmethod + def live_events_overview_page(cls) -> dict: + """LiveEventsOverview — all upcoming/live events grouped by teaserRows.""" + return cls._build("LiveEventsOverview", "{}") + # --- VOD --- @classmethod diff --git a/lib/streaming_providers/providers/rtlplus/models.py b/lib/streaming_providers/providers/rtlplus/models.py index 207ce2e..1cd88ca 100644 --- a/lib/streaming_providers/providers/rtlplus/models.py +++ b/lib/streaming_providers/providers/rtlplus/models.py @@ -200,21 +200,22 @@ class RTLPlusLiveEvent: @classmethod def from_api_node(cls, node: Dict[str, Any]) -> "RTLPlusLiveEvent": - """Parse a single LiveEvent GraphQL node.""" + """Parse a single LiveEvent GraphQL node from LiveEventsOverview.""" + details = node.get("details") or {} + images = node.get("images") or {} return cls( id=node["id"], title=node["title"], stream_start=node["streamStart"], stream_end=node["streamEnd"], - location=node.get("location"), + location=details.get("stadium"), event_category=node.get("eventCategory", "GENERIC"), event_sub_category=node.get("eventSubCategory", ""), description=node.get("description"), - short_description=node.get("shortDescription"), + short_description=None, logo_url=( - node.get("liveImages", {}) - .get("artworkLandscape", {}) - .get("url") + images.get("artworkLandscape", {}).get("url") + or images.get("artworkPortrait", {}).get("url") ), required_permission=node.get( "requiredPermission", RTLPlusDefaults.PERMISSION_PAY_TV @@ -287,4 +288,4 @@ class RTLPlusStreamInfo: def has_drm(self) -> bool: """Check if stream has DRM protection""" - return bool(self.drm_license_url and self.drm_key_id) + return bool(self.drm_license_url and self.drm_key_id) \ No newline at end of file diff --git a/lib/streaming_providers/providers/rtlplus/provider.py b/lib/streaming_providers/providers/rtlplus/provider.py index 9fa2370..b0e49e0 100644 --- a/lib/streaming_providers/providers/rtlplus/provider.py +++ b/lib/streaming_providers/providers/rtlplus/provider.py @@ -209,13 +209,12 @@ class RTLPlusProvider(StreamingProvider): 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) + headers = self._get_rtlplus_authenticated_headers() try: response = self.http_manager.get( self.rtl_config.graphql_endpoint, - params=RTLPlusGraphQL.explore_widget_watch(), + params=RTLPlusGraphQL.live_events_overview_page(), headers=headers, operation="api", ) @@ -229,25 +228,30 @@ class RTLPlusProvider(StreamingProvider): @staticmethod def _parse_live_events(data: Dict[str, Any]) -> List[RTLPlusLiveEvent]: """ - Extract LiveEvent nodes from the GraphQL response. + Extract LiveEvent nodes from the LiveEventsOverview GraphQL response. + + Response shape: data.liveEventsOverview.teaserRows[].events[] Static so it's unit-testable without provider state. """ events: List[RTLPlusLiveEvent] = [] - widgets = ( + seen: set = set() + + teaser_rows = ( data.get("data", {}) - .get("editorialView", {}) - .get("widgets", {}) - .get("items", []) + .get("liveEventsOverview", {}) + .get("teaserRows", []) ) - for widget in widgets: - if widget.get("__typename") != "LiveEventWidget": - continue - for element in widget.get("elements", []): + for row in teaser_rows: + for element in (row.get("events") or []): if element is None: continue if element.get("__typename") != "LiveEvent": - continue # skip ExternalTeaser etc. + continue + event_id = element.get("id", "") + if not event_id or event_id in seen: + continue + seen.add(event_id) try: events.append(RTLPlusLiveEvent.from_api_node(element)) except Exception as e: diff --git a/lib/streaming_providers/providers/rtlplus/vod_manager.py b/lib/streaming_providers/providers/rtlplus/vod_manager.py index 40a7083..c7c1912 100644 --- a/lib/streaming_providers/providers/rtlplus/vod_manager.py +++ b/lib/streaming_providers/providers/rtlplus/vod_manager.py @@ -139,6 +139,10 @@ class RTLPlusVodManager: if category_path[0].startswith(RTLPlusDefaults.VOD_SERIES_ROOT_WATCH_PATH): return self._dispatch_series(category_path) + # Shows branch — watch-path based IDs + if category_path[0].startswith(RTLPlusDefaults.VOD_SHOWS_ROOT_WATCH_PATH): + return self._dispatch_shows(category_path) + # TopicWorlds branch — server RRNs only (rrn:multipurpose:...) if depth == 1 and category_path[0].startswith("rrn:"): return self._list_formats_for_topic(category_path[0]) @@ -191,6 +195,12 @@ class RTLPlusVodManager: provider="rtlplus", description="Serien auf RTL+", ), + VodCategory( + name="Shows", + content_id=RTLPlusDefaults.VOD_SHOWS_ROOT_WATCH_PATH, + provider="rtlplus", + description="Shows auf RTL+", + ), ] results.extend(self._list_topic_worlds()) logger.debug(f"RTLPlusVodManager: _list_root() → {len(results)} nodes") @@ -215,10 +225,14 @@ class RTLPlusVodManager: element_type=RTLPlusDefaults.VOD_ELEMENT_TYPE_MOVIE, genre_slug=genre_slug, ) - return self._list_genres( + genres = self._list_genres( root_watch_path=RTLPlusDefaults.VOD_MOVIES_ROOT_WATCH_PATH, genre_slugs=RTLPlusDefaults.VOD_MOVIE_GENRE_SLUGS, ) + items = self._list_overview_page_items( + element_type=RTLPlusDefaults.VOD_ELEMENT_TYPE_MOVIE, + ) + return genres + items if depth == 2: genre_slug = category_path[1].rsplit("/", 1)[-1] return self._list_overview_page_items( @@ -245,10 +259,14 @@ class RTLPlusVodManager: element_type=RTLPlusDefaults.VOD_ELEMENT_TYPE_SERIES, genre_slug=genre_slug, ) - return self._list_genres( + genres = self._list_genres( root_watch_path=RTLPlusDefaults.VOD_SERIES_ROOT_WATCH_PATH, genre_slugs=RTLPlusDefaults.VOD_SERIES_GENRE_SLUGS, ) + items = self._list_overview_page_items( + element_type=RTLPlusDefaults.VOD_ELEMENT_TYPE_SERIES, + ) + return genres + items if depth == 2: genre_slug = category_path[1].rsplit("/", 1)[-1] return self._list_overview_page_items( @@ -258,6 +276,39 @@ class RTLPlusVodManager: logger.warning(f"RTLPlusVodManager: series path too deep: {category_path!r}") return [] + # ------------------------------------------------------------------ + # Shows branch dispatcher + # ------------------------------------------------------------------ + + def _dispatch_shows( + self, category_path: List[str] + ) -> List[Union[VodCategory, VodItem]]: + depth = len(category_path) + if depth == 1: + genre_prefix = RTLPlusDefaults.VOD_SHOWS_ROOT_WATCH_PATH + "/genre/" + if category_path[0].startswith(genre_prefix): + genre_slug = category_path[0][len(genre_prefix):].split("/")[0] + return self._list_overview_page_items( + element_type=RTLPlusDefaults.VOD_ELEMENT_TYPE_SHOW, + genre_slug=genre_slug, + ) + genres = self._list_genres( + root_watch_path=RTLPlusDefaults.VOD_SHOWS_ROOT_WATCH_PATH, + genre_slugs=RTLPlusDefaults.VOD_SHOW_GENRE_SLUGS, + ) + items = self._list_overview_page_items( + element_type=RTLPlusDefaults.VOD_ELEMENT_TYPE_SHOW, + ) + return genres + items + if depth == 2: + genre_slug = category_path[1].rsplit("/", 1)[-1] + return self._list_overview_page_items( + element_type=RTLPlusDefaults.VOD_ELEMENT_TYPE_SHOW, + genre_slug=genre_slug, + ) + logger.warning(f"RTLPlusVodManager: shows path too deep: {category_path!r}") + return [] + # ------------------------------------------------------------------ # Shared genre list (movies + series, depth 1 of each branch) # ------------------------------------------------------------------ @@ -323,13 +374,13 @@ class RTLPlusVodManager: def _list_overview_page_items( self, element_type: str, - genre_slug: str, + genre_slug: Optional[str] = None, ) -> List[Union[VodCategory, VodItem]]: """ - Paginate OverviewPage for a single genre slug. + Paginate OverviewPage for an element type, optionally filtered by genre. element_type: RTLPlusDefaults.VOD_ELEMENT_TYPE_MOVIE or …SERIES - genre_slug: e.g. "drama", "action" + genre_slug: e.g. "drama", "action" — omit for unfiltered (all items) Series items at this level are returned as VodCategory nodes (each wraps a Format RRN) so the caller can drill into seasons/episodes via @@ -344,7 +395,7 @@ class RTLPlusVodManager: while True: params = RTLPlusGraphQL.overview_page( element_type=element_type, - genres=[genre_slug], + genres=[genre_slug] if genre_slug else None, offset=offset, limit=limit, ) @@ -390,7 +441,7 @@ class RTLPlusVodManager: logger.debug( f"RTLPlusVodManager: _list_overview_page_items" - f"({element_type}, {genre_slug}) → {len(results)} items" + f"({element_type}, genre={genre_slug}) → {len(results)} items" ) return results