From 37475a79eafdb673adf17d84159c754c5ce51d7b Mon Sep 17 00:00:00 2001 From: Nirvana Date: Thu, 23 Apr 2026 22:36:36 +0200 Subject: [PATCH] Prepare RTLPlus for new streaming prov --- .../providers/rtlplus/auth.py | 46 +++++- .../providers/rtlplus/constants.py | 135 ++++++++++++++---- 2 files changed, 144 insertions(+), 37 deletions(-) diff --git a/lib/streaming_providers/providers/rtlplus/auth.py b/lib/streaming_providers/providers/rtlplus/auth.py index 9b26513..e7c160c 100644 --- a/lib/streaming_providers/providers/rtlplus/auth.py +++ b/lib/streaming_providers/providers/rtlplus/auth.py @@ -2,6 +2,7 @@ import base64 import json import time +import hashlib from typing import Any, Dict, Optional from ...base.auth.base_auth import BaseAuthToken, TokenAuthLevel @@ -215,21 +216,54 @@ class RTLPlusAuthenticator(BaseOAuth2Authenticator): # Bedrock Token Management (Linear TV) # -------------------------------------------------------------------------- - def get_bedrock_token(self, force_refresh: bool = False) -> str: - """ - Get or refresh Bedrock token. + def _get_server_timestamp(self) -> int: + """Get current server timestamp from RTL+ time endpoint.""" + try: + response = self.http_manager.get( + "https://time.rtlde.bedrock.tech/", + operation="api", + headers={"User-Agent": self.config.user_agent} + ) + data = response.json() + return data.get("timestamp", int(time.time())) + except Exception as e: + logger.warning(f"Could not fetch server timestamp: {e}") + return int(time.time()) - The Bedrock token is a JWT required for all Bedrock API calls - (layout, DRM, heartbeat). Obtained using the OAuth token. + @staticmethod + def _generate_auth_token(device_id: str, timestamp: int) -> str: """ + Generate the x-auth-token required for Bedrock API calls. + + Based on analysis, this appears to be a SHA-1 hash of device_id + timestamp. + """ + # Convert timestamp to string + ts_str = str(timestamp) + + # Try SHA-1 of device_id + timestamp (most likely) + data = f"{device_id}{ts_str}" + token = hashlib.sha1(data.encode()).hexdigest() + + logger.debug(f"Generated auth token: {token} for device {device_id[:8]}... at {timestamp}") + return token + + def get_bedrock_token(self, force_refresh: bool = False) -> str: + """Get or refresh Bedrock token.""" if not force_refresh and self._bedrock_token and self._bedrock_token_expiry > time.time() + 300: return self._bedrock_token oauth_token = self.get_bearer_token() + # Get timestamp and generate auth token + timestamp = self._get_server_timestamp() + auth_token = self._generate_auth_token(self.config.device_id, timestamp) + + # Use headers from config + headers = self.config.get_bedrock_token_headers(oauth_token, auth_token, timestamp) + response = self.http_manager.get( self.config.bedrock_auth_url, - headers={"authorization": f"Bearer {oauth_token}"}, + headers=headers, operation="api", ) response.raise_for_status() diff --git a/lib/streaming_providers/providers/rtlplus/constants.py b/lib/streaming_providers/providers/rtlplus/constants.py index f2f23db..e330682 100644 --- a/lib/streaming_providers/providers/rtlplus/constants.py +++ b/lib/streaming_providers/providers/rtlplus/constants.py @@ -21,6 +21,10 @@ class RTLPlusDefaults: DEVICE_NAME = "Linux Chrome" PLAYREADY_DEVICE_NAME = "Windows Edge" + # Device screen size (used in headers) + DEVICE_PLAYER_SIZE_WIDTH = 972 + DEVICE_PLAYER_SIZE_HEIGHT = 919 + # User agents USER_AGENT = get_user_agent("windows", "chrome") PLAYREADY_USER_AGENT = get_user_agent("windows", "edge") @@ -42,6 +46,7 @@ class RTLPlusDefaults: AUTH_AUTHORIZE_ENDPOINT = f"{AUTH_BASE_URL}/auth" GRAPHQL_ENDPOINT = "https://cdn.gateway.now-plus-prod.aws-cbc.cloud/graphql" BASE_WEBSITE = "https://plus.rtl.de/" + BETA_WEBSITE = "https://beta.plus.rtl.de/" CONFIG_ENDPOINT = "https://plus.rtl.de/assets/config/config.json" # Bedrock API endpoints (Linear TV) @@ -49,6 +54,7 @@ class RTLPlusDefaults: BEDROCK_LAYOUT_BASE = "https://layout.rtlde.bedrock.tech/front/v1/rtlde/m6group_web/main/token-web-31" BEDROCK_DRM_UPFRONT_BASE = "https://drm.rtlde.bedrock.tech/v1/customers/rtlde/platforms/m6group_web/services/rtlplus_root/users/{uid}/live" BEDROCK_HEARTBEAT_URL = "https://heartbeat-v2.rtlde.bedrock.tech/v2/platforms/m6group_web/notify/session_live" + TIME_ENDPOINT = "https://time.rtlde.bedrock.tech/" # DRM license server DRMTODAY_LICENSE_URL = "https://lic.drmtoday.com/license-proxy-widevine/cenc/" @@ -141,7 +147,8 @@ def _get_rtlplus_client_version() -> str: _CLIENT_VERSION_CACHE = data["version"] logger.debug(f"Fetched RTL+ client version: {_CLIENT_VERSION_CACHE}") except Exception as exc: - logger.warning(f"Could not fetch RTL+ client version ({exc}); using fallback {RTLPlusDefaults.CLIENT_VERSION_FALLBACK}") + logger.warning( + f"Could not fetch RTL+ client version ({exc}); using fallback {RTLPlusDefaults.CLIENT_VERSION_FALLBACK}") _CLIENT_VERSION_CACHE = RTLPlusDefaults.CLIENT_VERSION_FALLBACK return _CLIENT_VERSION_CACHE @@ -173,7 +180,8 @@ class RTLPlusGraphQL: @classmethod def live_tv_stations(cls, epg_count: int = 4) -> dict: - return cls._build("LiveTvStations", f'{{"epgCount":{epg_count},"filter":{{"channelTypes":["BROADCAST","FAST"]}}}}') + return cls._build("LiveTvStations", + f'{{"epgCount":{epg_count},"filter":{{"channelTypes":["BROADCAST","FAST"]}}}}') @classmethod def explore_widget_watch(cls, area: str = "home", offset: int = 0, take: int = 15) -> dict: @@ -181,7 +189,8 @@ class RTLPlusGraphQL: @classmethod def season_with_episodes(cls, season_rrn: str, offset: int = 0, limit: int = 48) -> dict: - return cls._build("SeasonWithFormatAndEpisodes", f'{{"seasonId":"{season_rrn}","offset":{offset},"limit":{limit}}}') + return cls._build("SeasonWithFormatAndEpisodes", + f'{{"seasonId":"{season_rrn}","offset":{offset},"limit":{limit}}}') @classmethod def live_events_overview_page(cls) -> dict: @@ -205,7 +214,8 @@ class RTLPlusGraphQL: @classmethod def watch_player_config(cls, rrn: str) -> dict: - return cls._build("WatchPlayerConfigV3", f'{{"platform":"{RTLPlusDefaults.VOD_PLATFORM_GRAPHQL}","id":"{rrn}"}}') + return cls._build("WatchPlayerConfigV3", + f'{{"platform":"{RTLPlusDefaults.VOD_PLATFORM_GRAPHQL}","id":"{rrn}"}}') @classmethod def seo_url_data(cls, watch_path: str) -> dict: @@ -228,6 +238,14 @@ class RTLPlusGraphQL: class RTLPlusHeaders: """Standard header configurations for RTL+ requests""" + # Common headers shared across multiple request types + _COMMON_HEADERS = { + "accept": "*/*", + "accept-encoding": "gzip, deflate, br, zstd", + "accept-language": "de-DE,de;q=0.9", + "cache-control": "no-cache", + } + @staticmethod def get_base_headers(user_agent: str = None) -> dict: return {"User-Agent": user_agent or RTLPlusDefaults.USER_AGENT, "Accept": "application/json"} @@ -243,7 +261,8 @@ class RTLPlusHeaders: return headers @staticmethod - def get_api_headers(access_token: str = None, device_id: str = None, client_version: str = None, user_agent: str = None) -> dict: + def get_api_headers(access_token: str = None, device_id: str = None, client_version: str = None, + user_agent: str = None) -> dict: headers = RTLPlusHeaders.get_base_headers(user_agent) headers.update({ "Content-Type": "application/json", @@ -258,69 +277,106 @@ 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: - headers = RTLPlusHeaders.get_api_headers(access_token=access_token, device_id=device_id, client_version=client_version, user_agent=user_agent) + def get_events_headers(access_token: str, profile_token: str = None, device_id: str = None, + client_version: str = None, user_agent: str = None) -> dict: + 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_bedrock_layout_headers( - oauth_token: str, - bedrock_token: str, - device_id: str, - client_version: str, - user_agent: str, - location: str = None, + def get_bedrock_token_headers( + oauth_token: str, + device_id: str, + client_version: str, + user_agent: str, + auth_token: str, + timestamp: int, ) -> dict: - headers = { + """Headers for obtaining Bedrock token.""" + headers = dict(RTLPlusHeaders._COMMON_HEADERS) + headers.update({ "authorization": f"Bearer {oauth_token}", - "cache-control": "no-cache", - "origin": RTLPlusDefaults.BASE_WEBSITE.rstrip("/"), - "referer": RTLPlusDefaults.BASE_WEBSITE, - "request-timeout": "10000", + "origin": RTLPlusDefaults.BETA_WEBSITE.rstrip("/"), + "referer": RTLPlusDefaults.BETA_WEBSITE, + "user-agent": user_agent, + "x-auth-device-id": device_id, + "x-auth-device-name": RTLPlusDefaults.DEVICE_NAME, + "x-auth-device-player-size-width": str(RTLPlusDefaults.DEVICE_PLAYER_SIZE_WIDTH), + "x-auth-device-player-size-height": str(RTLPlusDefaults.DEVICE_PLAYER_SIZE_HEIGHT), + "x-auth-token": auth_token, + "x-auth-token-timestamp": str(timestamp), + "x-client-release": client_version, + "x-customer-name": "rtlde", + }) + return headers + + @staticmethod + def get_bedrock_layout_headers( + oauth_token: str, + bedrock_token: str, + device_id: str, + client_version: str, + user_agent: str, + location: str = None, + ) -> dict: + """Headers for Bedrock layout API calls.""" + headers = dict(RTLPlusHeaders._COMMON_HEADERS) + headers.update({ + "authorization": f"Bearer {oauth_token}", + "origin": RTLPlusDefaults.BETA_WEBSITE.rstrip("/"), + "referer": RTLPlusDefaults.BETA_WEBSITE, "user-agent": user_agent, "x-bedrock-token": bedrock_token, "x-auth-device-id": device_id, "x-auth-device-name": RTLPlusDefaults.DEVICE_NAME, "x-client-release": client_version, "x-customer-name": "rtlde", - } + }) if location: headers["x-location"] = location return headers @staticmethod - def get_upfront_token_headers(oauth_token: str, bedrock_token: str, device_id: str, client_version: str, user_agent: str) -> dict: - return { + def get_upfront_token_headers( + oauth_token: str, + bedrock_token: str, + device_id: str, + client_version: str, + user_agent: str, + ) -> dict: + """Headers for upfront token request.""" + headers = dict(RTLPlusHeaders._COMMON_HEADERS) + headers.update({ "authorization": f"Bearer {oauth_token}", - "cache-control": "no-cache", - "origin": RTLPlusDefaults.BASE_WEBSITE.rstrip("/"), - "referer": RTLPlusDefaults.BASE_WEBSITE, - "request-timeout": "10000", + "origin": RTLPlusDefaults.BETA_WEBSITE.rstrip("/"), + "referer": RTLPlusDefaults.BETA_WEBSITE, "user-agent": user_agent, "x-bedrock-token": bedrock_token, "x-auth-device-id": device_id, "x-auth-device-name": RTLPlusDefaults.DEVICE_NAME, "x-client-release": client_version, "x-customer-name": "rtlde", - } + }) + return headers @staticmethod def get_drm_license_headers(upfront_token: str, user_agent: str) -> dict: - return { - "accept": "*/*", + """Headers for DRM license request to DRMToday.""" + headers = dict(RTLPlusHeaders._COMMON_HEADERS) + headers.update({ "content-type": "application/json", "origin": RTLPlusDefaults.BASE_WEBSITE.rstrip("/"), "referer": RTLPlusDefaults.BASE_WEBSITE, "user-agent": user_agent, "x-dt-auth-token": upfront_token, - } + }) + return headers @staticmethod def get_drm_headers(access_token: str, device_id: str = None, user_agent: str = None) -> dict: return { - "Cache-Control": "no-cache", "Origin": RTLPlusDefaults.BASE_WEBSITE.rstrip("/"), "Referer": RTLPlusDefaults.BASE_WEBSITE, "User-Agent": user_agent or RTLPlusDefaults.USER_AGENT, @@ -357,10 +413,15 @@ class RTLPlusConfig: self.user_agent = config.get("user_agent", RTLPlusDefaults.USER_AGENT) self.platform = config.get("platform", RTLPlusDefaults.PLATFORM_DEFAULT) + # Screen size + self.screen_width = config.get("screen_width", RTLPlusDefaults.DEVICE_PLAYER_SIZE_WIDTH) + self.screen_height = config.get("screen_height", RTLPlusDefaults.DEVICE_PLAYER_SIZE_HEIGHT) + # API endpoints self.auth_endpoint = config.get("auth_endpoint", RTLPlusDefaults.AUTH_ENDPOINT) self.graphql_endpoint = config.get("graphql_endpoint", RTLPlusDefaults.GRAPHQL_ENDPOINT) self.base_website = config.get("base_website", RTLPlusDefaults.BASE_WEBSITE) + self.beta_website = config.get("beta_website", RTLPlusDefaults.BETA_WEBSITE) self.config_endpoint = config.get("config_endpoint", RTLPlusDefaults.CONFIG_ENDPOINT) # Bedrock endpoints (Linear TV) @@ -368,6 +429,7 @@ class RTLPlusConfig: self.bedrock_layout_base = config.get("bedrock_layout_base", RTLPlusDefaults.BEDROCK_LAYOUT_BASE) self.bedrock_drm_upfront_base = config.get("bedrock_drm_upfront_base", RTLPlusDefaults.BEDROCK_DRM_UPFRONT_BASE) self.bedrock_heartbeat_url = config.get("bedrock_heartbeat_url", RTLPlusDefaults.BEDROCK_HEARTBEAT_URL) + self.time_endpoint = config.get("time_endpoint", RTLPlusDefaults.TIME_ENDPOINT) # DRM license server self.drmtoday_license_url = config.get("drmtoday_license_url", RTLPlusDefaults.DRMTODAY_LICENSE_URL) @@ -415,6 +477,17 @@ class RTLPlusConfig: user_agent=self.user_agent, ) + def get_bedrock_token_headers(self, oauth_token: str, auth_token: str, timestamp: int) -> dict: + """Get headers for Bedrock token request.""" + return RTLPlusHeaders.get_bedrock_token_headers( + oauth_token=oauth_token, + device_id=self.device_id, + client_version=self.client_version, + user_agent=self.user_agent, + auth_token=auth_token, + timestamp=timestamp, + ) + def get_bedrock_layout_headers(self, oauth_token: str, bedrock_token: str, location: str = None) -> dict: return RTLPlusHeaders.get_bedrock_layout_headers( oauth_token=oauth_token,