Fix Pandora authentication failures (#2949)
authorOzGav <gavnosp@hotmail.com>
Mon, 26 Jan 2026 06:59:56 +0000 (17:59 +1100)
committerGitHub <noreply@github.com>
Mon, 26 Jan 2026 06:59:56 +0000 (07:59 +0100)
* fix: Pandora auth token expiry during long listening sessions

Implemented automatic re-authentication mechanism for Pandora provider
to prevent auth token expiry during extended listening sessions.

- Added token expiration tracking with AUTH_TOKEN_LIFETIME (50 minutes)
- Added ensure_valid_auth() method to proactively re-authenticate before
  token expiration (with 5 minute buffer)
- Updated _api_request() to call ensure_valid_auth() before all API calls
- Added retry logic for 401 errors to handle reactive re-authentication

This follows the same pattern used by the Tidal provider for maintaining
valid authentication tokens during long-running sessions.

Fixes issue where users reported authentication expiring while listening
to Pandora stations.

* improvement: Check for explicit token expiry from Pandora API

Updated authentication to check if Pandora returns expiry information
(expiresIn or expires_in fields) and use it if available, rather than
always assuming a hardcoded lifetime.

- Checks for expiry fields in authentication response
- Uses actual expiry if provided by API
- Falls back to conservative 50-minute estimate if not
- Added logging to show which approach is being used

This makes the implementation more robust and will automatically adapt
if Pandora provides expiry information in their response.

* refactor: Switch to reactive-only auth handling for Pandora

Changed from proactive time-based re-authentication to reactive
401-error handling only. This is a better approach because:

1. User reports show auth failures occurring anywhere from 5 minutes
   to several hours, indicating it's NOT a simple token timeout
2. Proactive re-auth would mask the real issue and prevent diagnosis
3. Reactive handling will log actual failure patterns to help identify
   the root cause (concurrent logins, IP changes, rate limiting, etc.)

Changes:
- Removed AUTH_TOKEN_LIFETIME and AUTH_REFRESH_BUFFER constants
- Removed ensure_valid_auth() proactive check method
- Removed _auth_expires_at expiry tracking
- Added _auth_time to track when authentication occurred
- Enhanced 401 error handling to log how long tokens lasted
- Simplified to reactive-only approach: retry on 401 with fresh auth

This will help diagnose the actual cause of auth expiry issues while
still preventing playback interruption by automatically re-authenticating
when needed.

* chore: Change Pandora auth log messages to debug level

Changed authentication success and token expiry messages from info/warning
to debug level to reduce log noise for routine operations.

* chore: Remove diagnostic timing code from Pandora auth

Removed _auth_time tracking and associated debug logging that calculated
token lifetime. Fix is confirmed working, diagnostic code no longer needed.

* chore: Change authentication success log back to info level

* fix: Remove duplicate type annotation to fix mypy error

* fix: Handle network errors gracefully in Pandora stream handler

Added ProviderUnavailableError handling to _handle_stream_request to
prevent crashes when network errors (DNS timeouts, connection failures)
occur during fragment fetching.

- Wrapped all _get_fragment_data calls in try-except block
- Added ProviderUnavailableError exception handler
- Returns 503 Service Unavailable instead of crashing
- Logs error for diagnostics

* refactor: Use recursive retry pattern for Pandora auth failures

Refactored _api_request to use recursive call instead of duplicating
error handling code in retry block.

- Added 'retry: bool = True' parameter to _api_request
- On 401 error, re-authenticate and recursively call with retry=False
- Eliminates ~20 lines of duplicate error handling code
- Cleaner and more maintainable

Credit: @MarvinSchenkel for the suggestion

* fix: Add type annotation to satisfy mypy no-any-return check

* style: Remove superfluous else after return (RET505)

Fixed ruff linting error - removed unnecessary else clause after
return statement in retry logic.

---------

Co-authored-by: Claude <noreply@anthropic.com>
music_assistant/providers/pandora/provider.py

index 4441ccf6be11122bc80a538b39038963f4b9e699..711924f72e0aa661d094267020108e11ac367aa8 100644 (file)
@@ -146,9 +146,15 @@ class PandoraProvider(MusicProvider):
             ) from err
 
     async def _api_request(
-        self, method: str, url: str, data: dict[str, Any] | None = None
+        self, method: str, url: str, data: dict[str, Any] | None = None, retry: bool = True
     ) -> dict[str, Any]:
-        """Make an API request to Pandora."""
+        """Make an API request to Pandora.
+
+        :param method: HTTP method (GET, POST, etc.)
+        :param url: API endpoint URL
+        :param data: Optional JSON data to send
+        :param retry: Whether to retry once on 401 authentication errors
+        """
         if not self._csrf_token or not self._auth_token:
             raise LoginFailed("Not authenticated with Pandora")
 
@@ -160,7 +166,14 @@ class PandoraProvider(MusicProvider):
             ) as response:
                 # Check status BEFORE parsing JSON
                 if response.status == 401:
-                    raise LoginFailed("Pandora session expired")
+                    if retry:
+                        # Auth token expired, re-authenticate and retry once
+                        username = str(self.config.get_value(CONF_USERNAME))
+                        password = str(self.config.get_value(CONF_PASSWORD))
+                        await self._authenticate(username, password)
+                        return await self._api_request(method, url, data, retry=False)
+                    raise LoginFailed("Pandora authentication failed after retry")
+
                 if response.status == 404:
                     raise MediaNotFoundError("Resource not found")
                 if response.status >= 500:
@@ -347,15 +360,15 @@ class PandoraProvider(MusicProvider):
         # Get or create session with LRU eviction
         session = self._get_or_create_session(station_id)
 
-        # If we don't have this music track yet, fetch more fragments
-        while music_track_num >= len(session.track_map):
-            next_fragment_idx = len(session.fragments)
-            await self._get_fragment_data(session, next_fragment_idx)
+        try:
+            # If we don't have this music track yet, fetch more fragments
+            while music_track_num >= len(session.track_map):
+                next_fragment_idx = len(session.fragments)
+                await self._get_fragment_data(session, next_fragment_idx)
 
-        # Look up the actual fragment/track position
-        fragment_idx, track_idx = session.track_map[music_track_num]
+            # Look up the actual fragment/track position
+            fragment_idx, track_idx = session.track_map[music_track_num]
 
-        try:
             # Ensure fragment is loaded
             if fragment_idx >= len(session.fragments) or not session.fragments[fragment_idx]:
                 await self._get_fragment_data(session, fragment_idx)
@@ -387,6 +400,9 @@ class PandoraProvider(MusicProvider):
         except (MediaNotFoundError, InvalidDataError) as err:
             self.logger.error("Stream error: %s", err)
             return web.Response(status=404, text="Stream unavailable")
+        except ProviderUnavailableError as err:
+            self.logger.error("Pandora service unavailable: %s", err)
+            return web.Response(status=503, text="Service temporarily unavailable")
 
     def _get_or_create_session(self, station_id: str) -> PandoraStationSession:
         """Get or create a session, with LRU eviction if needed."""