From d25b8f015f469f6015040b8bc772ef3dd0166bf5 Mon Sep 17 00:00:00 2001 From: Nirvana Date: Tue, 10 Mar 2026 10:04:03 +0100 Subject: [PATCH] Add discovery --- .../providers/rtlplus/constants.py | 257 ++++++++---------- .../providers/rtlplus/provider.py | 6 +- .../providers/rtlplus/vod_manager.py | 24 +- 3 files changed, 126 insertions(+), 161 deletions(-) diff --git a/lib/streaming_providers/providers/rtlplus/constants.py b/lib/streaming_providers/providers/rtlplus/constants.py index 339452c..6837e4c 100644 --- a/lib/streaming_providers/providers/rtlplus/constants.py +++ b/lib/streaming_providers/providers/rtlplus/constants.py @@ -60,20 +60,6 @@ class RTLPlusDefaults: # HTTP settings DEFAULT_TIMEOUT = 30 - # 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" @@ -133,30 +119,125 @@ class RTLPlusDefaults: VOD_ELEMENT_TYPE_SERIES = "SERIES" VOD_OVERVIEW_PAGE_LIMIT = 48 - # --------------------------------------------------------------------------- - # VOD — persisted-query hashes (captured from browser network traffic) - # --------------------------------------------------------------------------- - # TopicWorlds — browse all genre/topic worlds; variables: { take, offset, filterForSearchGrid } - VOD_HASH_TOPIC_WORLDS = "3dbde4c45532f4bdb0a1d7c210db43f0888f8a82f4aab43f7a90f3c4762d8ff7" +class RTLPlusGraphQL: + """ + Persisted-query hashes and parameter builders for all RTL+ GraphQL operations. - # Format — full format detail by RRN; variables: { id } - VOD_HASH_FORMAT = "d112638c0184ab5698af7b69532dfe2f12973f7af9cb137b9f70278130b1eafa" + The HASHES dict maps each GraphQL operationName to its sha256 hash as + captured from live browser network traffic. Every builder method calls + _build(), which assembles the standard persisted-query envelope so that + the boilerplate never has to be repeated. + """ - # MRE (More / Related Episodes) — seasons + episodes for a Format; variables: { id } - VOD_HASH_MRE = "0c77404637570adff548e329a48654498c54ce1c36a459d72586ff18999bebaa" + # sha256 hashes keyed by operationName (captured from browser network traffic) + HASHES: dict[str, str] = { + "LiveTvStations": "845cf56a2a78110a0f978c1a2af2bc7f9a1c937d0f324ffaf852a9a4414c8485", + "ExploreWidgetWatch": "724f21ab86aa3f8c57673a3b346cf119f6a155bf82bb0201fd3b18e28e44f1ed", + "TopicWorlds": "3dbde4c45532f4bdb0a1d7c210db43f0888f8a82f4aab43f7a90f3c4762d8ff7", + "Format": "d112638c0184ab5698af7b69532dfe2f12973f7af9cb137b9f70278130b1eafa", + "MRE": "0c77404637570adff548e329a48654498c54ce1c36a459d72586ff18999bebaa", + "Episode": "87dbde15a0d269b11606f5ff458d555e98eb493bb4fb6ddc150d812d5e9a9cf8", + "WatchPlayerConfigV3":"fea0311fb572b6fded60c5a1a9d652f97f55d182bc4cedbdad676354a8d2797c", + "SeoUrlData": "fcc4a812d6b93496f00c3068234db7722f553032bb760e09e5e6c74586c86f8d", + "OverviewPage": "28aad4e992bb63330bfcd40a6906af3119d8a2612fa9fd28dae9c19127e247ca", + } - # Episode — full episode detail by RRN; variables: { rrn } - VOD_HASH_EPISODE = "87dbde15a0d269b11606f5ff458d555e98eb493bb4fb6ddc150d812d5e9a9cf8" + @classmethod + def _build(cls, operation: str, variables: str) -> dict: + """Assemble a persisted-query parameter dict for *operation*.""" + return { + "operationName": operation, + "variables": variables, + "extensions": ( + f'{{"persistedQuery":{{"version":1,"sha256Hash":"{cls.HASHES[operation]}"}}}}' + ), + } - # WatchPlayerConfigV3 — stream + DRM config; variables: { platform, id } - VOD_HASH_WATCH_PLAYER = "fea0311fb572b6fded60c5a1a9d652f97f55d182bc4cedbdad676354a8d2797c" + # --- Live TV / Events --- - # SeoUrlData — resolve watch-path to hierarchy; variables: { watchPath } - VOD_HASH_SEO_URL_DATA = "fcc4a812d6b93496f00c3068234db7722f553032bb760e09e5e6c74586c86f8d" + @classmethod + def live_tv_stations(cls, epg_count: int = 4) -> dict: + """LiveTvStations — all broadcast + FAST channels with EPG slots.""" + return cls._build( + "LiveTvStations", + f'{{"epgCount":{epg_count},"filter":{{"channelTypes":["BROADCAST","FAST"]}}}}', + ) - # OverviewPage — paginated grid for MOVIE or SERIES; variables: { pagination, filter } - VOD_HASH_OVERVIEW_PAGE = "28aad4e992bb63330bfcd40a6906af3119d8a2612fa9fd28dae9c19127e247ca" + @classmethod + def explore_widget_watch(cls, area: str = "home", offset: int = 0, take: int = 15) -> dict: + """ExploreWidgetWatch — editorial home view containing LiveEventWidget nodes.""" + return cls._build( + "ExploreWidgetWatch", + f'{{"area":"{area}","offset":{offset},"take":{take}}}', + ) + + # --- VOD --- + + @classmethod + def topic_worlds(cls, take: int = 100, offset: int = 0) -> dict: + """ + TopicWorlds — browse all genre/topic worlds. + + Confirmed from live traffic: + response key: data.topicWorldsV2.elements[] (NOT topicWorlds.items[]) + pagination: data.topicWorldsV2.pageInfo.hasNextPage + """ + return cls._build( + "TopicWorlds", + f'{{"take":{take},"offset":{offset},"filterForSearchGrid":true}}', + ) + + @classmethod + def format(cls, format_rrn: str) -> dict: + """Format — full format detail (seasons, metadata) by RRN.""" + return cls._build("Format", f'{{"id":"{format_rrn}"}}') + + @classmethod + def mre(cls, format_rrn: str) -> dict: + """MRE — seasons + related episodes for a Format.""" + return cls._build("MRE", f'{{"id":"{format_rrn}"}}') + + @classmethod + def episode(cls, episode_rrn: str) -> dict: + """Episode — full episode detail by RRN.""" + return cls._build("Episode", f'{{"rrn":"{episode_rrn}"}}') + + @classmethod + def watch_player_config(cls, rrn: str) -> dict: + """WatchPlayerConfigV3 — stream URL + DRM config for an episode or movie.""" + return cls._build( + "WatchPlayerConfigV3", + f'{{"platform":"{RTLPlusDefaults.VOD_PLATFORM_GRAPHQL}","id":"{rrn}"}}', + ) + + @classmethod + def seo_url_data(cls, watch_path: str) -> dict: + """SeoUrlData — resolve a watch-path to its content hierarchy.""" + return cls._build("SeoUrlData", f'{{"watchPath":"{watch_path}"}}') + + @classmethod + def overview_page( + cls, + element_type: str, + genres: list = None, + offset: int = 0, + limit: int = None, + ) -> dict: + """OverviewPage — paginated grid for MOVIE or SERIES with optional genre filter.""" + if limit is None: + limit = RTLPlusDefaults.VOD_OVERVIEW_PAGE_LIMIT + + filter_parts = [f'"elementType":"{element_type}"'] + if genres: + genres_json = "[" + ",".join(f'"{g}"' for g in genres) + "]" + filter_parts.append(f'"genres":{genres_json}') + + variables = ( + f'{{"pagination":{{"offset":{offset},"limit":{limit}}},' + f'"filter":{{{",".join(filter_parts)}}}}}' + ) + return cls._build("OverviewPage", variables) class RTLPlusHeaders: @@ -404,116 +485,4 @@ class RTLPlusConfig: """Return the Wurstland config URL for a VOD RRN.""" return RTLPlusDefaults.VOD_WURSTLAND_CONFIG_URL.format( rrn=rrn, platform=RTLPlusDefaults.VOD_WURSTLAND_PLATFORM - ) - - @staticmethod - def get_vod_topic_worlds_params(take: int = 100, offset: int = 0) -> dict: - """ - TopicWorlds — browse all genre/topic worlds. - - Confirmed from live traffic: - operationName: TopicWorlds - variables: { take, offset, filterForSearchGrid: true } - response key: data.topicWorldsV2.elements[] (NOT topicWorlds.items[]) - pagination: data.topicWorldsV2.pageInfo.hasNextPage - """ - return { - "operationName": "TopicWorlds", - "variables": f'{{"take":{take},"offset":{offset},"filterForSearchGrid":true}}', - "extensions": ( - '{"persistedQuery":{"version":1,"sha256Hash":' - f'"{RTLPlusDefaults.VOD_HASH_TOPIC_WORLDS}"}}}}' - ), - } - - @staticmethod - def get_vod_format_params(format_rrn: str) -> dict: - """Format — full format detail (seasons, metadata) by RRN.""" - return { - "operationName": "Format", - "variables": f'{{"id":"{format_rrn}"}}', - "extensions": ( - '{"persistedQuery":{"version":1,"sha256Hash":' - f'"{RTLPlusDefaults.VOD_HASH_FORMAT}"}}}}' - ), - } - - @staticmethod - def get_vod_mre_params(format_rrn: str) -> dict: - """MRE — seasons + related episodes for a Format.""" - return { - "operationName": "MRE", - "variables": f'{{"id":"{format_rrn}"}}', - "extensions": ( - '{"persistedQuery":{"version":1,"sha256Hash":' - f'"{RTLPlusDefaults.VOD_HASH_MRE}"}}}}' - ), - } - - @staticmethod - def get_vod_episode_params(episode_rrn: str) -> dict: - """Episode — full episode detail by RRN.""" - return { - "operationName": "Episode", - "variables": f'{{"rrn":"{episode_rrn}"}}', - "extensions": ( - '{"persistedQuery":{"version":1,"sha256Hash":' - f'"{RTLPlusDefaults.VOD_HASH_EPISODE}"}}}}' - ), - } - - @staticmethod - def get_vod_watch_player_params(rrn: str) -> dict: - """WatchPlayerConfigV3 — stream URL + DRM config for an episode or movie.""" - return { - "operationName": "WatchPlayerConfigV3", - "variables": ( - f'{{"platform":"{RTLPlusDefaults.VOD_PLATFORM_GRAPHQL}",' - f'"id":"{rrn}"}}' - ), - "extensions": ( - '{"persistedQuery":{"version":1,"sha256Hash":' - f'"{RTLPlusDefaults.VOD_HASH_WATCH_PLAYER}"}}}}' - ), - } - - @staticmethod - def get_vod_seo_url_data_params(watch_path: str) -> dict: - """SeoUrlData — resolve a watch-path to its hierarchy. variables: { watchPath }""" - return { - "operationName": "SeoUrlData", - "variables": f'{{"watchPath":"{watch_path}"}}', - "extensions": ( - '{"persistedQuery":{"version":1,"sha256Hash":' + - f'"{RTLPlusDefaults.VOD_HASH_SEO_URL_DATA}"}}}}' - ), - } - - @staticmethod - def get_vod_overview_page_params( - element_type: str, - genres: list = None, - offset: int = 0, - limit: int = None, - ) -> dict: - """OverviewPage — paginated grid for MOVIE or SERIES with optional genre filter.""" - if limit is None: - limit = RTLPlusDefaults.VOD_OVERVIEW_PAGE_LIMIT - - filter_parts = [f'"elementType":"{element_type}"'] - if genres: - genres_json = "[" + ",".join(f'"{g}"' for g in genres) + "]" - filter_parts.append(f'"genres":{genres_json}') - - variables = ( - f'{{"pagination":{{"offset":{offset},"limit":{limit}}},' + - f'"filter":{{{",".join(filter_parts)}}}}}' - ) - return { - "operationName": "OverviewPage", - "variables": variables, - "extensions": ( - '{"persistedQuery":{"version":1,"sha256Hash":' + - f'"{RTLPlusDefaults.VOD_HASH_OVERVIEW_PAGE}"}}}}' - ), - } \ No newline at end of file + ) \ 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 508a8dc..ab1ec58 100644 --- a/lib/streaming_providers/providers/rtlplus/provider.py +++ b/lib/streaming_providers/providers/rtlplus/provider.py @@ -10,7 +10,7 @@ from ...base.models.proxy_models import ProxyConfig from ...base.provider import StreamingProvider from ...base.utils import logger from .auth import RTLPlusAuthenticator -from .constants import RTLPlusConfig, RTLPlusDefaults +from .constants import RTLPlusConfig, RTLPlusDefaults, RTLPlusGraphQL from .models import RTLPlusLiveEvent from .vod_manager import RTLPlusVodManager @@ -34,7 +34,7 @@ class RTLPlusProvider(StreamingProvider): # Initialize configuration self.rtl_config = RTLPlusConfig(config) - self.channels_query_params = RTLPlusDefaults.CHANNELS_QUERY_PARAMS + self.channels_query_params = RTLPlusGraphQL.live_tv_stations() # ✅ Using HTTP manager abstraction self.http_manager = self._setup_http_manager( @@ -215,7 +215,7 @@ class RTLPlusProvider(StreamingProvider): try: response = self.http_manager.get( self.rtl_config.graphql_endpoint, - params=RTLPlusDefaults.EVENTS_QUERY_PARAMS, + params=RTLPlusGraphQL.explore_widget_watch(), headers=headers, operation="api", ) diff --git a/lib/streaming_providers/providers/rtlplus/vod_manager.py b/lib/streaming_providers/providers/rtlplus/vod_manager.py index fb2d278..5cafca6 100644 --- a/lib/streaming_providers/providers/rtlplus/vod_manager.py +++ b/lib/streaming_providers/providers/rtlplus/vod_manager.py @@ -45,7 +45,7 @@ This manager never performs stream or DRM fetches directly. GraphQL note ------------- RTL+ uses persisted queries exclusively. All operation names and hashes live -in RTLPlusDefaults / RTLPlusConfig so this file contains zero magic strings. +in RTLPlusGraphQL so this file contains zero magic strings. Partial-data (valueCompletion) responses are handled defensively throughout. """ @@ -54,7 +54,7 @@ from typing import List, Optional, Union from ...base.models.vod import VodCategory, VodItem from ...base.utils.logger import logger -from .constants import RTLPlusConfig, RTLPlusDefaults +from .constants import RTLPlusConfig, RTLPlusDefaults, RTLPlusGraphQL class RTLPlusVodManager: @@ -287,8 +287,7 @@ class RTLPlusVodManager: Response shape (confirmed from live data): data.urlDataByWatchPath.hierarchy.entries[-1].metadata.breadcrumbTitle """ - params = RTLPlusConfig.get_vod_seo_url_data_params(watch_path) - data = self._graphql_get(params) + data = self._graphql_get(RTLPlusGraphQL.seo_url_data(watch_path)) if not data: return None try: @@ -325,7 +324,7 @@ class RTLPlusVodManager: limit = RTLPlusDefaults.VOD_OVERVIEW_PAGE_LIMIT while True: - params = RTLPlusConfig.get_vod_overview_page_params( + params = RTLPlusGraphQL.overview_page( element_type=element_type, genres=[genre_slug], offset=offset, @@ -346,7 +345,6 @@ class RTLPlusVodManager: for node in items: if not node: continue - typename = node.get("__typename", "") node_id = node.get("id", "") if not node_id or node_id in seen: continue @@ -395,8 +393,7 @@ class RTLPlusVodManager: take = 100 while True: - params = RTLPlusConfig.get_vod_topic_worlds_params(take=take, offset=offset) - data = self._graphql_get(params) + data = self._graphql_get(RTLPlusGraphQL.topic_worlds(take=take, offset=offset)) if not data: break @@ -432,8 +429,7 @@ class RTLPlusVodManager: We re-fetch TopicWorlds and locate the matching world, then iterate its content items which may be Format or Movie nodes. """ - params = RTLPlusConfig.get_vod_topic_worlds_params(take=100, offset=0) - data = self._graphql_get(params) + data = self._graphql_get(RTLPlusGraphQL.topic_worlds(take=100, offset=0)) if not data: return [] @@ -482,7 +478,7 @@ class RTLPlusVodManager: def _list_seasons(self, format_rrn: str) -> List[VodCategory]: """Fetch seasons using MRE first, falling back to the Format query.""" # Primary: MRE - data = self._graphql_get(RTLPlusConfig.get_vod_mre_params(format_rrn)) + data = self._graphql_get(RTLPlusGraphQL.mre(format_rrn)) seasons = self._extract_seasons_from_mre(data) if data else [] # Fallback: Format detail @@ -490,7 +486,7 @@ class RTLPlusVodManager: logger.debug( f"RTLPlusVodManager: MRE empty → Format fallback for '{format_rrn}'" ) - data = self._graphql_get(RTLPlusConfig.get_vod_format_params(format_rrn)) + data = self._graphql_get(RTLPlusGraphQL.format(format_rrn)) seasons = self._extract_seasons_from_format(data) if data else [] results: List[VodCategory] = [] @@ -539,7 +535,7 @@ class RTLPlusVodManager: """ discriminator = season_key.split("@")[0].replace("season:", "") - data = self._graphql_get(RTLPlusConfig.get_vod_mre_params(format_rrn)) + data = self._graphql_get(RTLPlusGraphQL.mre(format_rrn)) episodes_raw = ( self._extract_episodes_from_mre(data, discriminator) if data else [] ) @@ -548,7 +544,7 @@ class RTLPlusVodManager: logger.debug( f"RTLPlusVodManager: MRE episodes empty → Format fallback for '{format_rrn}'" ) - data = self._graphql_get(RTLPlusConfig.get_vod_format_params(format_rrn)) + data = self._graphql_get(RTLPlusGraphQL.format(format_rrn)) episodes_raw = ( self._extract_episodes_from_format(data, discriminator) if data else [] )