From 6598ec6b7522854e043489ca703c83e5c77f318b Mon Sep 17 00:00:00 2001 From: Nirvana Date: Tue, 17 Feb 2026 12:43:33 +0100 Subject: [PATCH] Add discovery --- .../providers/discovery/auth.py | 244 +++++++++++++----- 1 file changed, 182 insertions(+), 62 deletions(-) diff --git a/lib/streaming_providers/providers/discovery/auth.py b/lib/streaming_providers/providers/discovery/auth.py index aadf3a2..15f70b2 100644 --- a/lib/streaming_providers/providers/discovery/auth.py +++ b/lib/streaming_providers/providers/discovery/auth.py @@ -176,10 +176,25 @@ class DiscoveryAuthenticator(BaseAuthenticator): Supports: - Anonymous authentication (two-step token negotiation) - - User credentials (username/password) + - User credentials (username/password upgrade from anonymous token) - Dynamic endpoint discovery from bootstrap - Session header persistence (x-disco-id, x-wbd-session-state, x-wbd-ace) + Auth flows: + Anonymous : GET /token (no headers) → 400 + session headers + → GET /token (with session headers) → 200 + anon token + → GET /bootstrap (with session headers + Bearer anon token) + → 200 + full endpoint/routing map + + User : [same two-step /token negotiation] + → POST /login (Authorization: Bearer ) → 200 + user token + → GET /bootstrap (with session headers + Bearer user token) + → 200 + full endpoint/routing map + + Bootstrap is always called AFTER a token is obtained so it receives proper + session headers and an Authorization header, allowing it to return the + correct country-specific endpoint routing rather than a generic/empty 400. + Note: Discovery+ uses simple token-based auth, not OAuth2. """ @@ -209,7 +224,7 @@ class DiscoveryAuthenticator(BaseAuthenticator): self.env = DEFAULT_ENV self.device_id = DEFAULT_DEVICE_ID - # Session headers storage (from 400 response) + # Session headers storage (from /token 400 response) self._session_state: Optional[str] = None self._disco_id: Optional[str] = None self._wbd_ace: Optional[str] = None @@ -231,8 +246,10 @@ class DiscoveryAuthenticator(BaseAuthenticator): enable_kodi_integration=True, ) - # Discover endpoints after initialization - self._discover_endpoints() + # NOTE: _discover_endpoints() is NOT called here. + # Bootstrap requires an authenticated session to return correct routing, + # so it is called at the end of _perform_anonymous_auth / + # _perform_user_auth once a token and session headers are in hand. @property def http_manager(self): @@ -269,12 +286,18 @@ class DiscoveryAuthenticator(BaseAuthenticator): return self._http_manager - def _discover_endpoints(self) -> None: + def _discover_endpoints(self, auth_headers: Dict[str, str]) -> None: """ - Discover API endpoints from bootstrap. + Discover API endpoints from bootstrap using an authenticated session. - Raises: - EndpointDiscoveryError: If endpoint discovery fails + Must be called AFTER a token has been obtained so that the request + carries the correct session headers (x-disco-id, x-wbd-session-state, + x-wbd-ace) and an Authorization: Bearer header. Without these the + bootstrap returns a generic 400 with no useful routing data. + + Args: + auth_headers: Headers including Authorization and session headers, + as built by _build_authenticated_headers(). """ try: logger.debug(f"Discovering endpoints for Discovery+ ({self.country})") @@ -282,10 +305,25 @@ class DiscoveryAuthenticator(BaseAuthenticator): response = self.http_manager.get( DISCOVERY_BOOTSTRAP_URL, operation="bootstrap", + headers=auth_headers, timeout=30, ) - response.raise_for_status() - data = response.json() + + if response.status_code != 200: + logger.warning( + f"Bootstrap returned {response.status_code} — " + "falling back to hardcoded endpoints" + ) + return + + try: + data = response.json() + except Exception as parse_err: + logger.warning( + f"Could not parse bootstrap response: {parse_err}. " + "Falling back to hardcoded endpoints." + ) + return # Store routing info self._routing = data.get("routing", {}) @@ -301,14 +339,12 @@ class DiscoveryAuthenticator(BaseAuthenticator): if base_url: self._endpoints[path] = f"{base_url}{path}" - logger.debug(f"Discovered {len(self._endpoints)} endpoints") + logger.debug( + f"Discovered {len(self._endpoints)} endpoints from bootstrap" + ) except Exception as e: logger.warning(f"Failed to discover endpoints: {e}") - # Initialize empty dicts for fallback - self._endpoints = {} - self._api_groups = {} - self._routing = {} def _build_api_url(self, api_group: str) -> Optional[str]: """ @@ -343,16 +379,14 @@ class DiscoveryAuthenticator(BaseAuthenticator): def get_endpoint(self, path: str) -> Optional[str]: """ - Get full URL for an endpoint path. + Get full URL for an endpoint path discovered from bootstrap. Args: path: Endpoint path (e.g., '/token') Returns: - Full endpoint URL or None if not found + Full endpoint URL or None if not yet discovered (fallbacks apply) """ - if not self._endpoints: - self._discover_endpoints() return self._endpoints.get(path) @property @@ -366,7 +400,6 @@ class DiscoveryAuthenticator(BaseAuthenticator): Returns: Authentication endpoint URL """ - # Return login endpoint as the primary auth endpoint return self.login_endpoint @property @@ -431,9 +464,7 @@ class DiscoveryAuthenticator(BaseAuthenticator): Format: dplus/6.14.0 (desktop/desktop; Linux/x86_64; device-id/session-id) """ - # Use consistent device ID device_id = self.device_id or DEFAULT_DEVICE_ID - # Generate new session ID for each auth attempt session_id = str(uuid.uuid4()) return f"dplus/6.14.0 (desktop/desktop; Linux/x86_64; {device_id}/{session_id})" @@ -454,7 +485,7 @@ class DiscoveryAuthenticator(BaseAuthenticator): "x-wbd-time-zone": "Europe/Berlin", } - # Add session headers if we have them (from 400 response) + # Add session headers if we have them (from /token 400 response) if self._disco_id: headers["x-disco-id"] = self._disco_id if self._session_state: @@ -473,6 +504,23 @@ class DiscoveryAuthenticator(BaseAuthenticator): """ return self._build_base_headers() + def _build_authenticated_headers(self, token: BaseAuthToken) -> Dict[str, str]: + """ + Build headers for post-auth requests (bootstrap, CMS, playback). + + Combines session headers (populated during /token negotiation) with + the Authorization: Bearer header from the obtained token. + + Args: + token: Successfully obtained authentication token + + Returns: + Headers dict including Authorization and all session headers + """ + headers = self._build_base_headers() # picks up _disco_id etc. + headers["Authorization"] = f"Bearer {token.access_token}" + return headers + def _build_auth_payload(self) -> Dict[str, Any]: """ Build authentication payload based on credential type. @@ -542,15 +590,18 @@ class DiscoveryAuthenticator(BaseAuthenticator): """ Perform anonymous authentication with two-step header negotiation. - Step 1: Initial request returns 400 with required headers - Step 2: Second request with all headers returns 200 with token + Step 1: GET /token with no session headers → 400 response whose + headers contain x-disco-id, x-wbd-session-state, x-wbd-ace. + Step 2: GET /token again with those three headers → 200 with token. Returns: Anonymous authentication token """ - logger.info(f"Performing two-step anonymous authentication for {self.provider_name}") + logger.info( + f"Performing two-step anonymous authentication for {self.provider_name}" + ) - # Base headers for initial request (without session headers) + # Step 1 headers: base headers only, no session headers yet base_headers = { "User-Agent": DISCOVERY_USER_AGENT, "x-device-info": self._build_device_info(), @@ -563,7 +614,7 @@ class DiscoveryAuthenticator(BaseAuthenticator): params = {"realm": "bolt"} - # Step 1: Make initial request (expecting 400) + # Step 1: Initial request — we expect 400 + session headers in response logger.debug("Step 1: Making initial token request (expecting 400)") response = self.http_manager.get( self.token_endpoint, @@ -573,22 +624,23 @@ class DiscoveryAuthenticator(BaseAuthenticator): allow_redirects=False, ) - # Check for 400 with required headers if response.status_code == 400: - # Extract ALL required headers from response + # Extract required session headers from the 400 response disco_id = response.headers.get("x-disco-id") session_state = response.headers.get("x-wbd-session-state") wbd_ace = response.headers.get("x-wbd-ace") if not all([disco_id, session_state, wbd_ace]): - missing = [] - if not disco_id: - missing.append("x-disco-id") - if not session_state: - missing.append("x-wbd-session-state") - if not wbd_ace: - missing.append("x-wbd-ace") - logger.error(f"Missing required headers in 400 response: {missing}") + missing = [ + h for h, v in [ + ("x-disco-id", disco_id), + ("x-wbd-session-state", session_state), + ("x-wbd-ace", wbd_ace), + ] if not v + ] + logger.error( + f"Missing required headers in 400 response: {missing}" + ) logger.debug(f"Response headers: {dict(response.headers)}") response.raise_for_status() @@ -596,13 +648,13 @@ class DiscoveryAuthenticator(BaseAuthenticator): logger.debug(f"Received x-wbd-ace: {wbd_ace[:50]}...") logger.debug(f"Received x-wbd-session-state: {session_state[:100]}...") - # Store session headers for future requests + # Persist session headers for future requests (CMS, playback, etc.) self._disco_id = disco_id self._session_state = session_state self._wbd_ace = wbd_ace - # Step 2: Second request with ALL headers from first response - logger.debug("Step 2: Making second token request with all headers") + # Step 2: Repeat the request with all three session headers + logger.debug("Step 2: Making second token request with session headers") second_headers = base_headers.copy() second_headers["x-disco-id"] = disco_id second_headers["x-wbd-session-state"] = session_state @@ -614,53 +666,122 @@ class DiscoveryAuthenticator(BaseAuthenticator): headers=second_headers, params=params, ) - - # Should now be 200 with token response.raise_for_status() token_data = response.json() logger.info("Anonymous authentication successful") - return self._create_token_from_response(token_data) + token = self._create_token_from_response(token_data) + + # Bootstrap with authenticated session to discover country-specific + # endpoints. Must happen after token is obtained. + self._discover_endpoints(self._build_authenticated_headers(token)) + + return token - # If we get 200 directly (unlikely), handle it elif response.status_code == 200: + # Fallback: server skipped header negotiation (uncommon) logger.debug("Received 200 directly without header negotiation") token_data = response.json() - return self._create_token_from_response(token_data) + token = self._create_token_from_response(token_data) + self._discover_endpoints(self._build_authenticated_headers(token)) + return token - # Any other status is an error else: logger.error(f"Unexpected response status: {response.status_code}") response.raise_for_status() def _perform_user_auth(self) -> BaseAuthToken: """ - Perform user authentication (POST /login with credentials). + Perform user authentication by upgrading an anonymous token. + + Discovery+ does not accept a bare credentials POST to /login. + The session must first be established anonymously, then the anonymous + token is used as a Bearer token to POST credentials to /login, which + returns a new token with anonymous=False and full user entitlements. + + Flow: + 1. Obtain anonymous token via two-step header negotiation (reuses + _perform_anonymous_auth, which also populates session headers). + 2. POST /login with Authorization: Bearer and the + username/password payload to upgrade to a user token. Returns: - User authentication token + User-level authentication token (anonymous=False) + + Raises: + InvalidCredentialsError: If credentials are missing or rejected """ - logger.info(f"Performing user authentication for {self.provider_name}") + if not isinstance(self.credentials, DiscoveryUserCredentials): + raise InvalidCredentialsError( + "User credentials required for user authentication" + ) - headers = self._get_auth_headers() - headers["Content-Type"] = "application/json" + logger.info( + f"Performing user authentication for {self.provider_name} " + f"(user: {self.credentials.username})" + ) - payload = self._build_auth_payload() + # Step 1+2: Obtain anonymous token (also populates session headers + # self._disco_id / self._session_state / self._wbd_ace). + # NOTE: _perform_anonymous_auth calls _discover_endpoints internally + # with the anon token. That's fine — we intentionally overwrite the + # endpoint map below with the user token, which may return different + # (higher-entitlement) routing. + logger.debug("Obtaining anonymous base token for user auth upgrade") + anon_token = self._perform_anonymous_auth() + + # Step 3: Upgrade anonymous session to user session + logger.debug( + f"Upgrading anonymous token to user token for " + f"{self.credentials.username}" + ) + + # Build headers: session headers + Bearer anon token + upgrade_headers = self._build_base_headers() # includes session headers + upgrade_headers["Authorization"] = f"Bearer {anon_token.access_token}" + upgrade_headers["Content-Type"] = "application/json" + + payload = self.credentials.to_login_payload() response = self.http_manager.post( self.login_endpoint, operation="auth", - headers=headers, + headers=upgrade_headers, json_data=payload, ) + if response.status_code == 401: + raise InvalidCredentialsError( + f"Invalid credentials for user {self.credentials.username}" + ) + response.raise_for_status() token_data = response.json() - return self._create_token_from_response(token_data) + + user_token = self._create_token_from_response(token_data) + + if getattr(user_token, "anonymous", True): + # Login succeeded but token still anonymous — credentials were + # accepted but the account may lack an active subscription. + logger.warning( + f"Login succeeded for {self.credentials.username} but token " + "is still anonymous — account may lack an active subscription" + ) + else: + logger.info( + f"User authentication successful for {self.credentials.username}" + ) + + # Re-run bootstrap with the user token so endpoints reflect full + # user entitlements (country routing may differ from anonymous session). + self._discover_endpoints(self._build_authenticated_headers(user_token)) + + return user_token def _refresh_token(self) -> Optional[BaseAuthToken]: """ - Token refresh - Discovery tokens don't support refresh, re-authenticate. + Token refresh — Discovery+ tokens have no refresh_token mechanism. + Returning None triggers a full re-authentication in the base class. Returns: None (triggers re-authentication) @@ -681,9 +802,8 @@ class DiscoveryAuthenticator(BaseAuthenticator): Returns: Token authentication level """ - if hasattr(token, "anonymous"): - if not token.anonymous: - return TokenAuthLevel.USER_AUTHENTICATED + if hasattr(token, "anonymous") and not token.anonymous: + return TokenAuthLevel.USER_AUTHENTICATED return TokenAuthLevel.ANONYMOUS def get_bearer_token(self, force_refresh: bool = False) -> str: @@ -707,14 +827,14 @@ class DiscoveryAuthenticator(BaseAuthenticator): True if authenticated with valid token """ return ( - self._current_token is not None and - not self._current_token.is_expired + self._current_token is not None and + not self._current_token.is_expired ) def invalidate_token(self) -> None: """Invalidate current token and clear from storage""" self._current_token = None - # Also clear session headers + # Also clear session headers so next auth starts fresh self._disco_id = None self._session_state = None self._wbd_ace = None