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()]