diff --git a/music_assistant/helpers/audio.py b/music_assistant/helpers/audio.py index 28bf18812d..6f7c57fb99 100644 --- a/music_assistant/helpers/audio.py +++ b/music_assistant/helpers/audio.py @@ -378,8 +378,8 @@ async def get_stream_details( if ( streamdetails.stream_type in (StreamType.ICY, StreamType.HLS, StreamType.HTTP) and streamdetails.media_type == MediaType.RADIO + and isinstance(streamdetails.path, str) ): - assert isinstance(streamdetails.path, str) # for type checking resolved_url, stream_type = await resolve_radio_stream(mass, streamdetails.path) streamdetails.path = resolved_url streamdetails.stream_type = stream_type diff --git a/music_assistant/providers/pandora/__init__.py b/music_assistant/providers/pandora/__init__.py new file mode 100644 index 0000000000..50b07bf3ff --- /dev/null +++ b/music_assistant/providers/pandora/__init__.py @@ -0,0 +1,73 @@ +"""Pandora music provider support for Music Assistant.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from music_assistant_models.config_entries import ConfigEntry, ConfigValueType +from music_assistant_models.enums import ConfigEntryType, ProviderFeature +from music_assistant_models.errors import SetupFailedError + +from music_assistant.constants import CONF_PASSWORD, CONF_USERNAME + +from .provider import PandoraProvider + +if TYPE_CHECKING: + from music_assistant_models.config_entries import ProviderConfig + from music_assistant_models.provider import ProviderManifest + + from music_assistant import MusicAssistant + from music_assistant.models import ProviderInstanceType + +# Supported Features - Pandora is primarily a radio service +SUPPORTED_FEATURES = { + ProviderFeature.BROWSE, + ProviderFeature.LIBRARY_RADIOS, +} + + +async def setup( + mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig +) -> ProviderInstanceType: + """Initialize provider instance with given configuration.""" + username = config.get_value(CONF_USERNAME) + password = config.get_value(CONF_PASSWORD) + + # Type-safe validation + if ( + not username + or not password + or not isinstance(username, str) + or not isinstance(password, str) + or not username.strip() + or not password.strip() + ): + raise SetupFailedError("Username and password are required") + + return PandoraProvider(mass, manifest, config, SUPPORTED_FEATURES) + + +async def get_config_entries( + mass: MusicAssistant, + instance_id: str | None = None, + action: str | None = None, + values: dict[str, ConfigValueType] | None = None, +) -> tuple[ConfigEntry, ...]: + """Return configuration entries for this provider.""" + # ruff: noqa: ARG001 + return ( + ConfigEntry( + key=CONF_USERNAME, + type=ConfigEntryType.STRING, + label="Username", + description="Your Pandora username or email address", + required=True, + ), + ConfigEntry( + key=CONF_PASSWORD, + type=ConfigEntryType.SECURE_STRING, + label="Password", + description="Your Pandora password", + required=True, + ), + ) diff --git a/music_assistant/providers/pandora/constants.py b/music_assistant/providers/pandora/constants.py new file mode 100644 index 0000000000..5ae1546add --- /dev/null +++ b/music_assistant/providers/pandora/constants.py @@ -0,0 +1,43 @@ +"""Constants for the Pandora provider.""" + +from __future__ import annotations + +# API Endpoints +API_BASE_URL = "https://www.pandora.com/api/v1" +LOGIN_ENDPOINT = f"{API_BASE_URL}/auth/login" +STATIONS_ENDPOINT = f"{API_BASE_URL}/station/getStations" +PLAYLIST_FRAGMENT_ENDPOINT = f"{API_BASE_URL}/playlist/getFragment" + +# Error Codes from Pandora API +PANDORA_ERROR_CODES = { + 0: "INVALID_REQUEST", + 1: "INVALID_PARTNER", + 2: "LISTENER_NOT_AUTHORIZED", + 3: "USER_NOT_AUTHORIZED", + 4: "STATION_DOES_NOT_EXIST", + 5: "TRACK_NOT_FOUND", + 9: "PANDORA_NOT_AVAILABLE", + 10: "SYSTEM_NOT_AVAILABLE", + 11: "CALL_NOT_ALLOWED", + 12: "INVALID_USERNAME", + 13: "INVALID_PASSWORD", + 14: "DEVICE_NOT_FOUND", + 15: "PARTNER_NOT_AUTHORIZED", + 1000: "READ_ONLY_MODE", + 1001: "INVALID_AUTH_TOKEN", + 1002: "INVALID_LOGIN", + 1003: "LISTENER_NOT_AUTHORIZED", + 1004: "USER_ALREADY_EXISTS", + 1005: "DEVICE_ALREADY_ASSOCIATED_TO_ACCOUNT", + 1006: "UPGRADE_DEVICE_MODEL_INVALID", + 1009: "DEVICE_MODEL_INVALID", + 1010: "INVALID_SPONSOR", + 1018: "EXPLICIT_PIN_INCORRECT", + 1020: "EXPLICIT_PIN_MALFORMED", + 1023: "DEVICE_DISABLED", + 1024: "DAILY_TRIAL_LIMIT_REACHED", + 1025: "INVALID_SPONSOR_USERNAME", + 1026: "SPONSOR_CANNOT_SKIP_ADS", + 1027: "INSUFFICIENT_CONNECTIVITY", + 1034: "GEOLOCATION_REQUIRED", +} diff --git a/music_assistant/providers/pandora/helpers.py b/music_assistant/providers/pandora/helpers.py new file mode 100644 index 0000000000..a95e19a277 --- /dev/null +++ b/music_assistant/providers/pandora/helpers.py @@ -0,0 +1,105 @@ +"""Helper utilities for the Pandora provider.""" + +from __future__ import annotations + +import secrets +from typing import Any + +import aiohttp +from music_assistant_models.errors import ( + LoginFailed, + MediaNotFoundError, + ProviderUnavailableError, + ResourceTemporarilyUnavailable, +) + +from .constants import PANDORA_ERROR_CODES + + +def generate_csrf_token() -> str: + """Generate a random CSRF token.""" + return secrets.token_hex(16) + + +def handle_pandora_error(response_data: dict[str, Any]) -> None: + """Handle Pandora API error responses. + + Maps Pandora API error codes to appropriate Music Assistant exceptions. + + Raises: + LoginFailed: For authentication errors + MediaNotFoundError: For missing stations/tracks + ResourceTemporarilyUnavailable: For service availability issues + ProviderUnavailableError: For other API errors + """ + if response_data.get("errorCode") is not None: + error_code = response_data["errorCode"] + error_string = response_data.get("errorString", "UNKNOWN_ERROR") + message = response_data.get("message", "An unknown error occurred") + + # Map specific error codes to Music Assistant exceptions + if error_code in (12, 13, 1002): # Invalid username/password/login + raise LoginFailed(f"Login failed: {message}") + + if error_code in (4, 5): # Station/track not found + raise MediaNotFoundError(f"Media not found: {message}") + + if error_code in (9, 10): # Service unavailable + raise ResourceTemporarilyUnavailable(f"Service unavailable: {message}") + + if error_code in (1001, 1003): # Auth token issues + raise LoginFailed(f"Authentication error: {message}") + + # Get error description from our mapping + error_desc = PANDORA_ERROR_CODES.get(error_code, error_string) + raise ProviderUnavailableError(f"Pandora API error {error_code} ({error_desc}): {message}") + + +async def get_csrf_token(session: aiohttp.ClientSession) -> str: + """Get CSRF token from Pandora website. + + Attempts to retrieve CSRF token from Pandora cookies. + + Args: + session: aiohttp client session + + Returns: + CSRF token string + + Raises: + ResourceTemporarilyUnavailable: If network request fails or no token available + """ + try: + async with session.head("https://www.pandora.com/") as response: + if "csrftoken" in response.cookies: + return str(response.cookies["csrftoken"].value) + except aiohttp.ClientError as e: + raise ResourceTemporarilyUnavailable(f"Failed to get CSRF token from Pandora: {e}") from e + + # No token found - service may be unavailable + raise ResourceTemporarilyUnavailable( + "Pandora did not provide a CSRF token. Service may be unavailable." + ) + + +def create_auth_headers(csrf_token: str, auth_token: str | None = None) -> dict[str, str]: + """Create authentication headers for Pandora API requests. + + Args: + csrf_token: CSRF token for request validation + auth_token: Optional authentication token for authenticated requests + + Returns: + Dictionary of HTTP headers + """ + headers = { + "Content-Type": "application/json;charset=utf-8", + "X-CsrfToken": csrf_token, + "Cookie": f"csrftoken={csrf_token}", + "User-Agent": "Music Assistant Pandora Provider/1.0", + } + + if auth_token: + headers["X-AuthToken"] = auth_token + + return headers diff --git a/music_assistant/providers/pandora/icon.svg b/music_assistant/providers/pandora/icon.svg new file mode 100644 index 0000000000..e73c24b1e3 --- /dev/null +++ b/music_assistant/providers/pandora/icon.svg @@ -0,0 +1,46 @@ + + + + diff --git a/music_assistant/providers/pandora/icon_monochrome.svg b/music_assistant/providers/pandora/icon_monochrome.svg new file mode 100644 index 0000000000..d27e603fc1 --- /dev/null +++ b/music_assistant/providers/pandora/icon_monochrome.svg @@ -0,0 +1,46 @@ + + + + diff --git a/music_assistant/providers/pandora/manifest.json b/music_assistant/providers/pandora/manifest.json new file mode 100644 index 0000000000..29f79252f8 --- /dev/null +++ b/music_assistant/providers/pandora/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "pandora", + "name": "Pandora", + "description": "Pandora is a music and podcast streaming service that creates personalized radio stations based on your favorite songs and artists.", + "documentation": "https://music-assistant.io/music-providers/pandora/", + "type": "music", + "requirements": [], + "codeowners": "@ozgav", + "multi_instance": false, + "stage": "beta" +} diff --git a/music_assistant/providers/pandora/provider.py b/music_assistant/providers/pandora/provider.py new file mode 100644 index 0000000000..8488883bb1 --- /dev/null +++ b/music_assistant/providers/pandora/provider.py @@ -0,0 +1,454 @@ +"""Pandora music provider for Music Assistant.""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import TYPE_CHECKING, Any + +import aiohttp +from aiohttp import web +from music_assistant_models.config_entries import ( + ConfigEntry, + ConfigValueType, + ProviderConfig, +) +from music_assistant_models.enums import ( + ConfigEntryType, + ContentType, + ImageType, + MediaType, + ProviderFeature, + StreamType, +) +from music_assistant_models.errors import LoginFailed, MediaNotFoundError +from music_assistant_models.media_items import ( + AudioFormat, + MediaItemImage, + MediaItemMetadata, + ProviderMapping, + Radio, + SearchResults, + UniqueList, +) +from music_assistant_models.streamdetails import MultiPartPath, StreamDetails, StreamMetadata + +from music_assistant.controllers.cache import use_cache +from music_assistant.helpers.compare import compare_strings +from music_assistant.models.music_provider import MusicProvider + +from .helpers import create_auth_headers, get_csrf_token, handle_pandora_error + +if TYPE_CHECKING: + from collections.abc import AsyncGenerator + + from music_assistant_models.provider import ProviderManifest + + from music_assistant.mass import MusicAssistant + from music_assistant.models import ProviderInstanceType + +# API Configuration +API_BASE = "https://www.pandora.com/api/v1" +CONF_USERNAME = "username" +CONF_PASSWORD = "password" + +# Pandora returns ~4 tracks per fragment +TRACKS_PER_FRAGMENT = 4 + +SUPPORTED_FEATURES = ( + ProviderFeature.LIBRARY_RADIOS, + ProviderFeature.BROWSE, +) + + +async def setup( + mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig +) -> ProviderInstanceType: + """Initialize provider(instance) with given configuration.""" + prov = PandoraProvider(mass, manifest, config) + await prov.handle_async_init() + return prov + + +async def get_config_entries( + mass: MusicAssistant, # noqa: ARG001 + instance_id: str | None = None, # noqa: ARG001 + action: str | None = None, # noqa: ARG001 + values: dict[str, ConfigValueType] | None = None, # noqa: ARG001 +) -> tuple[ConfigEntry, ...]: + """Return Config entries to setup this provider.""" + return ( + ConfigEntry( + key=CONF_USERNAME, + type=ConfigEntryType.SECURE_STRING, + label="Username", + required=True, + description="Your Pandora account email address", + ), + ConfigEntry( + key=CONF_PASSWORD, + type=ConfigEntryType.SECURE_STRING, + label="Password", + required=True, + description="Your Pandora account password", + ), + ) + + +class PandoraProvider(MusicProvider): + """Pandora Music Provider.""" + + _auth_token: str | None = None + _user_id: str | None = None + _csrf_token: str | None = None + _on_unload_callbacks: list[Callable[[], None]] + + async def handle_async_init(self) -> None: + """Handle async initialization of the provider.""" + self._on_unload_callbacks = [] + + # Authenticate with Pandora + username = str(self.config.get_value(CONF_USERNAME)) + password = str(self.config.get_value(CONF_PASSWORD)) + + try: + await self._authenticate(username, password) + except Exception as err: + raise LoginFailed(f"Failed to authenticate with Pandora: {err}") from err + + # Register dynamic stream route + self._on_unload_callbacks.append( + self.mass.streams.register_dynamic_route( + f"/{self.instance_id}_stream", self._handle_stream_request + ) + ) + + async def unload(self, is_removed: bool = False) -> None: + """Handle unload/close of the provider.""" + for callback in self._on_unload_callbacks: + callback() + await super().unload(is_removed) + + async def _authenticate(self, username: str, password: str) -> None: + """Authenticate with Pandora and get auth token.""" + try: + # First, get CSRF token + self._csrf_token = await get_csrf_token(self.mass.http_session) + + # Prepare login data + login_data = { + "username": username, + "password": password, + "keepLoggedIn": True, + "existingAuthToken": None, + } + + # Create headers with CSRF token + headers = create_auth_headers(self._csrf_token) + + async with self.mass.http_session.post( + f"{API_BASE}/auth/login", + headers=headers, + json=login_data, + timeout=aiohttp.ClientTimeout(total=30), + ) as response: + if response.status != 200: + raise LoginFailed(f"Login request failed with status {response.status}") + + response_data: dict[str, Any] = await response.json() + handle_pandora_error(response_data) + + self._auth_token = response_data.get("authToken") + if not self._auth_token: + raise LoginFailed("No auth token received from Pandora") + + self._user_id = response_data.get("listenerId") + self.logger.info("Successfully authenticated with Pandora") + + except LoginFailed: + raise + except Exception as err: + self.logger.error("Authentication failed: %s", err) + raise LoginFailed(f"Failed to authenticate with Pandora: {err}") from err + + async def _api_request( + self, + method: str, + url: str, + data: dict[str, Any] | None = None, + ) -> dict[str, Any]: + """Make an API request to Pandora.""" + if not self._csrf_token or not self._auth_token: + raise LoginFailed("Not authenticated with Pandora") + + headers = create_auth_headers(self._csrf_token, self._auth_token) + + async with self.mass.http_session.request( + method, + url, + json=data, + headers=headers, + ) as response: + response.raise_for_status() + result: dict[str, Any] = await response.json() + handle_pandora_error(result) + return result + + async def get_library_radios(self) -> AsyncGenerator[Radio, None]: + """Retrieve library/subscribed radio stations from the provider.""" + self.logger.debug("Fetching Pandora stations") + + try: + response = await self._api_request( + "POST", + f"{API_BASE}/station/getStations", + data={}, + ) + except Exception as err: + self.logger.error("Failed to fetch stations: %s", err) + return + + stations = response.get("stations", []) + self.logger.debug("Found %d Pandora stations", len(stations)) + + for station in stations: + # Get station artwork (prefer 500px version) + station_image = None + if art := station.get("art"): + art_url = next( + (item["url"] for item in art if item.get("size") == 500), + art[-1]["url"] if art else None, + ) + if art_url: + station_image = MediaItemImage( + type=ImageType.THUMB, + path=art_url, + provider=self.instance_id, + remotely_accessible=True, + ) + yield Radio( + item_id=station["stationId"], + provider=self.domain, + name=station["name"], + metadata=MediaItemMetadata( + images=UniqueList([station_image]) if station_image else None, + ), + provider_mappings={ + ProviderMapping( + item_id=station["stationId"], + provider_domain=self.domain, + provider_instance=self.instance_id, + ) + }, + ) + + @use_cache(3600) + async def get_radio(self, prov_radio_id: str) -> Radio: + """Get single radio station details.""" + # For now, just return basic info + # We could enhance this with more details from the API + return Radio( + item_id=prov_radio_id, + provider=self.domain, + name=f"Pandora Station {prov_radio_id}", + provider_mappings={ + ProviderMapping( + item_id=prov_radio_id, + provider_domain=self.domain, + provider_instance=self.instance_id, + ) + }, + ) + + async def get_stream_details(self, item_id: str, media_type: MediaType) -> StreamDetails: + """Get streamdetails for a radio station.""" + if media_type != MediaType.RADIO: + raise MediaNotFoundError(f"Unsupported media type: {media_type}") + + # Create 1000 dummy tracks pointing to our dynamic handler + # This provides roughly two days of radio streaming assuming 3 mins per track + parts = [ + MultiPartPath( + path=f"{self.mass.streams.base_url}/{self.instance_id}_stream?" + f"station_id={item_id}&track_num={i}", + ) + for i in range(1000) + ] + # Is this what is envisaged to pass the static variables? + # MultiPartPath( + # path=f"{self.mass.streams.base_url}/{self.instance_id}_stream?" + # f"station_id={item_id}&track_num={i}&queue_id={queue_id}" + # f"&queue_item_id={queue_item_id}", + # ) + return StreamDetails( + provider=self.instance_id, + item_id=item_id, + audio_format=AudioFormat( + content_type=ContentType.AAC, + ), + media_type=MediaType.RADIO, + stream_type=StreamType.HTTP, + path=parts, + can_seek=False, + allow_seek=False, + stream_metadata=StreamMetadata( + title="Pandora", + ), + ) + + @use_cache(300) # Cache fragments for 5 minutes + async def _get_fragment_data(self, station_id: str, fragment_index: int) -> dict[str, Any]: + """Fetch fragment data from Pandora API (cached per station + fragment index).""" + self.logger.debug( + "Fetching fragment %d for station %s", + fragment_index, + station_id, + ) + + fragment_data = { + "stationId": station_id, + "isStationStart": fragment_index == 0, + "fragmentRequestReason": "Normal", + "audioFormat": "aacplus", + "startingAtTrackId": None, + "onDemandArtistMessageArtistUidHex": None, + "onDemandArtistMessageIdHex": None, + } + + try: + return await self._api_request( + "POST", + f"{API_BASE}/playlist/getFragment", + data=fragment_data, + ) + except Exception as err: + self.logger.error( + "Failed to fetch fragment %d for station %s: %s", + fragment_index, + station_id, + err, + ) + raise + + async def _handle_stream_request(self, request: web.Request) -> web.Response: + """ + Handle dynamic stream request. + + This is called by FFmpeg when it requests each track URL. + We fetch the fragment containing that track and redirect to the actual audio URL. + """ + if not (station_id := request.query.get("station_id")): + return web.Response(status=400, text="Missing station_id") + if not (track_num_str := request.query.get("track_num")): + return web.Response(status=400, text="Missing track_num") + + try: + track_num = int(track_num_str) + except ValueError: + return web.Response(status=400, text="Invalid track_num") + + queue_id = request.query.get("queue_id") + queue_item_id = request.query.get("queue_item_id") + + # Calculate which fragment and which track within that fragment + fragment_idx = track_num // TRACKS_PER_FRAGMENT + track_idx = track_num % TRACKS_PER_FRAGMENT + + self.logger.debug( + "Stream request: track %d = fragment %d, track %d", + track_num, + fragment_idx, + track_idx, + ) + + try: + # Fetch the fragment (cached automatically by decorator) + fragment = await self._get_fragment_data(station_id, fragment_idx) + + # Get the tracks from the fragment + tracks = fragment.get("tracks", []) + if track_idx >= len(tracks): + self.logger.error( + "Track index %d out of range (fragment has %d tracks)", + track_idx, + len(tracks), + ) + return web.Response(status=404, text="Track not found in fragment") + + track = tracks[track_idx] + audio_url = track.get("audioURL") + + if not audio_url: + self.logger.error("No audio URL in track data") + return web.Response(status=404, text="No audio URL available") + + # Update metadata if we have queue context + if queue_id and queue_item_id: + queue_item = self.mass.player_queues.get_item(queue_id, queue_item_id) + if queue_item and queue_item.streamdetails: + # Get the best quality album art + album_art_url = None + if album_art := track.get("albumArt"): + # Get the 500px version (good balance of quality/size) + album_art_url = next( + (art["url"] for art in album_art if art.get("size") == 500), + album_art[-1]["url"] if album_art else None, + ) + + queue_item.streamdetails.stream_metadata = StreamMetadata( + title=track.get("songTitle", "Unknown Song"), + artist=track.get("artistName", "Unknown Artist"), + album=track.get("albumTitle"), + image_url=album_art_url, + duration=track.get("trackLength"), + ) + self.logger.debug( + "Updated stream metadata: %s - %s", + track.get("artistName", "Unknown"), + track.get("songTitle", "Unknown"), + ) + + self.logger.debug( + "Redirecting to audio URL for track %d (%s - %s)", + track_num, + track.get("artistName", "Unknown"), + track.get("songTitle", "Unknown"), + ) + + # Redirect to the actual audio URL + return web.Response(status=302, headers={"Location": audio_url}) + + except Exception as err: + self.logger.error( + "Error handling stream request for track %d: %s", + track_num, + err, + ) + return web.Response(status=500, text="A stream error occurred") + + async def search( + self, + search_query: str, + media_types: list[MediaType], + limit: int = 25, + ) -> SearchResults: + """Search library radio stations by name.""" + # We can't search Pandora's API without the legacy endpoints, + # but we can search the user's existing library stations + if MediaType.RADIO not in media_types: + return SearchResults() + + results: list[Radio] = [] + + async for station in self.get_library_radios(): + if compare_strings(station.name, search_query): + results.append(station) + if len(results) >= limit: + break + + return SearchResults(radio=results) + + async def browse(self, path: str) -> list[Radio]: + """Browse radio stations.""" + # For now, just return all stations like get_library_radios + # Could be enhanced with categories/genres in the future + return [station async for station in self.get_library_radios()]