From 6bafb6b88f0b68404bceda7239c9974c1f5d98ab Mon Sep 17 00:00:00 2001 From: Gav Date: Mon, 15 Sep 2025 17:00:16 +1000 Subject: [PATCH 01/18] Initial Commit --- music_assistant/providers/pandora/__init__.py | 78 +++ .../providers/pandora/constants.py | 82 +++ music_assistant/providers/pandora/helpers.py | 91 +++ music_assistant/providers/pandora/icon.svg | 46 ++ .../providers/pandora/icon_monochrome.svg | 46 ++ .../providers/pandora/manifest.json | 10 + music_assistant/providers/pandora/parsers.py | 300 +++++++++ music_assistant/providers/pandora/provider.py | 581 ++++++++++++++++++ 8 files changed, 1234 insertions(+) create mode 100644 music_assistant/providers/pandora/__init__.py create mode 100644 music_assistant/providers/pandora/constants.py create mode 100644 music_assistant/providers/pandora/helpers.py create mode 100644 music_assistant/providers/pandora/icon.svg create mode 100644 music_assistant/providers/pandora/icon_monochrome.svg create mode 100644 music_assistant/providers/pandora/manifest.json create mode 100644 music_assistant/providers/pandora/parsers.py create mode 100644 music_assistant/providers/pandora/provider.py diff --git a/music_assistant/providers/pandora/__init__.py b/music_assistant/providers/pandora/__init__.py new file mode 100644 index 0000000000..873377a237 --- /dev/null +++ b/music_assistant/providers/pandora/__init__.py @@ -0,0 +1,78 @@ +"""Pandora music provider support for Music Assistant.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from music_assistant_models.config_entries import ConfigEntry, ConfigValueOption, ConfigValueType +from music_assistant_models.enums import ConfigEntryType +from music_assistant_models.errors import SetupFailedError + +from .constants import CONF_AUDIO_QUALITY, 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 + + +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) + + +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, + ), + ConfigEntry( + key=CONF_AUDIO_QUALITY, + type=ConfigEntryType.STRING, + label="Audio Quality", + description="Preferred audio quality (requires Premium subscription for high quality)", + default_value="high", + options=[ + ConfigValueOption("Low (64 kbps AAC+)", "low"), + ConfigValueOption("Medium (128 kbps MP3)", "medium"), + ConfigValueOption("High (192 kbps AAC+) - Premium only", "high"), + ], + ), + ) diff --git a/music_assistant/providers/pandora/constants.py b/music_assistant/providers/pandora/constants.py new file mode 100644 index 0000000000..decb2603ea --- /dev/null +++ b/music_assistant/providers/pandora/constants.py @@ -0,0 +1,82 @@ +"""Constants for the Pandora provider.""" + +from __future__ import annotations + +from music_assistant_models.enums import ProviderFeature + +# Configuration Keys +CONF_USERNAME = "username" +CONF_PASSWORD = "password" +CONF_AUDIO_QUALITY = "audio_quality" + +# 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" +STATION_DETAILS_ENDPOINT = f"{API_BASE_URL}/station/getStationDetails" +PLAYLIST_FRAGMENT_ENDPOINT = f"{API_BASE_URL}/playlist/getFragment" +PROFILE_ENDPOINT = f"{API_BASE_URL}/listener/getProfile" +SEARCH_ENDPOINT = f"{API_BASE_URL}/search/search" +TRACK_FEEDBACK_ENDPOINT = f"{API_BASE_URL}/station/addFeedback" + +# Request Headers +DEFAULT_HEADERS = { + "Content-Type": "application/json;charset=utf-8", + "User-Agent": "Music Assistant Pandora Provider/1.0", +} + +# Supported Features - Pandora is primarily a radio service +SUPPORTED_FEATURES = { + ProviderFeature.SEARCH, + ProviderFeature.BROWSE, + # Pandora doesn't support traditional library features + # as it's a radio service, but we can implement stations as playlists +} + +# API Limits +MAX_SEARCH_RESULTS = 50 +MAX_STATION_TRACKS = 100 +DEFAULT_PAGE_SIZE = 50 + +# Audio Quality Settings +AUDIO_QUALITIES = { + "low": {"bitrate": 64, "format": "AAC+"}, # Free tier + "medium": {"bitrate": 128, "format": "MP3"}, # In-home devices + "high": {"bitrate": 192, "format": "AAC+"}, # Premium web/desktop +} + +DEFAULT_AUDIO_QUALITY = "high" # Assume premium subscription + +# 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..4fc795c9a3 --- /dev/null +++ b/music_assistant/providers/pandora/helpers.py @@ -0,0 +1,91 @@ +"""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, + 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.""" + 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 RuntimeError(f"Pandora API error {error_code} ({error_desc}): {message}") + + +async def get_csrf_token(session: aiohttp.ClientSession) -> str: + """Get CSRF token from Pandora website.""" + try: + async with session.head("https://www.pandora.com/") as response: + # Try to extract from cookies first + if "csrftoken" in response.cookies: + return str(response.cookies["csrftoken"].value) + except aiohttp.ClientError as e: + # Network issues - this is temporarily unavailable + raise ResourceTemporarilyUnavailable(f"Failed to get CSRF token from Pandora: {e}") + except Exception as e: + # Unexpected errors should also be treated as temporary issues + raise ResourceTemporarilyUnavailable(f"Unexpected error getting CSRF token: {e}") + + # If we get here, no CSRF token was found in cookies + return generate_csrf_token() + + +def create_auth_headers(csrf_token: str, auth_token: str | None = None) -> dict[str, str]: + """Create authentication headers for Pandora API requests.""" + 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 + + +def format_duration(duration_ms: int | None) -> float: + """Convert duration from milliseconds to seconds.""" + if duration_ms is None: + return 0.0 + return duration_ms / 1000.0 + + +def safe_get(data: dict[str, Any], *keys: str, default: Any = None) -> Any: + """Safely get nested dictionary values.""" + for key in keys: + if isinstance(data, dict) and key in data: + data = data[key] + else: + return default + return data 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..d0b04f066b --- /dev/null +++ b/music_assistant/providers/pandora/manifest.json @@ -0,0 +1,10 @@ +{ + "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 +} diff --git a/music_assistant/providers/pandora/parsers.py b/music_assistant/providers/pandora/parsers.py new file mode 100644 index 0000000000..67e7cb7963 --- /dev/null +++ b/music_assistant/providers/pandora/parsers.py @@ -0,0 +1,300 @@ +"""Parsing utilities to convert Pandora API responses into Music Assistant model objects.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from music_assistant_models.enums import ContentType, ImageType, MediaType, StreamType +from music_assistant_models.media_items import ( + Album, + Artist, + AudioFormat, + ItemMapping, + MediaItemImage, + Playlist, + ProviderMapping, + SearchResults, + Track, +) +from music_assistant_models.streamdetails import StreamDetails + +from music_assistant.helpers.util import parse_title_and_version + +from .constants import ( + AUDIO_QUALITIES, + DEFAULT_AUDIO_QUALITY, +) +from .helpers import safe_get + +if TYPE_CHECKING: + from .provider import PandoraProvider + + +def parse_artist(artist_data: dict[str, Any], provider: PandoraProvider) -> Artist: + """Parse Pandora artist data into Music Assistant Artist object.""" + artist_id = str(artist_data.get("pandoraId", artist_data.get("artistId", ""))) + if not artist_id: + artist_id = str(artist_data.get("musicId", "")) + + artist = Artist( + item_id=artist_id, + provider=provider.lookup_key, + name=artist_data.get("artistName", "Unknown Artist"), + provider_mappings={ + ProviderMapping( + item_id=artist_id, + provider_domain=provider.domain, + provider_instance=provider.instance_id, + ) + }, + ) + + # Add artist image if available + if artist_art := safe_get(artist_data, "artistArt"): + artist.metadata.add_image( + MediaItemImage( + type=ImageType.THUMB, + path=artist_art, + provider=provider.lookup_key, + remotely_accessible=True, + ) + ) + + return artist + + +def parse_album(album_data: dict[str, Any], provider: PandoraProvider) -> Album: + """Parse Pandora album data into Music Assistant Album object.""" + album_id = str(album_data.get("pandoraId", album_data.get("albumId", ""))) + if not album_id: + album_id = str(album_data.get("musicId", "")) + + album_name = album_data.get("albumName", "Unknown Album") + name, version = parse_title_and_version(album_name) + + album = Album( + item_id=album_id, + provider=provider.lookup_key, + name=name, + version=version, + provider_mappings={ + ProviderMapping( + item_id=album_id, + provider_domain=provider.domain, + provider_instance=provider.instance_id, + ) + }, + ) + + # Add album artist if available + if artist_name := album_data.get("artistName"): + artist = ItemMapping( + item_id=str(album_data.get("artistId", "")), + provider=provider.lookup_key, + name=artist_name, + media_type=MediaType.ARTIST, + ) + album.artists.append(artist) + + # Add album art if available + if album_art := safe_get(album_data, "albumArt"): + album.metadata.add_image( + MediaItemImage( + type=ImageType.THUMB, + path=album_art, + provider=provider.lookup_key, + remotely_accessible=True, + ) + ) + + return album + + +def parse_track(track_data: dict[str, Any], provider: PandoraProvider) -> Track: + """Parse Pandora track data into Music Assistant Track object.""" + track_id = str(track_data.get("pandoraId", track_data.get("trackId", ""))) + if not track_id: + track_id = str(track_data.get("musicId", "")) + + track_name = track_data.get("songName", track_data.get("trackName", "Unknown Track")) + name, version = parse_title_and_version(track_name) + + # Get duration in milliseconds (Track expects int milliseconds) + duration_ms = 0 + if track_length := track_data.get("trackLength"): + # trackLength is usually in seconds already + duration_ms = int(track_length * 1000) # Convert to milliseconds + + track = Track( + item_id=track_id, + provider=provider.lookup_key, + name=name, + version=version, + duration=duration_ms, + provider_mappings={ + ProviderMapping( + item_id=track_id, + provider_domain=provider.domain, + provider_instance=provider.instance_id, + audio_format=AudioFormat( + content_type=ContentType.AAC, + bit_rate=128, # Pandora typically uses 128kbps + ), + available=True, + ) + }, + ) + + # Add artist information + if artist_name := track_data.get("artistName"): + artist = ItemMapping( + item_id=str(track_data.get("artistMusicId", track_data.get("artistId", ""))), + provider=provider.lookup_key, + name=artist_name, + media_type=MediaType.ARTIST, + ) + track.artists.append(artist) + + # Add album information if available + if album_name := track_data.get("albumTitle", track_data.get("albumName")): + album = ItemMapping( + item_id=str(track_data.get("albumId", "")), + provider=provider.lookup_key, + name=album_name, + media_type=MediaType.ALBUM, + ) + track.album = album + + # Add track art/album art if available + if track_art := safe_get(track_data, "albumArt"): + track.metadata.add_image( + MediaItemImage( + type=ImageType.THUMB, + path=track_art, + provider=provider.lookup_key, + remotely_accessible=True, + ) + ) + + # Set explicit flag if available + if explicit := track_data.get("explicit"): + track.metadata.explicit = explicit + + return track + + +def parse_station(station_data: dict[str, Any], provider: PandoraProvider) -> Playlist: + """Parse Pandora station data into Music Assistant Playlist object.""" + station_id = str(station_data.get("stationId", "")) + + # Stations in Pandora are represented as playlists in Music Assistant + station = Playlist( + item_id=station_id, + provider=provider.lookup_key, + name=station_data.get("stationName", "Unknown Station"), + owner="Pandora Radio", + provider_mappings={ + ProviderMapping( + item_id=station_id, + provider_domain=provider.domain, + provider_instance=provider.instance_id, + ) + }, + is_editable=False, # Pandora stations are not directly editable + ) + + # Add station art if available + if station_art := safe_get(station_data, "artUrl"): + station.metadata.add_image( + MediaItemImage( + type=ImageType.THUMB, + path=station_art, + provider=provider.lookup_key, + remotely_accessible=True, + ) + ) + + # Use station creation date as cache checksum if available + if date_created := station_data.get("dateCreated"): + station.cache_checksum = str(date_created) + + return station + + +def parse_search_results(search_data: dict[str, Any], provider: PandoraProvider) -> SearchResults: + """Parse Pandora search results into Music Assistant SearchResults object.""" + results = SearchResults() + + # Parse artists + artist_list = [] + if artists := safe_get(search_data, "artists"): + for artist_data in artists: + try: + artist_list.append(parse_artist(artist_data, provider)) + except Exception as e: + provider.logger.debug("Failed to parse artist: %s", e) + results.artists = artist_list + + # Parse albums + album_list = [] + if albums := safe_get(search_data, "albums"): + for album_data in albums: + try: + album_list.append(parse_album(album_data, provider)) + except Exception as e: + provider.logger.debug("Failed to parse album: %s", e) + results.albums = album_list + + # Parse tracks/songs + track_list = [] + if tracks := safe_get(search_data, "songs"): + for track_data in tracks: + try: + track_list.append(parse_track(track_data, provider)) + except Exception as e: + provider.logger.debug("Failed to parse track: %s", e) + results.tracks = track_list + + # Parse stations (as playlists) + playlist_list = [] + if stations := safe_get(search_data, "stations"): + for station_data in stations: + try: + playlist_list.append(parse_station(station_data, provider)) + except Exception as e: + provider.logger.debug("Failed to parse station: %s", e) + results.playlists = playlist_list + + return results + + +def create_stream_details(track_id: str, provider: PandoraProvider) -> StreamDetails: + """Create StreamDetails for a Pandora track.""" + # Get audio quality from provider config with proper type handling + quality_setting = provider.config.get_value("audio_quality", DEFAULT_AUDIO_QUALITY) + if not isinstance(quality_setting, str): + quality_setting = DEFAULT_AUDIO_QUALITY + + audio_quality = AUDIO_QUALITIES.get(quality_setting, AUDIO_QUALITIES[DEFAULT_AUDIO_QUALITY]) + content_type = ContentType.AAC if audio_quality["format"] == "AAC+" else ContentType.MP3 + + # Safely extract bitrate with type checking + bitrate_value = audio_quality["bitrate"] + if isinstance(bitrate_value, int): + bit_rate = bitrate_value + elif isinstance(bitrate_value, (float, str)): + bit_rate = int(bitrate_value) + else: + bit_rate = 128 # fallback default + + return StreamDetails( + item_id=track_id, + provider=provider.lookup_key, + audio_format=AudioFormat( + content_type=content_type, + bit_rate=bit_rate, + ), + stream_type=StreamType.HTTP, + allow_seek=False, # Pandora radio doesn't typically allow seeking + can_seek=False, + ) diff --git a/music_assistant/providers/pandora/provider.py b/music_assistant/providers/pandora/provider.py new file mode 100644 index 0000000000..5e9e31f76f --- /dev/null +++ b/music_assistant/providers/pandora/provider.py @@ -0,0 +1,581 @@ +"""Pandora radio provider.""" + +from __future__ import annotations + +import asyncio +import contextlib +import json +from collections.abc import AsyncGenerator, Sequence +from typing import Any + +import aiohttp +from aiohttp import web +from music_assistant_models.enums import ( + ContentType, + ImageType, + MediaType, + ProviderFeature, + StreamType, +) +from music_assistant_models.errors import LoginFailed, MediaNotFoundError, UnplayableMediaError +from music_assistant_models.media_items import ( + AudioFormat, + BrowseFolder, + ItemMapping, + MediaItemImage, + MediaItemType, + ProviderMapping, + Radio, +) +from music_assistant_models.streamdetails import StreamDetails, StreamMetadata + +from music_assistant.helpers.throttle_retry import ThrottlerManager, throttle_with_retries +from music_assistant.helpers.util import lock, select_free_port +from music_assistant.helpers.webserver import Webserver +from music_assistant.models.music_provider import MusicProvider + +from .constants import ( + AUDIO_QUALITIES, + CONF_PASSWORD, + CONF_USERNAME, + DEFAULT_AUDIO_QUALITY, + LOGIN_ENDPOINT, + PLAYLIST_FRAGMENT_ENDPOINT, + STATIONS_ENDPOINT, +) +from .helpers import create_auth_headers, get_csrf_token, handle_pandora_error + + +class PandoraProvider(MusicProvider): + """Implementation of a Pandora Radio Provider with sequential FFmpeg streaming.""" + + _auth_token: str | None = None + _csrf_token: str | None = None + _user_profile: dict[str, Any] | None = None + _station_fragments: dict[str, dict[str, Any]] = {} + + # Proxy server components + _proxy_server: Webserver | None = None + _proxy_port: int | None = None + _active_streams: dict[str, asyncio.Task[None]] = {} # station_id -> streaming task + _current_stream_details: dict[str, StreamDetails] = {} # station_id -> StreamDetails + + throttler: ThrottlerManager + + async def handle_async_init(self) -> None: + """Handle async initialization of the provider.""" + self.throttler = ThrottlerManager(rate_limit=10, period=60) + + try: + await self.login() + await self._setup_proxy_server() + except LoginFailed as e: + self.logger.error("Authentication failed: %s", e) + raise + except Exception as e: + self.logger.error("Failed to initialize Pandora provider: %s", e) + raise + + async def unload(self, is_removed: bool = False) -> None: + """Handle unload/close of the provider.""" + # Cancel all active streaming tasks + for task in self._active_streams.values(): + if not task.done(): + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + + self._active_streams.clear() + self._current_stream_details.clear() + + # Close proxy server + if self._proxy_server: + await self._proxy_server.close() + + async def _setup_proxy_server(self) -> None: + """Set up the local proxy server for streaming.""" + bind_ip = "127.0.0.1" + self._proxy_port = await select_free_port(8100, 9999) + + self._proxy_server = Webserver(self.logger) + + # Define the streaming endpoint + async def stream_handler(request: web.Request) -> web.StreamResponse: + return await self._handle_stream_request(request) + + await self._proxy_server.setup( + bind_ip=bind_ip, + bind_port=self._proxy_port, + base_url=f"{bind_ip}:{self._proxy_port}", + static_routes=[ + ("GET", "/pandora/{station_id}.mp3", stream_handler), + ], + ) + + self.logger.debug(f"Pandora proxy server running at {bind_ip}:{self._proxy_port}") + + async def _handle_stream_request(self, request: web.Request) -> web.StreamResponse: + """Handle a streaming request for a station.""" + station_id = request.match_info["station_id"] + + response = web.StreamResponse( + status=200, + headers={ + "Content-Type": "audio/mpeg", + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "Transfer-Encoding": "chunked", + "Accept-Ranges": "none", + }, + ) + await response.prepare(request) + + try: + # Start the FFmpeg streaming task for this station if not already running + if station_id not in self._active_streams: + self._active_streams[station_id] = self.mass.create_task( + self._stream_directly(station_id, response) + ) + + # Wait for the streaming task to complete + await self._active_streams[station_id] + + except Exception as e: + self.logger.error("Error in stream handler for station %s: %s", station_id, e) + finally: + # Clean up + if station_id in self._active_streams: + del self._active_streams[station_id] + + return response + + async def _stream_directly(self, station_id: str, response: web.StreamResponse) -> None: # noqa: PLR0915 + """Stream tracks directly in a continuous loop with larger initial buffer.""" + self.logger.info("Starting continuous streaming for station %s", station_id) + + last_track_id = None + + try: + fragment_data = await self._get_station_fragment(station_id, is_start=True) + if not fragment_data or not fragment_data.get("tracks"): + self.logger.warning("No initial tracks found for station %s", station_id) + return + + while True: + tracks = fragment_data.get("tracks", []) if fragment_data else [] + + if not tracks: + self.logger.debug("End of fragment, fetching next one.") + fragment_data = await self._get_station_fragment( + station_id, last_track_id=last_track_id + ) + tracks = ( + fragment_data.get("tracks", []) + if fragment_data and fragment_data.get("tracks") + else [] + ) + if not tracks: + self.logger.warning("Could not fetch new fragment, ending stream.") + break + + track_data = tracks.pop(0) + audio_url = track_data.get("audioURL") + + if not audio_url: + self.logger.warning("Track has no audio URL, skipping.") + continue + + self.logger.info( + "Now streaming: %s - %s", + track_data.get("artistName"), + track_data.get("songTitle"), + ) + last_track_id = track_data.get("trackId") + + await self._update_stream_metadata(station_id, track_data) + + try: + self.logger.debug("Attempting to connect to audio URL: %s", audio_url) + async with self.mass.http_session.get(audio_url) as track_response: + self.logger.debug( + "Connected to audio URL with status: %s", track_response.status + ) + if track_response.status != 200: + self.logger.error( + "Failed to fetch track audio from %s: status %d", + audio_url, + track_response.status, + ) + continue + + # Read a much larger chunk to handle the client's aggressive read timeout. + self.logger.debug("Reading first chunk for pre-buffering (256 KB).") + first_chunk = await track_response.content.read(262144) + if first_chunk: + self.logger.debug( + "Writing first chunk of %d bytes to stream.", len(first_chunk) + ) + await response.write(first_chunk) + self.logger.debug("Successfully wrote first chunk.") + + total_bytes_sent = len(first_chunk) + + self.logger.debug("Starting continuous stream of remaining chunks.") + async for chunk in track_response.content.iter_chunked(8192): + await response.write(chunk) + total_bytes_sent += len(chunk) + self.logger.debug( + "Wrote a chunk, total bytes sent: %d", total_bytes_sent + ) + + except (aiohttp.ClientError, ConnectionResetError) as e: + self.logger.error("Error fetching or writing track audio: %s", e) + self.logger.debug("Breaking streaming loop due to connection error.") + break + + except Exception as e: + self.logger.error( + "Error in continuous streaming loop for station %s: %s", station_id, e + ) + finally: + self.logger.info("Stopping continuous stream for station %s", station_id) + try: + await response.write_eof() + except (ConnectionResetError, RuntimeError): + self.logger.debug("Stream transport was already closed, nothing to do.") + except Exception as e: + self.logger.error("Error writing EOF to stream: %s", e) + + async def _update_stream_metadata(self, station_id: str, track_data: dict[str, Any]) -> None: + """Update stream metadata for the current track.""" + if station_id not in self._current_stream_details: + return + + stream_details = self._current_stream_details[station_id] + + # Create metadata for current track + title = track_data.get("songTitle", "Unknown Title") + artist = track_data.get("artistName", "Unknown Artist") + album = track_data.get("albumTitle") + + # Get album art + image_url = None + if album_art := track_data.get("albumArt"): + if isinstance(album_art, list) and album_art: + best_art = max(album_art, key=lambda x: x.get("size", 0)) + image_url = best_art.get("url") + + # Get duration + duration = None + if track_length := track_data.get("trackLength"): + duration = int(track_length * 1000) # Convert to milliseconds + + stream_metadata = StreamMetadata( + title=title, + artist=artist, + album=album, + image_url=image_url, + duration=duration, + ) + + stream_details.stream_metadata = stream_metadata + self.logger.debug("Updated metadata for station %s: %s - %s", station_id, artist, title) + + @property + def supported_features(self) -> set[ProviderFeature]: + """Return the features supported by this Provider.""" + return { + ProviderFeature.BROWSE, + ProviderFeature.LIBRARY_RADIOS, + } + + @property + def is_streaming_provider(self) -> bool: + """Return True if the provider is a streaming provider.""" + return True + + @property + def instance_name_postfix(self) -> str | None: + """Return a postfix for the instance name.""" + if self._user_profile: + username = self._user_profile.get("username") + return str(username) if username is not None else None + return None + + async def get_library_radios(self) -> AsyncGenerator[Radio, None]: + """Retrieve library/subscribed radio stations from the provider.""" + try: + stations_data = await self._api_request("POST", STATIONS_ENDPOINT, data={}) + stations = stations_data.get("stations", []) + + for station_data in stations: + try: + yield self._parse_radio(station_data) + except Exception as e: + self.logger.debug("Failed to parse station: %s", e) + + except Exception as e: + self.logger.error("Failed to retrieve stations: %s", e) + + async def get_radio(self, prov_radio_id: str) -> Radio: + """Get full radio details by id.""" + try: + stations_data = await self._api_request("POST", STATIONS_ENDPOINT, data={}) + stations = stations_data.get("stations", []) + + for station_data in stations: + if str(station_data.get("stationId")) == prov_radio_id: + return self._parse_radio(station_data) + + raise MediaNotFoundError(f"Radio station {prov_radio_id} not found") + + except Exception as e: + self.logger.error("Failed to get radio station %s: %s", prov_radio_id, e) + raise MediaNotFoundError(f"Radio station {prov_radio_id} not found") from e + + 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 UnplayableMediaError(f"Unsupported media type: {media_type}") + + # Create proxy URL for this station + proxy_url = f"http://127.0.0.1:{self._proxy_port}/pandora/{item_id}.mp3" + + # Get audio quality from config + quality_setting = self.config.get_value("audio_quality", DEFAULT_AUDIO_QUALITY) + if not isinstance(quality_setting, str): + quality_setting = DEFAULT_AUDIO_QUALITY + + audio_quality = AUDIO_QUALITIES.get(quality_setting, AUDIO_QUALITIES[DEFAULT_AUDIO_QUALITY]) + content_type = ContentType.MP3 # Always MP3 output from FFmpeg + + bitrate_value = audio_quality["bitrate"] + if isinstance(bitrate_value, int): + bit_rate = bitrate_value + elif isinstance(bitrate_value, (float, str)): + bit_rate = int(bitrate_value) + else: + bit_rate = 128 + + # Get initial metadata from first fragment + stream_metadata = None + try: + fragment_data = await self._get_station_fragment(item_id, is_start=True) + if fragment_data and fragment_data.get("tracks"): + first_track = fragment_data["tracks"][0] + stream_metadata = StreamMetadata( + title=first_track.get("songTitle", "Unknown Title"), + artist=first_track.get("artistName"), + album=first_track.get("albumTitle"), + duration=int(first_track.get("trackLength", 0) * 1000) + if first_track.get("trackLength") + else None, + ) + + if album_art := first_track.get("albumArt"): + if isinstance(album_art, list) and album_art: + best_art = max(album_art, key=lambda x: x.get("size", 0)) + stream_metadata.image_url = best_art.get("url") + + except Exception as e: + self.logger.debug("Failed to get initial metadata for %s: %s", item_id, e) + + stream_details = StreamDetails( + item_id=item_id, + provider=self.lookup_key, + audio_format=AudioFormat( + content_type=content_type, + bit_rate=bit_rate, + channels=2, + ), + media_type=MediaType.RADIO, + stream_type=StreamType.HTTP, # Use HTTP, not CUSTOM + path=proxy_url, # Direct URL to proxy + allow_seek=False, + can_seek=False, + duration=0, # Radio streams are infinite + stream_metadata=stream_metadata, + ) + + # Store reference for metadata updates + self._current_stream_details[item_id] = stream_details + + return stream_details + + async def browse(self, path: str) -> Sequence[MediaItemType | ItemMapping | BrowseFolder]: + """Browse this provider's items.""" + return [radio async for radio in self.get_library_radios()] + + def _parse_radio(self, station_data: dict[str, Any]) -> Radio: + """Create a Radio object from station data.""" + station_id = str(station_data.get("stationId", "")) + station_name = station_data.get("name", "Unknown Station") + + radio = Radio( + provider=self.lookup_key, + item_id=station_id, + name=station_name, + provider_mappings={ + ProviderMapping( + provider_domain=self.domain, + provider_instance=self.instance_id, + item_id=station_id, + available=True, + ) + }, + ) + + # Add station artwork if available + if art_list := station_data.get("art"): + if isinstance(art_list, list) and art_list: + best_art = max(art_list, key=lambda x: x.get("size", 0)) + radio.metadata.add_image( + MediaItemImage( + type=ImageType.THUMB, + path=best_art["url"], + provider=self.lookup_key, + remotely_accessible=True, + ) + ) + + return radio + + async def _get_station_fragment( + self, + station_id: str, + is_start: bool = False, + last_track_id: str | None = None, # 👈 Add this new parameter + ) -> dict[str, Any] | None: + """Get a fragment of tracks from a station.""" + fragment_data = { + "stationId": station_id, + "isStationStart": is_start, + "fragmentRequestReason": "Normal", + "audioFormat": "aacplus", + "startingAtTrackId": last_track_id, # 👈 Use the new parameter here + "onDemandArtistMessageArtistUidHex": None, + "onDemandArtistMessageIdHex": None, + } + + try: + return await self._api_request("POST", PLAYLIST_FRAGMENT_ENDPOINT, data=fragment_data) + except Exception as e: + self.logger.error("Failed to get fragment for station %s: %s", station_id, e) + return None + + @lock + async def login(self, force_refresh: bool = False) -> None: + """Authenticate with Pandora.""" + if not force_refresh and self._auth_token: + return + + username = self.config.get_value(CONF_USERNAME) + password = self.config.get_value(CONF_PASSWORD) + + try: + self._csrf_token = await get_csrf_token(self.mass.http_session) + + login_data = { + "username": username, + "password": password, + "keepLoggedIn": True, + "existingAuthToken": None, + } + + headers = create_auth_headers(self._csrf_token) + + async with self.mass.http_session.post( + LOGIN_ENDPOINT, + headers=headers, + data=json.dumps(login_data), + ssl=True, + ) as response: + if response.status != 200: + raise LoginFailed(f"Login request failed with status {response.status}") + + response_data = 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_profile = { + "username": response_data.get("username", username), + "listenerId": response_data.get("listenerId"), + } + + self.logger.info( + "Successfully logged in to Pandora as %s", self._user_profile["username"] + ) + + except LoginFailed: + raise + except Exception as e: + self.logger.error("Login failed: %s", e) + raise LoginFailed(f"Authentication failed: {e}") from e + + @throttle_with_retries + async def _api_request( + self, + method: str, + endpoint: str, + data: dict[str, Any] | None = None, + params: dict[str, Any] | None = None, + ) -> dict[str, Any]: + """Make authenticated API request to Pandora.""" + + def get_auth_headers() -> dict[str, str]: + if not self._auth_token or not self._csrf_token: + raise LoginFailed("Authentication failed - tokens are missing.") + return create_auth_headers(self._csrf_token, self._auth_token) + + async def perform_request(headers: dict[str, str]) -> aiohttp.ClientResponse: + request_kwargs: dict[str, Any] = { + "headers": headers, + "ssl": True, + } + if data is not None: + request_kwargs["data"] = json.dumps(data) + if params is not None: + request_kwargs["params"] = params + return await self.mass.http_session.request(method, endpoint, **request_kwargs) + + if not self._auth_token or not self._csrf_token: + await self.login() + + try: + async with await perform_request(get_auth_headers()) as response: + if response.status == 401: + await self.login(force_refresh=True) + async with await perform_request(get_auth_headers()) as retry_response: + if retry_response.status != 200: + error_text = await retry_response.text() + self.logger.error( + "API request failed with status %s: %s", + retry_response.status, + error_text, + ) + raise aiohttp.ClientError( + f"API request failed with status {retry_response.status}: " + f"{error_text}" + ) + response_data: dict[str, Any] = await retry_response.json() + elif response.status != 200: + error_text = await response.text() + self.logger.error( + "API request failed with status %s: %s", response.status, error_text + ) + raise aiohttp.ClientError( + f"API request failed with status {response.status}: {error_text}" + ) + else: + response_data = await response.json() + + handle_pandora_error(response_data) + return response_data + except aiohttp.ClientError: + raise + except Exception as e: + self.logger.error("API request failed: %s", e) + raise aiohttp.ClientError(f"Request failed: {e}") from e From ffa76625b7d6b8894968c97ca09f98b62f2aa46a Mon Sep 17 00:00:00 2001 From: Gav Date: Mon, 15 Sep 2025 22:18:15 +1000 Subject: [PATCH 02/18] Try and fix streaming --- music_assistant/providers/pandora/provider.py | 405 +++++++----------- 1 file changed, 162 insertions(+), 243 deletions(-) diff --git a/music_assistant/providers/pandora/provider.py b/music_assistant/providers/pandora/provider.py index 5e9e31f76f..0108d47520 100644 --- a/music_assistant/providers/pandora/provider.py +++ b/music_assistant/providers/pandora/provider.py @@ -1,15 +1,14 @@ -"""Pandora radio provider.""" +"""Pandora radio provider with custom streaming.""" from __future__ import annotations import asyncio -import contextlib import json +import time from collections.abc import AsyncGenerator, Sequence from typing import Any import aiohttp -from aiohttp import web from music_assistant_models.enums import ( ContentType, ImageType, @@ -30,8 +29,7 @@ from music_assistant_models.streamdetails import StreamDetails, StreamMetadata from music_assistant.helpers.throttle_retry import ThrottlerManager, throttle_with_retries -from music_assistant.helpers.util import lock, select_free_port -from music_assistant.helpers.webserver import Webserver +from music_assistant.helpers.util import lock from music_assistant.models.music_provider import MusicProvider from .constants import ( @@ -47,18 +45,13 @@ class PandoraProvider(MusicProvider): - """Implementation of a Pandora Radio Provider with sequential FFmpeg streaming.""" + """Implementation of a Pandora Radio Provider with custom streaming.""" _auth_token: str | None = None _csrf_token: str | None = None _user_profile: dict[str, Any] | None = None _station_fragments: dict[str, dict[str, Any]] = {} - - # Proxy server components - _proxy_server: Webserver | None = None - _proxy_port: int | None = None - _active_streams: dict[str, asyncio.Task[None]] = {} # station_id -> streaming task - _current_stream_details: dict[str, StreamDetails] = {} # station_id -> StreamDetails + _station_track_positions: dict[str, int] = {} # Track position in fragment per station throttler: ThrottlerManager @@ -68,7 +61,7 @@ async def handle_async_init(self) -> None: try: await self.login() - await self._setup_proxy_server() + except LoginFailed as e: self.logger.error("Authentication failed: %s", e) raise @@ -76,211 +69,6 @@ async def handle_async_init(self) -> None: self.logger.error("Failed to initialize Pandora provider: %s", e) raise - async def unload(self, is_removed: bool = False) -> None: - """Handle unload/close of the provider.""" - # Cancel all active streaming tasks - for task in self._active_streams.values(): - if not task.done(): - task.cancel() - with contextlib.suppress(asyncio.CancelledError): - await task - - self._active_streams.clear() - self._current_stream_details.clear() - - # Close proxy server - if self._proxy_server: - await self._proxy_server.close() - - async def _setup_proxy_server(self) -> None: - """Set up the local proxy server for streaming.""" - bind_ip = "127.0.0.1" - self._proxy_port = await select_free_port(8100, 9999) - - self._proxy_server = Webserver(self.logger) - - # Define the streaming endpoint - async def stream_handler(request: web.Request) -> web.StreamResponse: - return await self._handle_stream_request(request) - - await self._proxy_server.setup( - bind_ip=bind_ip, - bind_port=self._proxy_port, - base_url=f"{bind_ip}:{self._proxy_port}", - static_routes=[ - ("GET", "/pandora/{station_id}.mp3", stream_handler), - ], - ) - - self.logger.debug(f"Pandora proxy server running at {bind_ip}:{self._proxy_port}") - - async def _handle_stream_request(self, request: web.Request) -> web.StreamResponse: - """Handle a streaming request for a station.""" - station_id = request.match_info["station_id"] - - response = web.StreamResponse( - status=200, - headers={ - "Content-Type": "audio/mpeg", - "Cache-Control": "no-cache", - "Connection": "keep-alive", - "Transfer-Encoding": "chunked", - "Accept-Ranges": "none", - }, - ) - await response.prepare(request) - - try: - # Start the FFmpeg streaming task for this station if not already running - if station_id not in self._active_streams: - self._active_streams[station_id] = self.mass.create_task( - self._stream_directly(station_id, response) - ) - - # Wait for the streaming task to complete - await self._active_streams[station_id] - - except Exception as e: - self.logger.error("Error in stream handler for station %s: %s", station_id, e) - finally: - # Clean up - if station_id in self._active_streams: - del self._active_streams[station_id] - - return response - - async def _stream_directly(self, station_id: str, response: web.StreamResponse) -> None: # noqa: PLR0915 - """Stream tracks directly in a continuous loop with larger initial buffer.""" - self.logger.info("Starting continuous streaming for station %s", station_id) - - last_track_id = None - - try: - fragment_data = await self._get_station_fragment(station_id, is_start=True) - if not fragment_data or not fragment_data.get("tracks"): - self.logger.warning("No initial tracks found for station %s", station_id) - return - - while True: - tracks = fragment_data.get("tracks", []) if fragment_data else [] - - if not tracks: - self.logger.debug("End of fragment, fetching next one.") - fragment_data = await self._get_station_fragment( - station_id, last_track_id=last_track_id - ) - tracks = ( - fragment_data.get("tracks", []) - if fragment_data and fragment_data.get("tracks") - else [] - ) - if not tracks: - self.logger.warning("Could not fetch new fragment, ending stream.") - break - - track_data = tracks.pop(0) - audio_url = track_data.get("audioURL") - - if not audio_url: - self.logger.warning("Track has no audio URL, skipping.") - continue - - self.logger.info( - "Now streaming: %s - %s", - track_data.get("artistName"), - track_data.get("songTitle"), - ) - last_track_id = track_data.get("trackId") - - await self._update_stream_metadata(station_id, track_data) - - try: - self.logger.debug("Attempting to connect to audio URL: %s", audio_url) - async with self.mass.http_session.get(audio_url) as track_response: - self.logger.debug( - "Connected to audio URL with status: %s", track_response.status - ) - if track_response.status != 200: - self.logger.error( - "Failed to fetch track audio from %s: status %d", - audio_url, - track_response.status, - ) - continue - - # Read a much larger chunk to handle the client's aggressive read timeout. - self.logger.debug("Reading first chunk for pre-buffering (256 KB).") - first_chunk = await track_response.content.read(262144) - if first_chunk: - self.logger.debug( - "Writing first chunk of %d bytes to stream.", len(first_chunk) - ) - await response.write(first_chunk) - self.logger.debug("Successfully wrote first chunk.") - - total_bytes_sent = len(first_chunk) - - self.logger.debug("Starting continuous stream of remaining chunks.") - async for chunk in track_response.content.iter_chunked(8192): - await response.write(chunk) - total_bytes_sent += len(chunk) - self.logger.debug( - "Wrote a chunk, total bytes sent: %d", total_bytes_sent - ) - - except (aiohttp.ClientError, ConnectionResetError) as e: - self.logger.error("Error fetching or writing track audio: %s", e) - self.logger.debug("Breaking streaming loop due to connection error.") - break - - except Exception as e: - self.logger.error( - "Error in continuous streaming loop for station %s: %s", station_id, e - ) - finally: - self.logger.info("Stopping continuous stream for station %s", station_id) - try: - await response.write_eof() - except (ConnectionResetError, RuntimeError): - self.logger.debug("Stream transport was already closed, nothing to do.") - except Exception as e: - self.logger.error("Error writing EOF to stream: %s", e) - - async def _update_stream_metadata(self, station_id: str, track_data: dict[str, Any]) -> None: - """Update stream metadata for the current track.""" - if station_id not in self._current_stream_details: - return - - stream_details = self._current_stream_details[station_id] - - # Create metadata for current track - title = track_data.get("songTitle", "Unknown Title") - artist = track_data.get("artistName", "Unknown Artist") - album = track_data.get("albumTitle") - - # Get album art - image_url = None - if album_art := track_data.get("albumArt"): - if isinstance(album_art, list) and album_art: - best_art = max(album_art, key=lambda x: x.get("size", 0)) - image_url = best_art.get("url") - - # Get duration - duration = None - if track_length := track_data.get("trackLength"): - duration = int(track_length * 1000) # Convert to milliseconds - - stream_metadata = StreamMetadata( - title=title, - artist=artist, - album=album, - image_url=image_url, - duration=duration, - ) - - stream_details.stream_metadata = stream_metadata - self.logger.debug("Updated metadata for station %s: %s - %s", station_id, artist, title) - @property def supported_features(self) -> set[ProviderFeature]: """Return the features supported by this Provider.""" @@ -334,20 +122,17 @@ async def get_radio(self, prov_radio_id: str) -> Radio: raise MediaNotFoundError(f"Radio station {prov_radio_id} not found") from e async def get_stream_details(self, item_id: str, media_type: MediaType) -> StreamDetails: - """Get streamdetails for a radio station.""" + """Get streamdetails for a radio station using custom streaming.""" if media_type != MediaType.RADIO: raise UnplayableMediaError(f"Unsupported media type: {media_type}") - # Create proxy URL for this station - proxy_url = f"http://127.0.0.1:{self._proxy_port}/pandora/{item_id}.mp3" - # Get audio quality from config quality_setting = self.config.get_value("audio_quality", DEFAULT_AUDIO_QUALITY) if not isinstance(quality_setting, str): quality_setting = DEFAULT_AUDIO_QUALITY audio_quality = AUDIO_QUALITIES.get(quality_setting, AUDIO_QUALITIES[DEFAULT_AUDIO_QUALITY]) - content_type = ContentType.MP3 # Always MP3 output from FFmpeg + content_type = ContentType.AAC if audio_quality["format"] == "AAC+" else ContentType.MP3 bitrate_value = audio_quality["bitrate"] if isinstance(bitrate_value, int): @@ -357,7 +142,7 @@ async def get_stream_details(self, item_id: str, media_type: MediaType) -> Strea else: bit_rate = 128 - # Get initial metadata from first fragment + # Get initial metadata stream_metadata = None try: fragment_data = await self._get_station_fragment(item_id, is_start=True) @@ -371,16 +156,13 @@ async def get_stream_details(self, item_id: str, media_type: MediaType) -> Strea if first_track.get("trackLength") else None, ) - - if album_art := first_track.get("albumArt"): - if isinstance(album_art, list) and album_art: - best_art = max(album_art, key=lambda x: x.get("size", 0)) - stream_metadata.image_url = best_art.get("url") - except Exception as e: self.logger.debug("Failed to get initial metadata for %s: %s", item_id, e) - stream_details = StreamDetails( + # Initialize position tracking + self._station_track_positions[item_id] = 0 + + return StreamDetails( item_id=item_id, provider=self.lookup_key, audio_format=AudioFormat( @@ -389,18 +171,155 @@ async def get_stream_details(self, item_id: str, media_type: MediaType) -> Strea channels=2, ), media_type=MediaType.RADIO, - stream_type=StreamType.HTTP, # Use HTTP, not CUSTOM - path=proxy_url, # Direct URL to proxy + stream_type=StreamType.CUSTOM, # Back to custom streaming allow_seek=False, can_seek=False, - duration=0, # Radio streams are infinite + duration=0, # Infinite radio stream stream_metadata=stream_metadata, ) - # Store reference for metadata updates - self._current_stream_details[item_id] = stream_details + async def get_audio_stream( # noqa: PLR0915 + self, streamdetails: StreamDetails, seek_position: int = 0 + ) -> AsyncGenerator[bytes, None]: + """Stream tracks in real-time without pre-buffering.""" + station_id = streamdetails.item_id + + self.logger.info("Starting real-time radio stream for station %s", station_id) - return stream_details + # Get target bitrate for throttling + target_bitrate = streamdetails.audio_format.bit_rate or 128 # kbps + bytes_per_second = (target_bitrate * 1000) / 8 # Convert to bytes/second + + try: + while True: + # Get current fragment + fragment_data = self._station_fragments.get(station_id) + if not fragment_data: + fragment_data = await self._get_station_fragment(station_id, is_start=True) + if not fragment_data: + await asyncio.sleep(5) + continue + + if not fragment_data.get("tracks"): + await asyncio.sleep(5) + continue + + tracks = fragment_data["tracks"] + current_position = self._station_track_positions.get(station_id, 0) + + # Get next fragment if needed + if current_position >= len(tracks): + fragment_data = await self._get_station_fragment(station_id, is_start=False) + if not fragment_data or not fragment_data.get("tracks"): + await asyncio.sleep(5) + continue + tracks = fragment_data["tracks"] + current_position = 0 + self._station_track_positions[station_id] = 0 + + # Get current track + track_data = tracks[current_position] + audio_url = track_data.get("audioURL") + + if not audio_url: + # Add silence for missing tracks + silence_duration = 10 # seconds + silence_bytes_total = int(bytes_per_second * silence_duration) + chunk_size = 8192 + + for i in range(0, silence_bytes_total, chunk_size): + chunk = b"\x00" * min(chunk_size, silence_bytes_total - i) + yield chunk + await asyncio.sleep(chunk_size / bytes_per_second) + + self._station_track_positions[station_id] = current_position + 1 + continue + + track_info = ( + f"{track_data.get('artistName', 'Unknown')} - " + f"{track_data.get('songTitle', 'Unknown')}" + ) + track_length = track_data.get("trackLength", 0) + + self.logger.info( + "Now streaming: %s - Duration: %s seconds", track_info, track_length + ) + + try: + track_start_time = time.time() + total_bytes_sent = 0 + + async with self.mass.http_session.get(audio_url) as response: + if response.status != 200: + self.logger.warning( + "Failed to get audio for %s, status: %s", + track_info, + response.status, + ) + # Add silence for failed tracks + silence_duration = 10 + silence_bytes_total = int(bytes_per_second * silence_duration) + chunk_size = 8192 + + for i in range(0, silence_bytes_total, chunk_size): + chunk = b"\x00" * min(chunk_size, silence_bytes_total - i) + yield chunk + await asyncio.sleep(chunk_size / bytes_per_second) + + self._station_track_positions[station_id] = current_position + 1 + continue + + # Stream in real-time with proper throttling + async for chunk in response.content.iter_chunked(8192): + if not chunk: + break + + # Send the chunk immediately + yield chunk + total_bytes_sent += len(chunk) + + # Calculate actual elapsed time + elapsed_time = time.time() - track_start_time + expected_time = total_bytes_sent / bytes_per_second + + # If we're ahead of schedule, sleep + if elapsed_time < expected_time: + sleep_time = expected_time - elapsed_time + await asyncio.sleep(sleep_time) + + # Track completed + actual_duration = time.time() - track_start_time + self.logger.info( + "Completed streaming %s in %.1f seconds (expected: %s)", + track_info, + actual_duration, + track_length, + ) + + except asyncio.CancelledError: + self.logger.info("Stream cancelled for %s", track_info) + raise + except Exception as e: + self.logger.error("Error streaming %s: %s", track_info, e) + # Add silence for error recovery + silence_duration = 5 + silence_bytes_total = int(bytes_per_second * silence_duration) + chunk_size = 8192 + + for i in range(0, silence_bytes_total, chunk_size): + chunk = b"\x00" * min(chunk_size, silence_bytes_total - i) + yield chunk + await asyncio.sleep(chunk_size / bytes_per_second) + + # Move to next track + self._station_track_positions[station_id] = current_position + 1 + + except asyncio.CancelledError: + self.logger.info("Audio stream cancelled for station %s", station_id) + raise + except Exception as e: + self.logger.error("Error in audio stream for station %s: %s", station_id, e) + raise async def browse(self, path: str) -> Sequence[MediaItemType | ItemMapping | BrowseFolder]: """Browse this provider's items.""" @@ -441,10 +360,7 @@ def _parse_radio(self, station_data: dict[str, Any]) -> Radio: return radio async def _get_station_fragment( - self, - station_id: str, - is_start: bool = False, - last_track_id: str | None = None, # 👈 Add this new parameter + self, station_id: str, is_start: bool = False ) -> dict[str, Any] | None: """Get a fragment of tracks from a station.""" fragment_data = { @@ -452,13 +368,16 @@ async def _get_station_fragment( "isStationStart": is_start, "fragmentRequestReason": "Normal", "audioFormat": "aacplus", - "startingAtTrackId": last_track_id, # 👈 Use the new parameter here + "startingAtTrackId": None, "onDemandArtistMessageArtistUidHex": None, "onDemandArtistMessageIdHex": None, } try: - return await self._api_request("POST", PLAYLIST_FRAGMENT_ENDPOINT, data=fragment_data) + result = await self._api_request("POST", PLAYLIST_FRAGMENT_ENDPOINT, data=fragment_data) + # Cache the fragment for this station + self._station_fragments[station_id] = result + return result except Exception as e: self.logger.error("Failed to get fragment for station %s: %s", station_id, e) return None From 5b445e873c8a99e102d2c6a97f3d5f01cbfe3997 Mon Sep 17 00:00:00 2001 From: Gav Date: Sat, 27 Sep 2025 23:10:28 +1000 Subject: [PATCH 03/18] more drafting --- music_assistant/providers/pandora/__init__.py | 26 +- .../providers/pandora/constants.py | 10 - music_assistant/providers/pandora/provider.py | 258 ++++++------------ 3 files changed, 87 insertions(+), 207 deletions(-) diff --git a/music_assistant/providers/pandora/__init__.py b/music_assistant/providers/pandora/__init__.py index 873377a237..cd6789fa40 100644 --- a/music_assistant/providers/pandora/__init__.py +++ b/music_assistant/providers/pandora/__init__.py @@ -4,11 +4,11 @@ from typing import TYPE_CHECKING -from music_assistant_models.config_entries import ConfigEntry, ConfigValueOption, ConfigValueType -from music_assistant_models.enums import ConfigEntryType +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 .constants import CONF_AUDIO_QUALITY, CONF_PASSWORD, CONF_USERNAME +from .constants import CONF_PASSWORD, CONF_USERNAME from .provider import PandoraProvider if TYPE_CHECKING: @@ -18,6 +18,12 @@ 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 @@ -37,7 +43,7 @@ async def setup( ): raise SetupFailedError("Username and password are required") - return PandoraProvider(mass, manifest, config) + return PandoraProvider(mass, manifest, config, SUPPORTED_FEATURES) async def get_config_entries( @@ -63,16 +69,4 @@ async def get_config_entries( description="Your Pandora password", required=True, ), - ConfigEntry( - key=CONF_AUDIO_QUALITY, - type=ConfigEntryType.STRING, - label="Audio Quality", - description="Preferred audio quality (requires Premium subscription for high quality)", - default_value="high", - options=[ - ConfigValueOption("Low (64 kbps AAC+)", "low"), - ConfigValueOption("Medium (128 kbps MP3)", "medium"), - ConfigValueOption("High (192 kbps AAC+) - Premium only", "high"), - ], - ), ) diff --git a/music_assistant/providers/pandora/constants.py b/music_assistant/providers/pandora/constants.py index decb2603ea..dd3af9c2ea 100644 --- a/music_assistant/providers/pandora/constants.py +++ b/music_assistant/providers/pandora/constants.py @@ -2,8 +2,6 @@ from __future__ import annotations -from music_assistant_models.enums import ProviderFeature - # Configuration Keys CONF_USERNAME = "username" CONF_PASSWORD = "password" @@ -25,14 +23,6 @@ "User-Agent": "Music Assistant Pandora Provider/1.0", } -# Supported Features - Pandora is primarily a radio service -SUPPORTED_FEATURES = { - ProviderFeature.SEARCH, - ProviderFeature.BROWSE, - # Pandora doesn't support traditional library features - # as it's a radio service, but we can implement stations as playlists -} - # API Limits MAX_SEARCH_RESULTS = 50 MAX_STATION_TRACKS = 100 diff --git a/music_assistant/providers/pandora/provider.py b/music_assistant/providers/pandora/provider.py index 0108d47520..e08b42e51f 100644 --- a/music_assistant/providers/pandora/provider.py +++ b/music_assistant/providers/pandora/provider.py @@ -1,10 +1,9 @@ -"""Pandora radio provider with custom streaming.""" +"""Pandora radio provider with single track streaming.""" from __future__ import annotations import asyncio import json -import time from collections.abc import AsyncGenerator, Sequence from typing import Any @@ -13,7 +12,6 @@ ContentType, ImageType, MediaType, - ProviderFeature, StreamType, ) from music_assistant_models.errors import LoginFailed, MediaNotFoundError, UnplayableMediaError @@ -33,10 +31,8 @@ from music_assistant.models.music_provider import MusicProvider from .constants import ( - AUDIO_QUALITIES, CONF_PASSWORD, CONF_USERNAME, - DEFAULT_AUDIO_QUALITY, LOGIN_ENDPOINT, PLAYLIST_FRAGMENT_ENDPOINT, STATIONS_ENDPOINT, @@ -45,7 +41,7 @@ class PandoraProvider(MusicProvider): - """Implementation of a Pandora Radio Provider with custom streaming.""" + """Implementation of a Pandora Radio Provider with single track streaming.""" _auth_token: str | None = None _csrf_token: str | None = None @@ -69,27 +65,6 @@ async def handle_async_init(self) -> None: self.logger.error("Failed to initialize Pandora provider: %s", e) raise - @property - def supported_features(self) -> set[ProviderFeature]: - """Return the features supported by this Provider.""" - return { - ProviderFeature.BROWSE, - ProviderFeature.LIBRARY_RADIOS, - } - - @property - def is_streaming_provider(self) -> bool: - """Return True if the provider is a streaming provider.""" - return True - - @property - def instance_name_postfix(self) -> str | None: - """Return a postfix for the instance name.""" - if self._user_profile: - username = self._user_profile.get("username") - return str(username) if username is not None else None - return None - async def get_library_radios(self) -> AsyncGenerator[Radio, None]: """Retrieve library/subscribed radio stations from the provider.""" try: @@ -126,42 +101,29 @@ async def get_stream_details(self, item_id: str, media_type: MediaType) -> Strea if media_type != MediaType.RADIO: raise UnplayableMediaError(f"Unsupported media type: {media_type}") - # Get audio quality from config - quality_setting = self.config.get_value("audio_quality", DEFAULT_AUDIO_QUALITY) - if not isinstance(quality_setting, str): - quality_setting = DEFAULT_AUDIO_QUALITY - - audio_quality = AUDIO_QUALITIES.get(quality_setting, AUDIO_QUALITIES[DEFAULT_AUDIO_QUALITY]) - content_type = ContentType.AAC if audio_quality["format"] == "AAC+" else ContentType.MP3 - - bitrate_value = audio_quality["bitrate"] - if isinstance(bitrate_value, int): - bit_rate = bitrate_value - elif isinstance(bitrate_value, (float, str)): - bit_rate = int(bitrate_value) - else: - bit_rate = 128 + # Fixed to HIGH quality (192 kbps AAC+) + content_type = ContentType.AAC + bit_rate = 192 # Get initial metadata stream_metadata = None try: fragment_data = await self._get_station_fragment(item_id, is_start=True) if fragment_data and fragment_data.get("tracks"): - first_track = fragment_data["tracks"][0] - stream_metadata = StreamMetadata( - title=first_track.get("songTitle", "Unknown Title"), - artist=first_track.get("artistName"), - album=first_track.get("albumTitle"), - duration=int(first_track.get("trackLength", 0) * 1000) - if first_track.get("trackLength") - else None, - ) + current_position = self._station_track_positions.get(item_id, 0) + if current_position < len(fragment_data["tracks"]): + current_track = fragment_data["tracks"][current_position] + stream_metadata = StreamMetadata( + title=current_track.get("songTitle", "Unknown Title"), + artist=current_track.get("artistName"), + album=current_track.get("albumTitle"), + duration=int(current_track.get("trackLength", 0) * 1000) + if current_track.get("trackLength") + else None, + ) except Exception as e: self.logger.debug("Failed to get initial metadata for %s: %s", item_id, e) - # Initialize position tracking - self._station_track_positions[item_id] = 0 - return StreamDetails( item_id=item_id, provider=self.lookup_key, @@ -171,154 +133,88 @@ async def get_stream_details(self, item_id: str, media_type: MediaType) -> Strea channels=2, ), media_type=MediaType.RADIO, - stream_type=StreamType.CUSTOM, # Back to custom streaming + stream_type=StreamType.CUSTOM, allow_seek=False, can_seek=False, duration=0, # Infinite radio stream stream_metadata=stream_metadata, ) - async def get_audio_stream( # noqa: PLR0915 + async def get_audio_stream( self, streamdetails: StreamDetails, seek_position: int = 0 ) -> AsyncGenerator[bytes, None]: - """Stream tracks in real-time without pre-buffering.""" + """Stream a single track from the radio station.""" station_id = streamdetails.item_id - self.logger.info("Starting real-time radio stream for station %s", station_id) + # Get current fragment and position + fragment_data = self._station_fragments.get(station_id) + current_position = self._station_track_positions.get(station_id, 0) - # Get target bitrate for throttling - target_bitrate = streamdetails.audio_format.bit_rate or 128 # kbps - bytes_per_second = (target_bitrate * 1000) / 8 # Convert to bytes/second + # Get new fragment if needed + if not fragment_data or not fragment_data.get("tracks"): + fragment_data = await self._get_station_fragment(station_id, is_start=True) + current_position = 0 - try: - while True: - # Get current fragment - fragment_data = self._station_fragments.get(station_id) - if not fragment_data: - fragment_data = await self._get_station_fragment(station_id, is_start=True) - if not fragment_data: - await asyncio.sleep(5) - continue - - if not fragment_data.get("tracks"): - await asyncio.sleep(5) - continue - - tracks = fragment_data["tracks"] - current_position = self._station_track_positions.get(station_id, 0) - - # Get next fragment if needed - if current_position >= len(tracks): - fragment_data = await self._get_station_fragment(station_id, is_start=False) - if not fragment_data or not fragment_data.get("tracks"): - await asyncio.sleep(5) - continue - tracks = fragment_data["tracks"] - current_position = 0 - self._station_track_positions[station_id] = 0 - - # Get current track - track_data = tracks[current_position] - audio_url = track_data.get("audioURL") - - if not audio_url: - # Add silence for missing tracks - silence_duration = 10 # seconds - silence_bytes_total = int(bytes_per_second * silence_duration) - chunk_size = 8192 - - for i in range(0, silence_bytes_total, chunk_size): - chunk = b"\x00" * min(chunk_size, silence_bytes_total - i) - yield chunk - await asyncio.sleep(chunk_size / bytes_per_second) - - self._station_track_positions[station_id] = current_position + 1 - continue - - track_info = ( - f"{track_data.get('artistName', 'Unknown')} - " - f"{track_data.get('songTitle', 'Unknown')}" - ) - track_length = track_data.get("trackLength", 0) + # Check if we need a new fragment (position beyond current tracks) + if fragment_data and current_position >= len(fragment_data.get("tracks", [])): + fragment_data = await self._get_station_fragment(station_id, is_start=False) + current_position = 0 - self.logger.info( - "Now streaming: %s - Duration: %s seconds", track_info, track_length - ) + if not fragment_data or not fragment_data.get("tracks"): + self.logger.error("No tracks available for station %s", station_id) + return - try: - track_start_time = time.time() - total_bytes_sent = 0 - - async with self.mass.http_session.get(audio_url) as response: - if response.status != 200: - self.logger.warning( - "Failed to get audio for %s, status: %s", - track_info, - response.status, - ) - # Add silence for failed tracks - silence_duration = 10 - silence_bytes_total = int(bytes_per_second * silence_duration) - chunk_size = 8192 - - for i in range(0, silence_bytes_total, chunk_size): - chunk = b"\x00" * min(chunk_size, silence_bytes_total - i) - yield chunk - await asyncio.sleep(chunk_size / bytes_per_second) - - self._station_track_positions[station_id] = current_position + 1 - continue - - # Stream in real-time with proper throttling - async for chunk in response.content.iter_chunked(8192): - if not chunk: - break - - # Send the chunk immediately - yield chunk - total_bytes_sent += len(chunk) - - # Calculate actual elapsed time - elapsed_time = time.time() - track_start_time - expected_time = total_bytes_sent / bytes_per_second - - # If we're ahead of schedule, sleep - if elapsed_time < expected_time: - sleep_time = expected_time - elapsed_time - await asyncio.sleep(sleep_time) - - # Track completed - actual_duration = time.time() - track_start_time - self.logger.info( - "Completed streaming %s in %.1f seconds (expected: %s)", - track_info, - actual_duration, - track_length, - ) + tracks = fragment_data["tracks"] + if current_position >= len(tracks): + self.logger.error( + "Track position %s beyond available tracks for station %s", + current_position, + station_id, + ) + return - except asyncio.CancelledError: - self.logger.info("Stream cancelled for %s", track_info) - raise - except Exception as e: - self.logger.error("Error streaming %s: %s", track_info, e) - # Add silence for error recovery - silence_duration = 5 - silence_bytes_total = int(bytes_per_second * silence_duration) - chunk_size = 8192 + # Get the current track + track_data = tracks[current_position] + audio_url = track_data.get("audioURL") + + if not audio_url: + self.logger.warning( + "No audio URL for track at position %s in station %s", current_position, station_id + ) + return + + track_info = ( + f"{track_data.get('artistName', 'Unknown')} - {track_data.get('songTitle', 'Unknown')}" + ) + track_length = track_data.get("trackLength", 0) + + self.logger.info("Streaming single track: %s (%.1f seconds)", track_info, track_length) + + # Update position for next call + self._station_track_positions[station_id] = current_position + 1 + + try: + # Stream the single track's audio + async with self.mass.http_session.get(audio_url) as response: + if response.status != 200: + self.logger.error( + "Failed to get audio for %s, status: %s", track_info, response.status + ) + return - for i in range(0, silence_bytes_total, chunk_size): - chunk = b"\x00" * min(chunk_size, silence_bytes_total - i) - yield chunk - await asyncio.sleep(chunk_size / bytes_per_second) + # Stream the audio data + async for chunk in response.content.iter_chunked(8192): + if not chunk: + break + yield chunk - # Move to next track - self._station_track_positions[station_id] = current_position + 1 + self.logger.info("Completed streaming track: %s", track_info) except asyncio.CancelledError: - self.logger.info("Audio stream cancelled for station %s", station_id) + self.logger.info("Stream cancelled for track: %s", track_info) raise except Exception as e: - self.logger.error("Error in audio stream for station %s: %s", station_id, e) + self.logger.error("Error streaming track %s: %s", track_info, e) raise async def browse(self, path: str) -> Sequence[MediaItemType | ItemMapping | BrowseFolder]: From 51fd49233e3b92a84bad4aba809431142c2c2cc6 Mon Sep 17 00:00:00 2001 From: Gav Date: Mon, 15 Sep 2025 17:00:16 +1000 Subject: [PATCH 04/18] Initial Commit --- music_assistant/providers/pandora/__init__.py | 78 +++ .../providers/pandora/constants.py | 82 +++ music_assistant/providers/pandora/helpers.py | 91 +++ music_assistant/providers/pandora/icon.svg | 46 ++ .../providers/pandora/icon_monochrome.svg | 46 ++ .../providers/pandora/manifest.json | 10 + music_assistant/providers/pandora/parsers.py | 300 +++++++++ music_assistant/providers/pandora/provider.py | 581 ++++++++++++++++++ 8 files changed, 1234 insertions(+) create mode 100644 music_assistant/providers/pandora/__init__.py create mode 100644 music_assistant/providers/pandora/constants.py create mode 100644 music_assistant/providers/pandora/helpers.py create mode 100644 music_assistant/providers/pandora/icon.svg create mode 100644 music_assistant/providers/pandora/icon_monochrome.svg create mode 100644 music_assistant/providers/pandora/manifest.json create mode 100644 music_assistant/providers/pandora/parsers.py create mode 100644 music_assistant/providers/pandora/provider.py diff --git a/music_assistant/providers/pandora/__init__.py b/music_assistant/providers/pandora/__init__.py new file mode 100644 index 0000000000..873377a237 --- /dev/null +++ b/music_assistant/providers/pandora/__init__.py @@ -0,0 +1,78 @@ +"""Pandora music provider support for Music Assistant.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from music_assistant_models.config_entries import ConfigEntry, ConfigValueOption, ConfigValueType +from music_assistant_models.enums import ConfigEntryType +from music_assistant_models.errors import SetupFailedError + +from .constants import CONF_AUDIO_QUALITY, 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 + + +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) + + +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, + ), + ConfigEntry( + key=CONF_AUDIO_QUALITY, + type=ConfigEntryType.STRING, + label="Audio Quality", + description="Preferred audio quality (requires Premium subscription for high quality)", + default_value="high", + options=[ + ConfigValueOption("Low (64 kbps AAC+)", "low"), + ConfigValueOption("Medium (128 kbps MP3)", "medium"), + ConfigValueOption("High (192 kbps AAC+) - Premium only", "high"), + ], + ), + ) diff --git a/music_assistant/providers/pandora/constants.py b/music_assistant/providers/pandora/constants.py new file mode 100644 index 0000000000..decb2603ea --- /dev/null +++ b/music_assistant/providers/pandora/constants.py @@ -0,0 +1,82 @@ +"""Constants for the Pandora provider.""" + +from __future__ import annotations + +from music_assistant_models.enums import ProviderFeature + +# Configuration Keys +CONF_USERNAME = "username" +CONF_PASSWORD = "password" +CONF_AUDIO_QUALITY = "audio_quality" + +# 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" +STATION_DETAILS_ENDPOINT = f"{API_BASE_URL}/station/getStationDetails" +PLAYLIST_FRAGMENT_ENDPOINT = f"{API_BASE_URL}/playlist/getFragment" +PROFILE_ENDPOINT = f"{API_BASE_URL}/listener/getProfile" +SEARCH_ENDPOINT = f"{API_BASE_URL}/search/search" +TRACK_FEEDBACK_ENDPOINT = f"{API_BASE_URL}/station/addFeedback" + +# Request Headers +DEFAULT_HEADERS = { + "Content-Type": "application/json;charset=utf-8", + "User-Agent": "Music Assistant Pandora Provider/1.0", +} + +# Supported Features - Pandora is primarily a radio service +SUPPORTED_FEATURES = { + ProviderFeature.SEARCH, + ProviderFeature.BROWSE, + # Pandora doesn't support traditional library features + # as it's a radio service, but we can implement stations as playlists +} + +# API Limits +MAX_SEARCH_RESULTS = 50 +MAX_STATION_TRACKS = 100 +DEFAULT_PAGE_SIZE = 50 + +# Audio Quality Settings +AUDIO_QUALITIES = { + "low": {"bitrate": 64, "format": "AAC+"}, # Free tier + "medium": {"bitrate": 128, "format": "MP3"}, # In-home devices + "high": {"bitrate": 192, "format": "AAC+"}, # Premium web/desktop +} + +DEFAULT_AUDIO_QUALITY = "high" # Assume premium subscription + +# 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..4fc795c9a3 --- /dev/null +++ b/music_assistant/providers/pandora/helpers.py @@ -0,0 +1,91 @@ +"""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, + 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.""" + 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 RuntimeError(f"Pandora API error {error_code} ({error_desc}): {message}") + + +async def get_csrf_token(session: aiohttp.ClientSession) -> str: + """Get CSRF token from Pandora website.""" + try: + async with session.head("https://www.pandora.com/") as response: + # Try to extract from cookies first + if "csrftoken" in response.cookies: + return str(response.cookies["csrftoken"].value) + except aiohttp.ClientError as e: + # Network issues - this is temporarily unavailable + raise ResourceTemporarilyUnavailable(f"Failed to get CSRF token from Pandora: {e}") + except Exception as e: + # Unexpected errors should also be treated as temporary issues + raise ResourceTemporarilyUnavailable(f"Unexpected error getting CSRF token: {e}") + + # If we get here, no CSRF token was found in cookies + return generate_csrf_token() + + +def create_auth_headers(csrf_token: str, auth_token: str | None = None) -> dict[str, str]: + """Create authentication headers for Pandora API requests.""" + 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 + + +def format_duration(duration_ms: int | None) -> float: + """Convert duration from milliseconds to seconds.""" + if duration_ms is None: + return 0.0 + return duration_ms / 1000.0 + + +def safe_get(data: dict[str, Any], *keys: str, default: Any = None) -> Any: + """Safely get nested dictionary values.""" + for key in keys: + if isinstance(data, dict) and key in data: + data = data[key] + else: + return default + return data 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..d0b04f066b --- /dev/null +++ b/music_assistant/providers/pandora/manifest.json @@ -0,0 +1,10 @@ +{ + "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 +} diff --git a/music_assistant/providers/pandora/parsers.py b/music_assistant/providers/pandora/parsers.py new file mode 100644 index 0000000000..67e7cb7963 --- /dev/null +++ b/music_assistant/providers/pandora/parsers.py @@ -0,0 +1,300 @@ +"""Parsing utilities to convert Pandora API responses into Music Assistant model objects.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from music_assistant_models.enums import ContentType, ImageType, MediaType, StreamType +from music_assistant_models.media_items import ( + Album, + Artist, + AudioFormat, + ItemMapping, + MediaItemImage, + Playlist, + ProviderMapping, + SearchResults, + Track, +) +from music_assistant_models.streamdetails import StreamDetails + +from music_assistant.helpers.util import parse_title_and_version + +from .constants import ( + AUDIO_QUALITIES, + DEFAULT_AUDIO_QUALITY, +) +from .helpers import safe_get + +if TYPE_CHECKING: + from .provider import PandoraProvider + + +def parse_artist(artist_data: dict[str, Any], provider: PandoraProvider) -> Artist: + """Parse Pandora artist data into Music Assistant Artist object.""" + artist_id = str(artist_data.get("pandoraId", artist_data.get("artistId", ""))) + if not artist_id: + artist_id = str(artist_data.get("musicId", "")) + + artist = Artist( + item_id=artist_id, + provider=provider.lookup_key, + name=artist_data.get("artistName", "Unknown Artist"), + provider_mappings={ + ProviderMapping( + item_id=artist_id, + provider_domain=provider.domain, + provider_instance=provider.instance_id, + ) + }, + ) + + # Add artist image if available + if artist_art := safe_get(artist_data, "artistArt"): + artist.metadata.add_image( + MediaItemImage( + type=ImageType.THUMB, + path=artist_art, + provider=provider.lookup_key, + remotely_accessible=True, + ) + ) + + return artist + + +def parse_album(album_data: dict[str, Any], provider: PandoraProvider) -> Album: + """Parse Pandora album data into Music Assistant Album object.""" + album_id = str(album_data.get("pandoraId", album_data.get("albumId", ""))) + if not album_id: + album_id = str(album_data.get("musicId", "")) + + album_name = album_data.get("albumName", "Unknown Album") + name, version = parse_title_and_version(album_name) + + album = Album( + item_id=album_id, + provider=provider.lookup_key, + name=name, + version=version, + provider_mappings={ + ProviderMapping( + item_id=album_id, + provider_domain=provider.domain, + provider_instance=provider.instance_id, + ) + }, + ) + + # Add album artist if available + if artist_name := album_data.get("artistName"): + artist = ItemMapping( + item_id=str(album_data.get("artistId", "")), + provider=provider.lookup_key, + name=artist_name, + media_type=MediaType.ARTIST, + ) + album.artists.append(artist) + + # Add album art if available + if album_art := safe_get(album_data, "albumArt"): + album.metadata.add_image( + MediaItemImage( + type=ImageType.THUMB, + path=album_art, + provider=provider.lookup_key, + remotely_accessible=True, + ) + ) + + return album + + +def parse_track(track_data: dict[str, Any], provider: PandoraProvider) -> Track: + """Parse Pandora track data into Music Assistant Track object.""" + track_id = str(track_data.get("pandoraId", track_data.get("trackId", ""))) + if not track_id: + track_id = str(track_data.get("musicId", "")) + + track_name = track_data.get("songName", track_data.get("trackName", "Unknown Track")) + name, version = parse_title_and_version(track_name) + + # Get duration in milliseconds (Track expects int milliseconds) + duration_ms = 0 + if track_length := track_data.get("trackLength"): + # trackLength is usually in seconds already + duration_ms = int(track_length * 1000) # Convert to milliseconds + + track = Track( + item_id=track_id, + provider=provider.lookup_key, + name=name, + version=version, + duration=duration_ms, + provider_mappings={ + ProviderMapping( + item_id=track_id, + provider_domain=provider.domain, + provider_instance=provider.instance_id, + audio_format=AudioFormat( + content_type=ContentType.AAC, + bit_rate=128, # Pandora typically uses 128kbps + ), + available=True, + ) + }, + ) + + # Add artist information + if artist_name := track_data.get("artistName"): + artist = ItemMapping( + item_id=str(track_data.get("artistMusicId", track_data.get("artistId", ""))), + provider=provider.lookup_key, + name=artist_name, + media_type=MediaType.ARTIST, + ) + track.artists.append(artist) + + # Add album information if available + if album_name := track_data.get("albumTitle", track_data.get("albumName")): + album = ItemMapping( + item_id=str(track_data.get("albumId", "")), + provider=provider.lookup_key, + name=album_name, + media_type=MediaType.ALBUM, + ) + track.album = album + + # Add track art/album art if available + if track_art := safe_get(track_data, "albumArt"): + track.metadata.add_image( + MediaItemImage( + type=ImageType.THUMB, + path=track_art, + provider=provider.lookup_key, + remotely_accessible=True, + ) + ) + + # Set explicit flag if available + if explicit := track_data.get("explicit"): + track.metadata.explicit = explicit + + return track + + +def parse_station(station_data: dict[str, Any], provider: PandoraProvider) -> Playlist: + """Parse Pandora station data into Music Assistant Playlist object.""" + station_id = str(station_data.get("stationId", "")) + + # Stations in Pandora are represented as playlists in Music Assistant + station = Playlist( + item_id=station_id, + provider=provider.lookup_key, + name=station_data.get("stationName", "Unknown Station"), + owner="Pandora Radio", + provider_mappings={ + ProviderMapping( + item_id=station_id, + provider_domain=provider.domain, + provider_instance=provider.instance_id, + ) + }, + is_editable=False, # Pandora stations are not directly editable + ) + + # Add station art if available + if station_art := safe_get(station_data, "artUrl"): + station.metadata.add_image( + MediaItemImage( + type=ImageType.THUMB, + path=station_art, + provider=provider.lookup_key, + remotely_accessible=True, + ) + ) + + # Use station creation date as cache checksum if available + if date_created := station_data.get("dateCreated"): + station.cache_checksum = str(date_created) + + return station + + +def parse_search_results(search_data: dict[str, Any], provider: PandoraProvider) -> SearchResults: + """Parse Pandora search results into Music Assistant SearchResults object.""" + results = SearchResults() + + # Parse artists + artist_list = [] + if artists := safe_get(search_data, "artists"): + for artist_data in artists: + try: + artist_list.append(parse_artist(artist_data, provider)) + except Exception as e: + provider.logger.debug("Failed to parse artist: %s", e) + results.artists = artist_list + + # Parse albums + album_list = [] + if albums := safe_get(search_data, "albums"): + for album_data in albums: + try: + album_list.append(parse_album(album_data, provider)) + except Exception as e: + provider.logger.debug("Failed to parse album: %s", e) + results.albums = album_list + + # Parse tracks/songs + track_list = [] + if tracks := safe_get(search_data, "songs"): + for track_data in tracks: + try: + track_list.append(parse_track(track_data, provider)) + except Exception as e: + provider.logger.debug("Failed to parse track: %s", e) + results.tracks = track_list + + # Parse stations (as playlists) + playlist_list = [] + if stations := safe_get(search_data, "stations"): + for station_data in stations: + try: + playlist_list.append(parse_station(station_data, provider)) + except Exception as e: + provider.logger.debug("Failed to parse station: %s", e) + results.playlists = playlist_list + + return results + + +def create_stream_details(track_id: str, provider: PandoraProvider) -> StreamDetails: + """Create StreamDetails for a Pandora track.""" + # Get audio quality from provider config with proper type handling + quality_setting = provider.config.get_value("audio_quality", DEFAULT_AUDIO_QUALITY) + if not isinstance(quality_setting, str): + quality_setting = DEFAULT_AUDIO_QUALITY + + audio_quality = AUDIO_QUALITIES.get(quality_setting, AUDIO_QUALITIES[DEFAULT_AUDIO_QUALITY]) + content_type = ContentType.AAC if audio_quality["format"] == "AAC+" else ContentType.MP3 + + # Safely extract bitrate with type checking + bitrate_value = audio_quality["bitrate"] + if isinstance(bitrate_value, int): + bit_rate = bitrate_value + elif isinstance(bitrate_value, (float, str)): + bit_rate = int(bitrate_value) + else: + bit_rate = 128 # fallback default + + return StreamDetails( + item_id=track_id, + provider=provider.lookup_key, + audio_format=AudioFormat( + content_type=content_type, + bit_rate=bit_rate, + ), + stream_type=StreamType.HTTP, + allow_seek=False, # Pandora radio doesn't typically allow seeking + can_seek=False, + ) diff --git a/music_assistant/providers/pandora/provider.py b/music_assistant/providers/pandora/provider.py new file mode 100644 index 0000000000..5e9e31f76f --- /dev/null +++ b/music_assistant/providers/pandora/provider.py @@ -0,0 +1,581 @@ +"""Pandora radio provider.""" + +from __future__ import annotations + +import asyncio +import contextlib +import json +from collections.abc import AsyncGenerator, Sequence +from typing import Any + +import aiohttp +from aiohttp import web +from music_assistant_models.enums import ( + ContentType, + ImageType, + MediaType, + ProviderFeature, + StreamType, +) +from music_assistant_models.errors import LoginFailed, MediaNotFoundError, UnplayableMediaError +from music_assistant_models.media_items import ( + AudioFormat, + BrowseFolder, + ItemMapping, + MediaItemImage, + MediaItemType, + ProviderMapping, + Radio, +) +from music_assistant_models.streamdetails import StreamDetails, StreamMetadata + +from music_assistant.helpers.throttle_retry import ThrottlerManager, throttle_with_retries +from music_assistant.helpers.util import lock, select_free_port +from music_assistant.helpers.webserver import Webserver +from music_assistant.models.music_provider import MusicProvider + +from .constants import ( + AUDIO_QUALITIES, + CONF_PASSWORD, + CONF_USERNAME, + DEFAULT_AUDIO_QUALITY, + LOGIN_ENDPOINT, + PLAYLIST_FRAGMENT_ENDPOINT, + STATIONS_ENDPOINT, +) +from .helpers import create_auth_headers, get_csrf_token, handle_pandora_error + + +class PandoraProvider(MusicProvider): + """Implementation of a Pandora Radio Provider with sequential FFmpeg streaming.""" + + _auth_token: str | None = None + _csrf_token: str | None = None + _user_profile: dict[str, Any] | None = None + _station_fragments: dict[str, dict[str, Any]] = {} + + # Proxy server components + _proxy_server: Webserver | None = None + _proxy_port: int | None = None + _active_streams: dict[str, asyncio.Task[None]] = {} # station_id -> streaming task + _current_stream_details: dict[str, StreamDetails] = {} # station_id -> StreamDetails + + throttler: ThrottlerManager + + async def handle_async_init(self) -> None: + """Handle async initialization of the provider.""" + self.throttler = ThrottlerManager(rate_limit=10, period=60) + + try: + await self.login() + await self._setup_proxy_server() + except LoginFailed as e: + self.logger.error("Authentication failed: %s", e) + raise + except Exception as e: + self.logger.error("Failed to initialize Pandora provider: %s", e) + raise + + async def unload(self, is_removed: bool = False) -> None: + """Handle unload/close of the provider.""" + # Cancel all active streaming tasks + for task in self._active_streams.values(): + if not task.done(): + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + + self._active_streams.clear() + self._current_stream_details.clear() + + # Close proxy server + if self._proxy_server: + await self._proxy_server.close() + + async def _setup_proxy_server(self) -> None: + """Set up the local proxy server for streaming.""" + bind_ip = "127.0.0.1" + self._proxy_port = await select_free_port(8100, 9999) + + self._proxy_server = Webserver(self.logger) + + # Define the streaming endpoint + async def stream_handler(request: web.Request) -> web.StreamResponse: + return await self._handle_stream_request(request) + + await self._proxy_server.setup( + bind_ip=bind_ip, + bind_port=self._proxy_port, + base_url=f"{bind_ip}:{self._proxy_port}", + static_routes=[ + ("GET", "/pandora/{station_id}.mp3", stream_handler), + ], + ) + + self.logger.debug(f"Pandora proxy server running at {bind_ip}:{self._proxy_port}") + + async def _handle_stream_request(self, request: web.Request) -> web.StreamResponse: + """Handle a streaming request for a station.""" + station_id = request.match_info["station_id"] + + response = web.StreamResponse( + status=200, + headers={ + "Content-Type": "audio/mpeg", + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "Transfer-Encoding": "chunked", + "Accept-Ranges": "none", + }, + ) + await response.prepare(request) + + try: + # Start the FFmpeg streaming task for this station if not already running + if station_id not in self._active_streams: + self._active_streams[station_id] = self.mass.create_task( + self._stream_directly(station_id, response) + ) + + # Wait for the streaming task to complete + await self._active_streams[station_id] + + except Exception as e: + self.logger.error("Error in stream handler for station %s: %s", station_id, e) + finally: + # Clean up + if station_id in self._active_streams: + del self._active_streams[station_id] + + return response + + async def _stream_directly(self, station_id: str, response: web.StreamResponse) -> None: # noqa: PLR0915 + """Stream tracks directly in a continuous loop with larger initial buffer.""" + self.logger.info("Starting continuous streaming for station %s", station_id) + + last_track_id = None + + try: + fragment_data = await self._get_station_fragment(station_id, is_start=True) + if not fragment_data or not fragment_data.get("tracks"): + self.logger.warning("No initial tracks found for station %s", station_id) + return + + while True: + tracks = fragment_data.get("tracks", []) if fragment_data else [] + + if not tracks: + self.logger.debug("End of fragment, fetching next one.") + fragment_data = await self._get_station_fragment( + station_id, last_track_id=last_track_id + ) + tracks = ( + fragment_data.get("tracks", []) + if fragment_data and fragment_data.get("tracks") + else [] + ) + if not tracks: + self.logger.warning("Could not fetch new fragment, ending stream.") + break + + track_data = tracks.pop(0) + audio_url = track_data.get("audioURL") + + if not audio_url: + self.logger.warning("Track has no audio URL, skipping.") + continue + + self.logger.info( + "Now streaming: %s - %s", + track_data.get("artistName"), + track_data.get("songTitle"), + ) + last_track_id = track_data.get("trackId") + + await self._update_stream_metadata(station_id, track_data) + + try: + self.logger.debug("Attempting to connect to audio URL: %s", audio_url) + async with self.mass.http_session.get(audio_url) as track_response: + self.logger.debug( + "Connected to audio URL with status: %s", track_response.status + ) + if track_response.status != 200: + self.logger.error( + "Failed to fetch track audio from %s: status %d", + audio_url, + track_response.status, + ) + continue + + # Read a much larger chunk to handle the client's aggressive read timeout. + self.logger.debug("Reading first chunk for pre-buffering (256 KB).") + first_chunk = await track_response.content.read(262144) + if first_chunk: + self.logger.debug( + "Writing first chunk of %d bytes to stream.", len(first_chunk) + ) + await response.write(first_chunk) + self.logger.debug("Successfully wrote first chunk.") + + total_bytes_sent = len(first_chunk) + + self.logger.debug("Starting continuous stream of remaining chunks.") + async for chunk in track_response.content.iter_chunked(8192): + await response.write(chunk) + total_bytes_sent += len(chunk) + self.logger.debug( + "Wrote a chunk, total bytes sent: %d", total_bytes_sent + ) + + except (aiohttp.ClientError, ConnectionResetError) as e: + self.logger.error("Error fetching or writing track audio: %s", e) + self.logger.debug("Breaking streaming loop due to connection error.") + break + + except Exception as e: + self.logger.error( + "Error in continuous streaming loop for station %s: %s", station_id, e + ) + finally: + self.logger.info("Stopping continuous stream for station %s", station_id) + try: + await response.write_eof() + except (ConnectionResetError, RuntimeError): + self.logger.debug("Stream transport was already closed, nothing to do.") + except Exception as e: + self.logger.error("Error writing EOF to stream: %s", e) + + async def _update_stream_metadata(self, station_id: str, track_data: dict[str, Any]) -> None: + """Update stream metadata for the current track.""" + if station_id not in self._current_stream_details: + return + + stream_details = self._current_stream_details[station_id] + + # Create metadata for current track + title = track_data.get("songTitle", "Unknown Title") + artist = track_data.get("artistName", "Unknown Artist") + album = track_data.get("albumTitle") + + # Get album art + image_url = None + if album_art := track_data.get("albumArt"): + if isinstance(album_art, list) and album_art: + best_art = max(album_art, key=lambda x: x.get("size", 0)) + image_url = best_art.get("url") + + # Get duration + duration = None + if track_length := track_data.get("trackLength"): + duration = int(track_length * 1000) # Convert to milliseconds + + stream_metadata = StreamMetadata( + title=title, + artist=artist, + album=album, + image_url=image_url, + duration=duration, + ) + + stream_details.stream_metadata = stream_metadata + self.logger.debug("Updated metadata for station %s: %s - %s", station_id, artist, title) + + @property + def supported_features(self) -> set[ProviderFeature]: + """Return the features supported by this Provider.""" + return { + ProviderFeature.BROWSE, + ProviderFeature.LIBRARY_RADIOS, + } + + @property + def is_streaming_provider(self) -> bool: + """Return True if the provider is a streaming provider.""" + return True + + @property + def instance_name_postfix(self) -> str | None: + """Return a postfix for the instance name.""" + if self._user_profile: + username = self._user_profile.get("username") + return str(username) if username is not None else None + return None + + async def get_library_radios(self) -> AsyncGenerator[Radio, None]: + """Retrieve library/subscribed radio stations from the provider.""" + try: + stations_data = await self._api_request("POST", STATIONS_ENDPOINT, data={}) + stations = stations_data.get("stations", []) + + for station_data in stations: + try: + yield self._parse_radio(station_data) + except Exception as e: + self.logger.debug("Failed to parse station: %s", e) + + except Exception as e: + self.logger.error("Failed to retrieve stations: %s", e) + + async def get_radio(self, prov_radio_id: str) -> Radio: + """Get full radio details by id.""" + try: + stations_data = await self._api_request("POST", STATIONS_ENDPOINT, data={}) + stations = stations_data.get("stations", []) + + for station_data in stations: + if str(station_data.get("stationId")) == prov_radio_id: + return self._parse_radio(station_data) + + raise MediaNotFoundError(f"Radio station {prov_radio_id} not found") + + except Exception as e: + self.logger.error("Failed to get radio station %s: %s", prov_radio_id, e) + raise MediaNotFoundError(f"Radio station {prov_radio_id} not found") from e + + 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 UnplayableMediaError(f"Unsupported media type: {media_type}") + + # Create proxy URL for this station + proxy_url = f"http://127.0.0.1:{self._proxy_port}/pandora/{item_id}.mp3" + + # Get audio quality from config + quality_setting = self.config.get_value("audio_quality", DEFAULT_AUDIO_QUALITY) + if not isinstance(quality_setting, str): + quality_setting = DEFAULT_AUDIO_QUALITY + + audio_quality = AUDIO_QUALITIES.get(quality_setting, AUDIO_QUALITIES[DEFAULT_AUDIO_QUALITY]) + content_type = ContentType.MP3 # Always MP3 output from FFmpeg + + bitrate_value = audio_quality["bitrate"] + if isinstance(bitrate_value, int): + bit_rate = bitrate_value + elif isinstance(bitrate_value, (float, str)): + bit_rate = int(bitrate_value) + else: + bit_rate = 128 + + # Get initial metadata from first fragment + stream_metadata = None + try: + fragment_data = await self._get_station_fragment(item_id, is_start=True) + if fragment_data and fragment_data.get("tracks"): + first_track = fragment_data["tracks"][0] + stream_metadata = StreamMetadata( + title=first_track.get("songTitle", "Unknown Title"), + artist=first_track.get("artistName"), + album=first_track.get("albumTitle"), + duration=int(first_track.get("trackLength", 0) * 1000) + if first_track.get("trackLength") + else None, + ) + + if album_art := first_track.get("albumArt"): + if isinstance(album_art, list) and album_art: + best_art = max(album_art, key=lambda x: x.get("size", 0)) + stream_metadata.image_url = best_art.get("url") + + except Exception as e: + self.logger.debug("Failed to get initial metadata for %s: %s", item_id, e) + + stream_details = StreamDetails( + item_id=item_id, + provider=self.lookup_key, + audio_format=AudioFormat( + content_type=content_type, + bit_rate=bit_rate, + channels=2, + ), + media_type=MediaType.RADIO, + stream_type=StreamType.HTTP, # Use HTTP, not CUSTOM + path=proxy_url, # Direct URL to proxy + allow_seek=False, + can_seek=False, + duration=0, # Radio streams are infinite + stream_metadata=stream_metadata, + ) + + # Store reference for metadata updates + self._current_stream_details[item_id] = stream_details + + return stream_details + + async def browse(self, path: str) -> Sequence[MediaItemType | ItemMapping | BrowseFolder]: + """Browse this provider's items.""" + return [radio async for radio in self.get_library_radios()] + + def _parse_radio(self, station_data: dict[str, Any]) -> Radio: + """Create a Radio object from station data.""" + station_id = str(station_data.get("stationId", "")) + station_name = station_data.get("name", "Unknown Station") + + radio = Radio( + provider=self.lookup_key, + item_id=station_id, + name=station_name, + provider_mappings={ + ProviderMapping( + provider_domain=self.domain, + provider_instance=self.instance_id, + item_id=station_id, + available=True, + ) + }, + ) + + # Add station artwork if available + if art_list := station_data.get("art"): + if isinstance(art_list, list) and art_list: + best_art = max(art_list, key=lambda x: x.get("size", 0)) + radio.metadata.add_image( + MediaItemImage( + type=ImageType.THUMB, + path=best_art["url"], + provider=self.lookup_key, + remotely_accessible=True, + ) + ) + + return radio + + async def _get_station_fragment( + self, + station_id: str, + is_start: bool = False, + last_track_id: str | None = None, # 👈 Add this new parameter + ) -> dict[str, Any] | None: + """Get a fragment of tracks from a station.""" + fragment_data = { + "stationId": station_id, + "isStationStart": is_start, + "fragmentRequestReason": "Normal", + "audioFormat": "aacplus", + "startingAtTrackId": last_track_id, # 👈 Use the new parameter here + "onDemandArtistMessageArtistUidHex": None, + "onDemandArtistMessageIdHex": None, + } + + try: + return await self._api_request("POST", PLAYLIST_FRAGMENT_ENDPOINT, data=fragment_data) + except Exception as e: + self.logger.error("Failed to get fragment for station %s: %s", station_id, e) + return None + + @lock + async def login(self, force_refresh: bool = False) -> None: + """Authenticate with Pandora.""" + if not force_refresh and self._auth_token: + return + + username = self.config.get_value(CONF_USERNAME) + password = self.config.get_value(CONF_PASSWORD) + + try: + self._csrf_token = await get_csrf_token(self.mass.http_session) + + login_data = { + "username": username, + "password": password, + "keepLoggedIn": True, + "existingAuthToken": None, + } + + headers = create_auth_headers(self._csrf_token) + + async with self.mass.http_session.post( + LOGIN_ENDPOINT, + headers=headers, + data=json.dumps(login_data), + ssl=True, + ) as response: + if response.status != 200: + raise LoginFailed(f"Login request failed with status {response.status}") + + response_data = 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_profile = { + "username": response_data.get("username", username), + "listenerId": response_data.get("listenerId"), + } + + self.logger.info( + "Successfully logged in to Pandora as %s", self._user_profile["username"] + ) + + except LoginFailed: + raise + except Exception as e: + self.logger.error("Login failed: %s", e) + raise LoginFailed(f"Authentication failed: {e}") from e + + @throttle_with_retries + async def _api_request( + self, + method: str, + endpoint: str, + data: dict[str, Any] | None = None, + params: dict[str, Any] | None = None, + ) -> dict[str, Any]: + """Make authenticated API request to Pandora.""" + + def get_auth_headers() -> dict[str, str]: + if not self._auth_token or not self._csrf_token: + raise LoginFailed("Authentication failed - tokens are missing.") + return create_auth_headers(self._csrf_token, self._auth_token) + + async def perform_request(headers: dict[str, str]) -> aiohttp.ClientResponse: + request_kwargs: dict[str, Any] = { + "headers": headers, + "ssl": True, + } + if data is not None: + request_kwargs["data"] = json.dumps(data) + if params is not None: + request_kwargs["params"] = params + return await self.mass.http_session.request(method, endpoint, **request_kwargs) + + if not self._auth_token or not self._csrf_token: + await self.login() + + try: + async with await perform_request(get_auth_headers()) as response: + if response.status == 401: + await self.login(force_refresh=True) + async with await perform_request(get_auth_headers()) as retry_response: + if retry_response.status != 200: + error_text = await retry_response.text() + self.logger.error( + "API request failed with status %s: %s", + retry_response.status, + error_text, + ) + raise aiohttp.ClientError( + f"API request failed with status {retry_response.status}: " + f"{error_text}" + ) + response_data: dict[str, Any] = await retry_response.json() + elif response.status != 200: + error_text = await response.text() + self.logger.error( + "API request failed with status %s: %s", response.status, error_text + ) + raise aiohttp.ClientError( + f"API request failed with status {response.status}: {error_text}" + ) + else: + response_data = await response.json() + + handle_pandora_error(response_data) + return response_data + except aiohttp.ClientError: + raise + except Exception as e: + self.logger.error("API request failed: %s", e) + raise aiohttp.ClientError(f"Request failed: {e}") from e From 0783f5f9e5a696cf16a325cff73cd15449a39f12 Mon Sep 17 00:00:00 2001 From: Gav Date: Mon, 15 Sep 2025 22:18:15 +1000 Subject: [PATCH 05/18] Try and fix streaming --- music_assistant/providers/pandora/provider.py | 405 +++++++----------- 1 file changed, 162 insertions(+), 243 deletions(-) diff --git a/music_assistant/providers/pandora/provider.py b/music_assistant/providers/pandora/provider.py index 5e9e31f76f..0108d47520 100644 --- a/music_assistant/providers/pandora/provider.py +++ b/music_assistant/providers/pandora/provider.py @@ -1,15 +1,14 @@ -"""Pandora radio provider.""" +"""Pandora radio provider with custom streaming.""" from __future__ import annotations import asyncio -import contextlib import json +import time from collections.abc import AsyncGenerator, Sequence from typing import Any import aiohttp -from aiohttp import web from music_assistant_models.enums import ( ContentType, ImageType, @@ -30,8 +29,7 @@ from music_assistant_models.streamdetails import StreamDetails, StreamMetadata from music_assistant.helpers.throttle_retry import ThrottlerManager, throttle_with_retries -from music_assistant.helpers.util import lock, select_free_port -from music_assistant.helpers.webserver import Webserver +from music_assistant.helpers.util import lock from music_assistant.models.music_provider import MusicProvider from .constants import ( @@ -47,18 +45,13 @@ class PandoraProvider(MusicProvider): - """Implementation of a Pandora Radio Provider with sequential FFmpeg streaming.""" + """Implementation of a Pandora Radio Provider with custom streaming.""" _auth_token: str | None = None _csrf_token: str | None = None _user_profile: dict[str, Any] | None = None _station_fragments: dict[str, dict[str, Any]] = {} - - # Proxy server components - _proxy_server: Webserver | None = None - _proxy_port: int | None = None - _active_streams: dict[str, asyncio.Task[None]] = {} # station_id -> streaming task - _current_stream_details: dict[str, StreamDetails] = {} # station_id -> StreamDetails + _station_track_positions: dict[str, int] = {} # Track position in fragment per station throttler: ThrottlerManager @@ -68,7 +61,7 @@ async def handle_async_init(self) -> None: try: await self.login() - await self._setup_proxy_server() + except LoginFailed as e: self.logger.error("Authentication failed: %s", e) raise @@ -76,211 +69,6 @@ async def handle_async_init(self) -> None: self.logger.error("Failed to initialize Pandora provider: %s", e) raise - async def unload(self, is_removed: bool = False) -> None: - """Handle unload/close of the provider.""" - # Cancel all active streaming tasks - for task in self._active_streams.values(): - if not task.done(): - task.cancel() - with contextlib.suppress(asyncio.CancelledError): - await task - - self._active_streams.clear() - self._current_stream_details.clear() - - # Close proxy server - if self._proxy_server: - await self._proxy_server.close() - - async def _setup_proxy_server(self) -> None: - """Set up the local proxy server for streaming.""" - bind_ip = "127.0.0.1" - self._proxy_port = await select_free_port(8100, 9999) - - self._proxy_server = Webserver(self.logger) - - # Define the streaming endpoint - async def stream_handler(request: web.Request) -> web.StreamResponse: - return await self._handle_stream_request(request) - - await self._proxy_server.setup( - bind_ip=bind_ip, - bind_port=self._proxy_port, - base_url=f"{bind_ip}:{self._proxy_port}", - static_routes=[ - ("GET", "/pandora/{station_id}.mp3", stream_handler), - ], - ) - - self.logger.debug(f"Pandora proxy server running at {bind_ip}:{self._proxy_port}") - - async def _handle_stream_request(self, request: web.Request) -> web.StreamResponse: - """Handle a streaming request for a station.""" - station_id = request.match_info["station_id"] - - response = web.StreamResponse( - status=200, - headers={ - "Content-Type": "audio/mpeg", - "Cache-Control": "no-cache", - "Connection": "keep-alive", - "Transfer-Encoding": "chunked", - "Accept-Ranges": "none", - }, - ) - await response.prepare(request) - - try: - # Start the FFmpeg streaming task for this station if not already running - if station_id not in self._active_streams: - self._active_streams[station_id] = self.mass.create_task( - self._stream_directly(station_id, response) - ) - - # Wait for the streaming task to complete - await self._active_streams[station_id] - - except Exception as e: - self.logger.error("Error in stream handler for station %s: %s", station_id, e) - finally: - # Clean up - if station_id in self._active_streams: - del self._active_streams[station_id] - - return response - - async def _stream_directly(self, station_id: str, response: web.StreamResponse) -> None: # noqa: PLR0915 - """Stream tracks directly in a continuous loop with larger initial buffer.""" - self.logger.info("Starting continuous streaming for station %s", station_id) - - last_track_id = None - - try: - fragment_data = await self._get_station_fragment(station_id, is_start=True) - if not fragment_data or not fragment_data.get("tracks"): - self.logger.warning("No initial tracks found for station %s", station_id) - return - - while True: - tracks = fragment_data.get("tracks", []) if fragment_data else [] - - if not tracks: - self.logger.debug("End of fragment, fetching next one.") - fragment_data = await self._get_station_fragment( - station_id, last_track_id=last_track_id - ) - tracks = ( - fragment_data.get("tracks", []) - if fragment_data and fragment_data.get("tracks") - else [] - ) - if not tracks: - self.logger.warning("Could not fetch new fragment, ending stream.") - break - - track_data = tracks.pop(0) - audio_url = track_data.get("audioURL") - - if not audio_url: - self.logger.warning("Track has no audio URL, skipping.") - continue - - self.logger.info( - "Now streaming: %s - %s", - track_data.get("artistName"), - track_data.get("songTitle"), - ) - last_track_id = track_data.get("trackId") - - await self._update_stream_metadata(station_id, track_data) - - try: - self.logger.debug("Attempting to connect to audio URL: %s", audio_url) - async with self.mass.http_session.get(audio_url) as track_response: - self.logger.debug( - "Connected to audio URL with status: %s", track_response.status - ) - if track_response.status != 200: - self.logger.error( - "Failed to fetch track audio from %s: status %d", - audio_url, - track_response.status, - ) - continue - - # Read a much larger chunk to handle the client's aggressive read timeout. - self.logger.debug("Reading first chunk for pre-buffering (256 KB).") - first_chunk = await track_response.content.read(262144) - if first_chunk: - self.logger.debug( - "Writing first chunk of %d bytes to stream.", len(first_chunk) - ) - await response.write(first_chunk) - self.logger.debug("Successfully wrote first chunk.") - - total_bytes_sent = len(first_chunk) - - self.logger.debug("Starting continuous stream of remaining chunks.") - async for chunk in track_response.content.iter_chunked(8192): - await response.write(chunk) - total_bytes_sent += len(chunk) - self.logger.debug( - "Wrote a chunk, total bytes sent: %d", total_bytes_sent - ) - - except (aiohttp.ClientError, ConnectionResetError) as e: - self.logger.error("Error fetching or writing track audio: %s", e) - self.logger.debug("Breaking streaming loop due to connection error.") - break - - except Exception as e: - self.logger.error( - "Error in continuous streaming loop for station %s: %s", station_id, e - ) - finally: - self.logger.info("Stopping continuous stream for station %s", station_id) - try: - await response.write_eof() - except (ConnectionResetError, RuntimeError): - self.logger.debug("Stream transport was already closed, nothing to do.") - except Exception as e: - self.logger.error("Error writing EOF to stream: %s", e) - - async def _update_stream_metadata(self, station_id: str, track_data: dict[str, Any]) -> None: - """Update stream metadata for the current track.""" - if station_id not in self._current_stream_details: - return - - stream_details = self._current_stream_details[station_id] - - # Create metadata for current track - title = track_data.get("songTitle", "Unknown Title") - artist = track_data.get("artistName", "Unknown Artist") - album = track_data.get("albumTitle") - - # Get album art - image_url = None - if album_art := track_data.get("albumArt"): - if isinstance(album_art, list) and album_art: - best_art = max(album_art, key=lambda x: x.get("size", 0)) - image_url = best_art.get("url") - - # Get duration - duration = None - if track_length := track_data.get("trackLength"): - duration = int(track_length * 1000) # Convert to milliseconds - - stream_metadata = StreamMetadata( - title=title, - artist=artist, - album=album, - image_url=image_url, - duration=duration, - ) - - stream_details.stream_metadata = stream_metadata - self.logger.debug("Updated metadata for station %s: %s - %s", station_id, artist, title) - @property def supported_features(self) -> set[ProviderFeature]: """Return the features supported by this Provider.""" @@ -334,20 +122,17 @@ async def get_radio(self, prov_radio_id: str) -> Radio: raise MediaNotFoundError(f"Radio station {prov_radio_id} not found") from e async def get_stream_details(self, item_id: str, media_type: MediaType) -> StreamDetails: - """Get streamdetails for a radio station.""" + """Get streamdetails for a radio station using custom streaming.""" if media_type != MediaType.RADIO: raise UnplayableMediaError(f"Unsupported media type: {media_type}") - # Create proxy URL for this station - proxy_url = f"http://127.0.0.1:{self._proxy_port}/pandora/{item_id}.mp3" - # Get audio quality from config quality_setting = self.config.get_value("audio_quality", DEFAULT_AUDIO_QUALITY) if not isinstance(quality_setting, str): quality_setting = DEFAULT_AUDIO_QUALITY audio_quality = AUDIO_QUALITIES.get(quality_setting, AUDIO_QUALITIES[DEFAULT_AUDIO_QUALITY]) - content_type = ContentType.MP3 # Always MP3 output from FFmpeg + content_type = ContentType.AAC if audio_quality["format"] == "AAC+" else ContentType.MP3 bitrate_value = audio_quality["bitrate"] if isinstance(bitrate_value, int): @@ -357,7 +142,7 @@ async def get_stream_details(self, item_id: str, media_type: MediaType) -> Strea else: bit_rate = 128 - # Get initial metadata from first fragment + # Get initial metadata stream_metadata = None try: fragment_data = await self._get_station_fragment(item_id, is_start=True) @@ -371,16 +156,13 @@ async def get_stream_details(self, item_id: str, media_type: MediaType) -> Strea if first_track.get("trackLength") else None, ) - - if album_art := first_track.get("albumArt"): - if isinstance(album_art, list) and album_art: - best_art = max(album_art, key=lambda x: x.get("size", 0)) - stream_metadata.image_url = best_art.get("url") - except Exception as e: self.logger.debug("Failed to get initial metadata for %s: %s", item_id, e) - stream_details = StreamDetails( + # Initialize position tracking + self._station_track_positions[item_id] = 0 + + return StreamDetails( item_id=item_id, provider=self.lookup_key, audio_format=AudioFormat( @@ -389,18 +171,155 @@ async def get_stream_details(self, item_id: str, media_type: MediaType) -> Strea channels=2, ), media_type=MediaType.RADIO, - stream_type=StreamType.HTTP, # Use HTTP, not CUSTOM - path=proxy_url, # Direct URL to proxy + stream_type=StreamType.CUSTOM, # Back to custom streaming allow_seek=False, can_seek=False, - duration=0, # Radio streams are infinite + duration=0, # Infinite radio stream stream_metadata=stream_metadata, ) - # Store reference for metadata updates - self._current_stream_details[item_id] = stream_details + async def get_audio_stream( # noqa: PLR0915 + self, streamdetails: StreamDetails, seek_position: int = 0 + ) -> AsyncGenerator[bytes, None]: + """Stream tracks in real-time without pre-buffering.""" + station_id = streamdetails.item_id + + self.logger.info("Starting real-time radio stream for station %s", station_id) - return stream_details + # Get target bitrate for throttling + target_bitrate = streamdetails.audio_format.bit_rate or 128 # kbps + bytes_per_second = (target_bitrate * 1000) / 8 # Convert to bytes/second + + try: + while True: + # Get current fragment + fragment_data = self._station_fragments.get(station_id) + if not fragment_data: + fragment_data = await self._get_station_fragment(station_id, is_start=True) + if not fragment_data: + await asyncio.sleep(5) + continue + + if not fragment_data.get("tracks"): + await asyncio.sleep(5) + continue + + tracks = fragment_data["tracks"] + current_position = self._station_track_positions.get(station_id, 0) + + # Get next fragment if needed + if current_position >= len(tracks): + fragment_data = await self._get_station_fragment(station_id, is_start=False) + if not fragment_data or not fragment_data.get("tracks"): + await asyncio.sleep(5) + continue + tracks = fragment_data["tracks"] + current_position = 0 + self._station_track_positions[station_id] = 0 + + # Get current track + track_data = tracks[current_position] + audio_url = track_data.get("audioURL") + + if not audio_url: + # Add silence for missing tracks + silence_duration = 10 # seconds + silence_bytes_total = int(bytes_per_second * silence_duration) + chunk_size = 8192 + + for i in range(0, silence_bytes_total, chunk_size): + chunk = b"\x00" * min(chunk_size, silence_bytes_total - i) + yield chunk + await asyncio.sleep(chunk_size / bytes_per_second) + + self._station_track_positions[station_id] = current_position + 1 + continue + + track_info = ( + f"{track_data.get('artistName', 'Unknown')} - " + f"{track_data.get('songTitle', 'Unknown')}" + ) + track_length = track_data.get("trackLength", 0) + + self.logger.info( + "Now streaming: %s - Duration: %s seconds", track_info, track_length + ) + + try: + track_start_time = time.time() + total_bytes_sent = 0 + + async with self.mass.http_session.get(audio_url) as response: + if response.status != 200: + self.logger.warning( + "Failed to get audio for %s, status: %s", + track_info, + response.status, + ) + # Add silence for failed tracks + silence_duration = 10 + silence_bytes_total = int(bytes_per_second * silence_duration) + chunk_size = 8192 + + for i in range(0, silence_bytes_total, chunk_size): + chunk = b"\x00" * min(chunk_size, silence_bytes_total - i) + yield chunk + await asyncio.sleep(chunk_size / bytes_per_second) + + self._station_track_positions[station_id] = current_position + 1 + continue + + # Stream in real-time with proper throttling + async for chunk in response.content.iter_chunked(8192): + if not chunk: + break + + # Send the chunk immediately + yield chunk + total_bytes_sent += len(chunk) + + # Calculate actual elapsed time + elapsed_time = time.time() - track_start_time + expected_time = total_bytes_sent / bytes_per_second + + # If we're ahead of schedule, sleep + if elapsed_time < expected_time: + sleep_time = expected_time - elapsed_time + await asyncio.sleep(sleep_time) + + # Track completed + actual_duration = time.time() - track_start_time + self.logger.info( + "Completed streaming %s in %.1f seconds (expected: %s)", + track_info, + actual_duration, + track_length, + ) + + except asyncio.CancelledError: + self.logger.info("Stream cancelled for %s", track_info) + raise + except Exception as e: + self.logger.error("Error streaming %s: %s", track_info, e) + # Add silence for error recovery + silence_duration = 5 + silence_bytes_total = int(bytes_per_second * silence_duration) + chunk_size = 8192 + + for i in range(0, silence_bytes_total, chunk_size): + chunk = b"\x00" * min(chunk_size, silence_bytes_total - i) + yield chunk + await asyncio.sleep(chunk_size / bytes_per_second) + + # Move to next track + self._station_track_positions[station_id] = current_position + 1 + + except asyncio.CancelledError: + self.logger.info("Audio stream cancelled for station %s", station_id) + raise + except Exception as e: + self.logger.error("Error in audio stream for station %s: %s", station_id, e) + raise async def browse(self, path: str) -> Sequence[MediaItemType | ItemMapping | BrowseFolder]: """Browse this provider's items.""" @@ -441,10 +360,7 @@ def _parse_radio(self, station_data: dict[str, Any]) -> Radio: return radio async def _get_station_fragment( - self, - station_id: str, - is_start: bool = False, - last_track_id: str | None = None, # 👈 Add this new parameter + self, station_id: str, is_start: bool = False ) -> dict[str, Any] | None: """Get a fragment of tracks from a station.""" fragment_data = { @@ -452,13 +368,16 @@ async def _get_station_fragment( "isStationStart": is_start, "fragmentRequestReason": "Normal", "audioFormat": "aacplus", - "startingAtTrackId": last_track_id, # 👈 Use the new parameter here + "startingAtTrackId": None, "onDemandArtistMessageArtistUidHex": None, "onDemandArtistMessageIdHex": None, } try: - return await self._api_request("POST", PLAYLIST_FRAGMENT_ENDPOINT, data=fragment_data) + result = await self._api_request("POST", PLAYLIST_FRAGMENT_ENDPOINT, data=fragment_data) + # Cache the fragment for this station + self._station_fragments[station_id] = result + return result except Exception as e: self.logger.error("Failed to get fragment for station %s: %s", station_id, e) return None From bf76031c7492d1d23ba280891e9de47540694fb2 Mon Sep 17 00:00:00 2001 From: Gav Date: Sat, 27 Sep 2025 23:10:28 +1000 Subject: [PATCH 06/18] more drafting --- music_assistant/providers/pandora/__init__.py | 26 +- .../providers/pandora/constants.py | 10 - music_assistant/providers/pandora/provider.py | 258 ++++++------------ 3 files changed, 87 insertions(+), 207 deletions(-) diff --git a/music_assistant/providers/pandora/__init__.py b/music_assistant/providers/pandora/__init__.py index 873377a237..cd6789fa40 100644 --- a/music_assistant/providers/pandora/__init__.py +++ b/music_assistant/providers/pandora/__init__.py @@ -4,11 +4,11 @@ from typing import TYPE_CHECKING -from music_assistant_models.config_entries import ConfigEntry, ConfigValueOption, ConfigValueType -from music_assistant_models.enums import ConfigEntryType +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 .constants import CONF_AUDIO_QUALITY, CONF_PASSWORD, CONF_USERNAME +from .constants import CONF_PASSWORD, CONF_USERNAME from .provider import PandoraProvider if TYPE_CHECKING: @@ -18,6 +18,12 @@ 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 @@ -37,7 +43,7 @@ async def setup( ): raise SetupFailedError("Username and password are required") - return PandoraProvider(mass, manifest, config) + return PandoraProvider(mass, manifest, config, SUPPORTED_FEATURES) async def get_config_entries( @@ -63,16 +69,4 @@ async def get_config_entries( description="Your Pandora password", required=True, ), - ConfigEntry( - key=CONF_AUDIO_QUALITY, - type=ConfigEntryType.STRING, - label="Audio Quality", - description="Preferred audio quality (requires Premium subscription for high quality)", - default_value="high", - options=[ - ConfigValueOption("Low (64 kbps AAC+)", "low"), - ConfigValueOption("Medium (128 kbps MP3)", "medium"), - ConfigValueOption("High (192 kbps AAC+) - Premium only", "high"), - ], - ), ) diff --git a/music_assistant/providers/pandora/constants.py b/music_assistant/providers/pandora/constants.py index decb2603ea..dd3af9c2ea 100644 --- a/music_assistant/providers/pandora/constants.py +++ b/music_assistant/providers/pandora/constants.py @@ -2,8 +2,6 @@ from __future__ import annotations -from music_assistant_models.enums import ProviderFeature - # Configuration Keys CONF_USERNAME = "username" CONF_PASSWORD = "password" @@ -25,14 +23,6 @@ "User-Agent": "Music Assistant Pandora Provider/1.0", } -# Supported Features - Pandora is primarily a radio service -SUPPORTED_FEATURES = { - ProviderFeature.SEARCH, - ProviderFeature.BROWSE, - # Pandora doesn't support traditional library features - # as it's a radio service, but we can implement stations as playlists -} - # API Limits MAX_SEARCH_RESULTS = 50 MAX_STATION_TRACKS = 100 diff --git a/music_assistant/providers/pandora/provider.py b/music_assistant/providers/pandora/provider.py index 0108d47520..e08b42e51f 100644 --- a/music_assistant/providers/pandora/provider.py +++ b/music_assistant/providers/pandora/provider.py @@ -1,10 +1,9 @@ -"""Pandora radio provider with custom streaming.""" +"""Pandora radio provider with single track streaming.""" from __future__ import annotations import asyncio import json -import time from collections.abc import AsyncGenerator, Sequence from typing import Any @@ -13,7 +12,6 @@ ContentType, ImageType, MediaType, - ProviderFeature, StreamType, ) from music_assistant_models.errors import LoginFailed, MediaNotFoundError, UnplayableMediaError @@ -33,10 +31,8 @@ from music_assistant.models.music_provider import MusicProvider from .constants import ( - AUDIO_QUALITIES, CONF_PASSWORD, CONF_USERNAME, - DEFAULT_AUDIO_QUALITY, LOGIN_ENDPOINT, PLAYLIST_FRAGMENT_ENDPOINT, STATIONS_ENDPOINT, @@ -45,7 +41,7 @@ class PandoraProvider(MusicProvider): - """Implementation of a Pandora Radio Provider with custom streaming.""" + """Implementation of a Pandora Radio Provider with single track streaming.""" _auth_token: str | None = None _csrf_token: str | None = None @@ -69,27 +65,6 @@ async def handle_async_init(self) -> None: self.logger.error("Failed to initialize Pandora provider: %s", e) raise - @property - def supported_features(self) -> set[ProviderFeature]: - """Return the features supported by this Provider.""" - return { - ProviderFeature.BROWSE, - ProviderFeature.LIBRARY_RADIOS, - } - - @property - def is_streaming_provider(self) -> bool: - """Return True if the provider is a streaming provider.""" - return True - - @property - def instance_name_postfix(self) -> str | None: - """Return a postfix for the instance name.""" - if self._user_profile: - username = self._user_profile.get("username") - return str(username) if username is not None else None - return None - async def get_library_radios(self) -> AsyncGenerator[Radio, None]: """Retrieve library/subscribed radio stations from the provider.""" try: @@ -126,42 +101,29 @@ async def get_stream_details(self, item_id: str, media_type: MediaType) -> Strea if media_type != MediaType.RADIO: raise UnplayableMediaError(f"Unsupported media type: {media_type}") - # Get audio quality from config - quality_setting = self.config.get_value("audio_quality", DEFAULT_AUDIO_QUALITY) - if not isinstance(quality_setting, str): - quality_setting = DEFAULT_AUDIO_QUALITY - - audio_quality = AUDIO_QUALITIES.get(quality_setting, AUDIO_QUALITIES[DEFAULT_AUDIO_QUALITY]) - content_type = ContentType.AAC if audio_quality["format"] == "AAC+" else ContentType.MP3 - - bitrate_value = audio_quality["bitrate"] - if isinstance(bitrate_value, int): - bit_rate = bitrate_value - elif isinstance(bitrate_value, (float, str)): - bit_rate = int(bitrate_value) - else: - bit_rate = 128 + # Fixed to HIGH quality (192 kbps AAC+) + content_type = ContentType.AAC + bit_rate = 192 # Get initial metadata stream_metadata = None try: fragment_data = await self._get_station_fragment(item_id, is_start=True) if fragment_data and fragment_data.get("tracks"): - first_track = fragment_data["tracks"][0] - stream_metadata = StreamMetadata( - title=first_track.get("songTitle", "Unknown Title"), - artist=first_track.get("artistName"), - album=first_track.get("albumTitle"), - duration=int(first_track.get("trackLength", 0) * 1000) - if first_track.get("trackLength") - else None, - ) + current_position = self._station_track_positions.get(item_id, 0) + if current_position < len(fragment_data["tracks"]): + current_track = fragment_data["tracks"][current_position] + stream_metadata = StreamMetadata( + title=current_track.get("songTitle", "Unknown Title"), + artist=current_track.get("artistName"), + album=current_track.get("albumTitle"), + duration=int(current_track.get("trackLength", 0) * 1000) + if current_track.get("trackLength") + else None, + ) except Exception as e: self.logger.debug("Failed to get initial metadata for %s: %s", item_id, e) - # Initialize position tracking - self._station_track_positions[item_id] = 0 - return StreamDetails( item_id=item_id, provider=self.lookup_key, @@ -171,154 +133,88 @@ async def get_stream_details(self, item_id: str, media_type: MediaType) -> Strea channels=2, ), media_type=MediaType.RADIO, - stream_type=StreamType.CUSTOM, # Back to custom streaming + stream_type=StreamType.CUSTOM, allow_seek=False, can_seek=False, duration=0, # Infinite radio stream stream_metadata=stream_metadata, ) - async def get_audio_stream( # noqa: PLR0915 + async def get_audio_stream( self, streamdetails: StreamDetails, seek_position: int = 0 ) -> AsyncGenerator[bytes, None]: - """Stream tracks in real-time without pre-buffering.""" + """Stream a single track from the radio station.""" station_id = streamdetails.item_id - self.logger.info("Starting real-time radio stream for station %s", station_id) + # Get current fragment and position + fragment_data = self._station_fragments.get(station_id) + current_position = self._station_track_positions.get(station_id, 0) - # Get target bitrate for throttling - target_bitrate = streamdetails.audio_format.bit_rate or 128 # kbps - bytes_per_second = (target_bitrate * 1000) / 8 # Convert to bytes/second + # Get new fragment if needed + if not fragment_data or not fragment_data.get("tracks"): + fragment_data = await self._get_station_fragment(station_id, is_start=True) + current_position = 0 - try: - while True: - # Get current fragment - fragment_data = self._station_fragments.get(station_id) - if not fragment_data: - fragment_data = await self._get_station_fragment(station_id, is_start=True) - if not fragment_data: - await asyncio.sleep(5) - continue - - if not fragment_data.get("tracks"): - await asyncio.sleep(5) - continue - - tracks = fragment_data["tracks"] - current_position = self._station_track_positions.get(station_id, 0) - - # Get next fragment if needed - if current_position >= len(tracks): - fragment_data = await self._get_station_fragment(station_id, is_start=False) - if not fragment_data or not fragment_data.get("tracks"): - await asyncio.sleep(5) - continue - tracks = fragment_data["tracks"] - current_position = 0 - self._station_track_positions[station_id] = 0 - - # Get current track - track_data = tracks[current_position] - audio_url = track_data.get("audioURL") - - if not audio_url: - # Add silence for missing tracks - silence_duration = 10 # seconds - silence_bytes_total = int(bytes_per_second * silence_duration) - chunk_size = 8192 - - for i in range(0, silence_bytes_total, chunk_size): - chunk = b"\x00" * min(chunk_size, silence_bytes_total - i) - yield chunk - await asyncio.sleep(chunk_size / bytes_per_second) - - self._station_track_positions[station_id] = current_position + 1 - continue - - track_info = ( - f"{track_data.get('artistName', 'Unknown')} - " - f"{track_data.get('songTitle', 'Unknown')}" - ) - track_length = track_data.get("trackLength", 0) + # Check if we need a new fragment (position beyond current tracks) + if fragment_data and current_position >= len(fragment_data.get("tracks", [])): + fragment_data = await self._get_station_fragment(station_id, is_start=False) + current_position = 0 - self.logger.info( - "Now streaming: %s - Duration: %s seconds", track_info, track_length - ) + if not fragment_data or not fragment_data.get("tracks"): + self.logger.error("No tracks available for station %s", station_id) + return - try: - track_start_time = time.time() - total_bytes_sent = 0 - - async with self.mass.http_session.get(audio_url) as response: - if response.status != 200: - self.logger.warning( - "Failed to get audio for %s, status: %s", - track_info, - response.status, - ) - # Add silence for failed tracks - silence_duration = 10 - silence_bytes_total = int(bytes_per_second * silence_duration) - chunk_size = 8192 - - for i in range(0, silence_bytes_total, chunk_size): - chunk = b"\x00" * min(chunk_size, silence_bytes_total - i) - yield chunk - await asyncio.sleep(chunk_size / bytes_per_second) - - self._station_track_positions[station_id] = current_position + 1 - continue - - # Stream in real-time with proper throttling - async for chunk in response.content.iter_chunked(8192): - if not chunk: - break - - # Send the chunk immediately - yield chunk - total_bytes_sent += len(chunk) - - # Calculate actual elapsed time - elapsed_time = time.time() - track_start_time - expected_time = total_bytes_sent / bytes_per_second - - # If we're ahead of schedule, sleep - if elapsed_time < expected_time: - sleep_time = expected_time - elapsed_time - await asyncio.sleep(sleep_time) - - # Track completed - actual_duration = time.time() - track_start_time - self.logger.info( - "Completed streaming %s in %.1f seconds (expected: %s)", - track_info, - actual_duration, - track_length, - ) + tracks = fragment_data["tracks"] + if current_position >= len(tracks): + self.logger.error( + "Track position %s beyond available tracks for station %s", + current_position, + station_id, + ) + return - except asyncio.CancelledError: - self.logger.info("Stream cancelled for %s", track_info) - raise - except Exception as e: - self.logger.error("Error streaming %s: %s", track_info, e) - # Add silence for error recovery - silence_duration = 5 - silence_bytes_total = int(bytes_per_second * silence_duration) - chunk_size = 8192 + # Get the current track + track_data = tracks[current_position] + audio_url = track_data.get("audioURL") + + if not audio_url: + self.logger.warning( + "No audio URL for track at position %s in station %s", current_position, station_id + ) + return + + track_info = ( + f"{track_data.get('artistName', 'Unknown')} - {track_data.get('songTitle', 'Unknown')}" + ) + track_length = track_data.get("trackLength", 0) + + self.logger.info("Streaming single track: %s (%.1f seconds)", track_info, track_length) + + # Update position for next call + self._station_track_positions[station_id] = current_position + 1 + + try: + # Stream the single track's audio + async with self.mass.http_session.get(audio_url) as response: + if response.status != 200: + self.logger.error( + "Failed to get audio for %s, status: %s", track_info, response.status + ) + return - for i in range(0, silence_bytes_total, chunk_size): - chunk = b"\x00" * min(chunk_size, silence_bytes_total - i) - yield chunk - await asyncio.sleep(chunk_size / bytes_per_second) + # Stream the audio data + async for chunk in response.content.iter_chunked(8192): + if not chunk: + break + yield chunk - # Move to next track - self._station_track_positions[station_id] = current_position + 1 + self.logger.info("Completed streaming track: %s", track_info) except asyncio.CancelledError: - self.logger.info("Audio stream cancelled for station %s", station_id) + self.logger.info("Stream cancelled for track: %s", track_info) raise except Exception as e: - self.logger.error("Error in audio stream for station %s: %s", station_id, e) + self.logger.error("Error streaming track %s: %s", track_info, e) raise async def browse(self, path: str) -> Sequence[MediaItemType | ItemMapping | BrowseFolder]: From 5973e37df615796cc3289acf01a2cd7d979138d6 Mon Sep 17 00:00:00 2001 From: Gav Date: Sun, 28 Sep 2025 22:54:44 +1000 Subject: [PATCH 07/18] More drafting --- .../providers/pandora/constants.py | 9 - music_assistant/providers/pandora/parsers.py | 29 +-- music_assistant/providers/pandora/provider.py | 192 ++++++++++++------ 3 files changed, 135 insertions(+), 95 deletions(-) diff --git a/music_assistant/providers/pandora/constants.py b/music_assistant/providers/pandora/constants.py index dd3af9c2ea..4f7917ab84 100644 --- a/music_assistant/providers/pandora/constants.py +++ b/music_assistant/providers/pandora/constants.py @@ -28,15 +28,6 @@ MAX_STATION_TRACKS = 100 DEFAULT_PAGE_SIZE = 50 -# Audio Quality Settings -AUDIO_QUALITIES = { - "low": {"bitrate": 64, "format": "AAC+"}, # Free tier - "medium": {"bitrate": 128, "format": "MP3"}, # In-home devices - "high": {"bitrate": 192, "format": "AAC+"}, # Premium web/desktop -} - -DEFAULT_AUDIO_QUALITY = "high" # Assume premium subscription - # Error Codes from Pandora API PANDORA_ERROR_CODES = { 0: "INVALID_REQUEST", diff --git a/music_assistant/providers/pandora/parsers.py b/music_assistant/providers/pandora/parsers.py index 67e7cb7963..39be55d151 100644 --- a/music_assistant/providers/pandora/parsers.py +++ b/music_assistant/providers/pandora/parsers.py @@ -20,10 +20,6 @@ from music_assistant.helpers.util import parse_title_and_version -from .constants import ( - AUDIO_QUALITIES, - DEFAULT_AUDIO_QUALITY, -) from .helpers import safe_get if TYPE_CHECKING: @@ -214,10 +210,6 @@ def parse_station(station_data: dict[str, Any], provider: PandoraProvider) -> Pl ) ) - # Use station creation date as cache checksum if available - if date_created := station_data.get("dateCreated"): - station.cache_checksum = str(date_created) - return station @@ -270,29 +262,12 @@ def parse_search_results(search_data: dict[str, Any], provider: PandoraProvider) def create_stream_details(track_id: str, provider: PandoraProvider) -> StreamDetails: """Create StreamDetails for a Pandora track.""" - # Get audio quality from provider config with proper type handling - quality_setting = provider.config.get_value("audio_quality", DEFAULT_AUDIO_QUALITY) - if not isinstance(quality_setting, str): - quality_setting = DEFAULT_AUDIO_QUALITY - - audio_quality = AUDIO_QUALITIES.get(quality_setting, AUDIO_QUALITIES[DEFAULT_AUDIO_QUALITY]) - content_type = ContentType.AAC if audio_quality["format"] == "AAC+" else ContentType.MP3 - - # Safely extract bitrate with type checking - bitrate_value = audio_quality["bitrate"] - if isinstance(bitrate_value, int): - bit_rate = bitrate_value - elif isinstance(bitrate_value, (float, str)): - bit_rate = int(bitrate_value) - else: - bit_rate = 128 # fallback default - return StreamDetails( item_id=track_id, provider=provider.lookup_key, audio_format=AudioFormat( - content_type=content_type, - bit_rate=bit_rate, + content_type=ContentType.AAC, + bit_rate=192, ), stream_type=StreamType.HTTP, allow_seek=False, # Pandora radio doesn't typically allow seeking diff --git a/music_assistant/providers/pandora/provider.py b/music_assistant/providers/pandora/provider.py index e08b42e51f..b892258c99 100644 --- a/music_assistant/providers/pandora/provider.py +++ b/music_assistant/providers/pandora/provider.py @@ -4,6 +4,7 @@ import asyncio import json +import time from collections.abc import AsyncGenerator, Sequence from typing import Any @@ -140,81 +141,154 @@ async def get_stream_details(self, item_id: str, media_type: MediaType) -> Strea stream_metadata=stream_metadata, ) - async def get_audio_stream( + async def get_audio_stream( # noqa: PLR0915 self, streamdetails: StreamDetails, seek_position: int = 0 ) -> AsyncGenerator[bytes, None]: - """Stream a single track from the radio station.""" + """Stream continuous radio with proper timing.""" station_id = streamdetails.item_id - # Get current fragment and position - fragment_data = self._station_fragments.get(station_id) - current_position = self._station_track_positions.get(station_id, 0) + # Calculate streaming parameters for HIGH quality (192 kbps AAC+) + target_bitrate = 192 # kbps + bytes_per_second = (target_bitrate * 1000) / 8 # Convert to bytes/second - # Get new fragment if needed - if not fragment_data or not fragment_data.get("tracks"): - fragment_data = await self._get_station_fragment(station_id, is_start=True) - current_position = 0 + self.logger.info("Starting continuous radio stream for station %s", station_id) - # Check if we need a new fragment (position beyond current tracks) - if fragment_data and current_position >= len(fragment_data.get("tracks", [])): - fragment_data = await self._get_station_fragment(station_id, is_start=False) - current_position = 0 - - if not fragment_data or not fragment_data.get("tracks"): - self.logger.error("No tracks available for station %s", station_id) - return - - tracks = fragment_data["tracks"] - if current_position >= len(tracks): - self.logger.error( - "Track position %s beyond available tracks for station %s", - current_position, - station_id, - ) - return - - # Get the current track - track_data = tracks[current_position] - audio_url = track_data.get("audioURL") - - if not audio_url: - self.logger.warning( - "No audio URL for track at position %s in station %s", current_position, station_id - ) - return - - track_info = ( - f"{track_data.get('artistName', 'Unknown')} - {track_data.get('songTitle', 'Unknown')}" - ) - track_length = track_data.get("trackLength", 0) + try: + while True: # Infinite radio loop + # Get current fragment and position + fragment_data = self._station_fragments.get(station_id) + current_position = self._station_track_positions.get(station_id, 0) + + # Get new fragment if needed + if not fragment_data or not fragment_data.get("tracks"): + fragment_data = await self._get_station_fragment(station_id, is_start=True) + current_position = 0 + self._station_track_positions[station_id] = 0 + + # Check if we need a new fragment (position beyond current tracks) + if fragment_data and current_position >= len(fragment_data.get("tracks", [])): + fragment_data = await self._get_station_fragment(station_id, is_start=False) + current_position = 0 + self._station_track_positions[station_id] = 0 + + if not fragment_data or not fragment_data.get("tracks"): + self.logger.error("No tracks available for station %s", station_id) + await asyncio.sleep(5) + continue + + tracks = fragment_data["tracks"] + if current_position >= len(tracks): + self.logger.error( + "Track position %s beyond available tracks for station %s", + current_position, + station_id, + ) + await asyncio.sleep(5) + continue + + # Get the current track + track_data = tracks[current_position] + audio_url = track_data.get("audioURL") + + if not audio_url: + self.logger.warning( + "No audio URL for track at position %s in station %s", + current_position, + station_id, + ) + # Add silence for missing tracks + silence_duration = 10 # seconds + silence_bytes_total = int(bytes_per_second * silence_duration) + chunk_size = 8192 + + for i in range(0, silence_bytes_total, chunk_size): + chunk = b"\x00" * min(chunk_size, silence_bytes_total - i) + yield chunk + await asyncio.sleep(chunk_size / bytes_per_second) + + self._station_track_positions[station_id] = current_position + 1 + continue + + track_info = ( + f"{track_data.get('artistName', 'Unknown')} - " + f"{track_data.get('songTitle', 'Unknown')}" + ) + track_duration = track_data.get("trackLength", 0) # seconds - self.logger.info("Streaming single track: %s (%.1f seconds)", track_info, track_length) + self.logger.info("Now streaming: %s (%.1f seconds)", track_info, track_duration) - # Update position for next call - self._station_track_positions[station_id] = current_position + 1 + try: + track_start_time = time.time() - try: - # Stream the single track's audio - async with self.mass.http_session.get(audio_url) as response: - if response.status != 200: - self.logger.error( - "Failed to get audio for %s, status: %s", track_info, response.status + async with self.mass.http_session.get(audio_url) as response: + if response.status != 200: + self.logger.error( + "Failed to get audio for %s, status: %s", + track_info, + response.status, + ) + # Add silence for failed tracks + silence_duration = 10 + silence_bytes_total = int(bytes_per_second * silence_duration) + chunk_size = 8192 + + for i in range(0, silence_bytes_total, chunk_size): + chunk = b"\x00" * min(chunk_size, silence_bytes_total - i) + yield chunk + await asyncio.sleep(chunk_size / bytes_per_second) + + self._station_track_positions[station_id] = current_position + 1 + continue + + # Stream with simple chunk-based timing + async for chunk in response.content.iter_chunked(8192): + if not chunk: + break + + yield chunk + + # Sleep for the time this chunk should take to play + chunk_play_time = len(chunk) / bytes_per_second + await asyncio.sleep(chunk_play_time) + + # Check if we've been streaming long enough + elapsed = time.time() - track_start_time + if elapsed >= (track_duration - 0.1): + self.logger.debug("Ending track after %.1f seconds", elapsed) + break + + # Track completed + actual_duration = time.time() - track_start_time + self.logger.info( + "Completed streaming %s in %.1f seconds (expected: %.1f)", + track_info, + actual_duration, + track_duration, ) - return - # Stream the audio data - async for chunk in response.content.iter_chunked(8192): - if not chunk: - break - yield chunk + except asyncio.CancelledError: + self.logger.info("Stream cancelled for %s", track_info) + raise + except Exception as e: + self.logger.error("Error streaming %s: %s", track_info, e) + # Add silence for error recovery + silence_duration = 5 + silence_bytes_total = int(bytes_per_second * silence_duration) + chunk_size = 8192 + + for i in range(0, silence_bytes_total, chunk_size): + chunk = b"\x00" * min(chunk_size, silence_bytes_total - i) + yield chunk + await asyncio.sleep(chunk_size / bytes_per_second) - self.logger.info("Completed streaming track: %s", track_info) + # Move to next track + self._station_track_positions[station_id] = current_position + 1 except asyncio.CancelledError: - self.logger.info("Stream cancelled for track: %s", track_info) + self.logger.info("Radio stream cancelled for station %s", station_id) raise except Exception as e: - self.logger.error("Error streaming track %s: %s", track_info, e) + self.logger.error("Error in radio stream for station %s: %s", station_id, e) raise async def browse(self, path: str) -> Sequence[MediaItemType | ItemMapping | BrowseFolder]: From d2f76eb246cea877a2a8836568c51ee7628fbc92 Mon Sep 17 00:00:00 2001 From: Gav Date: Sat, 11 Oct 2025 23:39:02 +1000 Subject: [PATCH 08/18] final drafting --- music_assistant/helpers/audio.py | 11 +- .../providers/pandora/constants.py | 16 - music_assistant/providers/pandora/helpers.py | 72 +-- music_assistant/providers/pandora/parsers.py | 275 ----------- music_assistant/providers/pandora/provider.py | 451 ++++++++---------- 5 files changed, 262 insertions(+), 563 deletions(-) delete mode 100644 music_assistant/providers/pandora/parsers.py diff --git a/music_assistant/helpers/audio.py b/music_assistant/helpers/audio.py index 28bf18812d..877cd7061d 100644 --- a/music_assistant/helpers/audio.py +++ b/music_assistant/helpers/audio.py @@ -379,10 +379,13 @@ async def get_stream_details( streamdetails.stream_type in (StreamType.ICY, StreamType.HLS, StreamType.HTTP) and streamdetails.media_type == MediaType.RADIO ): - 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 + # Only resolve if path is a string (traditional radio URL) + # Skip resolution for multi-file radio streams (list of URLs) + if 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 # set queue_id on the streamdetails so we know what is being streamed streamdetails.queue_id = queue_item.queue_id diff --git a/music_assistant/providers/pandora/constants.py b/music_assistant/providers/pandora/constants.py index 4f7917ab84..1d0b83de3e 100644 --- a/music_assistant/providers/pandora/constants.py +++ b/music_assistant/providers/pandora/constants.py @@ -5,28 +5,12 @@ # Configuration Keys CONF_USERNAME = "username" CONF_PASSWORD = "password" -CONF_AUDIO_QUALITY = "audio_quality" # 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" -STATION_DETAILS_ENDPOINT = f"{API_BASE_URL}/station/getStationDetails" PLAYLIST_FRAGMENT_ENDPOINT = f"{API_BASE_URL}/playlist/getFragment" -PROFILE_ENDPOINT = f"{API_BASE_URL}/listener/getProfile" -SEARCH_ENDPOINT = f"{API_BASE_URL}/search/search" -TRACK_FEEDBACK_ENDPOINT = f"{API_BASE_URL}/station/addFeedback" - -# Request Headers -DEFAULT_HEADERS = { - "Content-Type": "application/json;charset=utf-8", - "User-Agent": "Music Assistant Pandora Provider/1.0", -} - -# API Limits -MAX_SEARCH_RESULTS = 50 -MAX_STATION_TRACKS = 100 -DEFAULT_PAGE_SIZE = 50 # Error Codes from Pandora API PANDORA_ERROR_CODES = { diff --git a/music_assistant/providers/pandora/helpers.py b/music_assistant/providers/pandora/helpers.py index 4fc795c9a3..a95e19a277 100644 --- a/music_assistant/providers/pandora/helpers.py +++ b/music_assistant/providers/pandora/helpers.py @@ -9,6 +9,7 @@ from music_assistant_models.errors import ( LoginFailed, MediaNotFoundError, + ProviderUnavailableError, ResourceTemporarilyUnavailable, ) @@ -21,7 +22,16 @@ def generate_csrf_token() -> str: def handle_pandora_error(response_data: dict[str, Any]) -> None: - """Handle Pandora API error responses.""" + """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") @@ -30,37 +40,58 @@ def handle_pandora_error(response_data: dict[str, Any]) -> None: # 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 RuntimeError(f"Pandora API error {error_code} ({error_desc}): {message}") + 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.""" + """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: - # Try to extract from cookies first if "csrftoken" in response.cookies: return str(response.cookies["csrftoken"].value) except aiohttp.ClientError as e: - # Network issues - this is temporarily unavailable - raise ResourceTemporarilyUnavailable(f"Failed to get CSRF token from Pandora: {e}") - except Exception as e: - # Unexpected errors should also be treated as temporary issues - raise ResourceTemporarilyUnavailable(f"Unexpected error getting CSRF token: {e}") + raise ResourceTemporarilyUnavailable(f"Failed to get CSRF token from Pandora: {e}") from e - # If we get here, no CSRF token was found in cookies - return generate_csrf_token() + # 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.""" + """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, @@ -72,20 +103,3 @@ def create_auth_headers(csrf_token: str, auth_token: str | None = None) -> dict[ headers["X-AuthToken"] = auth_token return headers - - -def format_duration(duration_ms: int | None) -> float: - """Convert duration from milliseconds to seconds.""" - if duration_ms is None: - return 0.0 - return duration_ms / 1000.0 - - -def safe_get(data: dict[str, Any], *keys: str, default: Any = None) -> Any: - """Safely get nested dictionary values.""" - for key in keys: - if isinstance(data, dict) and key in data: - data = data[key] - else: - return default - return data diff --git a/music_assistant/providers/pandora/parsers.py b/music_assistant/providers/pandora/parsers.py deleted file mode 100644 index 39be55d151..0000000000 --- a/music_assistant/providers/pandora/parsers.py +++ /dev/null @@ -1,275 +0,0 @@ -"""Parsing utilities to convert Pandora API responses into Music Assistant model objects.""" - -from __future__ import annotations - -from typing import TYPE_CHECKING, Any - -from music_assistant_models.enums import ContentType, ImageType, MediaType, StreamType -from music_assistant_models.media_items import ( - Album, - Artist, - AudioFormat, - ItemMapping, - MediaItemImage, - Playlist, - ProviderMapping, - SearchResults, - Track, -) -from music_assistant_models.streamdetails import StreamDetails - -from music_assistant.helpers.util import parse_title_and_version - -from .helpers import safe_get - -if TYPE_CHECKING: - from .provider import PandoraProvider - - -def parse_artist(artist_data: dict[str, Any], provider: PandoraProvider) -> Artist: - """Parse Pandora artist data into Music Assistant Artist object.""" - artist_id = str(artist_data.get("pandoraId", artist_data.get("artistId", ""))) - if not artist_id: - artist_id = str(artist_data.get("musicId", "")) - - artist = Artist( - item_id=artist_id, - provider=provider.lookup_key, - name=artist_data.get("artistName", "Unknown Artist"), - provider_mappings={ - ProviderMapping( - item_id=artist_id, - provider_domain=provider.domain, - provider_instance=provider.instance_id, - ) - }, - ) - - # Add artist image if available - if artist_art := safe_get(artist_data, "artistArt"): - artist.metadata.add_image( - MediaItemImage( - type=ImageType.THUMB, - path=artist_art, - provider=provider.lookup_key, - remotely_accessible=True, - ) - ) - - return artist - - -def parse_album(album_data: dict[str, Any], provider: PandoraProvider) -> Album: - """Parse Pandora album data into Music Assistant Album object.""" - album_id = str(album_data.get("pandoraId", album_data.get("albumId", ""))) - if not album_id: - album_id = str(album_data.get("musicId", "")) - - album_name = album_data.get("albumName", "Unknown Album") - name, version = parse_title_and_version(album_name) - - album = Album( - item_id=album_id, - provider=provider.lookup_key, - name=name, - version=version, - provider_mappings={ - ProviderMapping( - item_id=album_id, - provider_domain=provider.domain, - provider_instance=provider.instance_id, - ) - }, - ) - - # Add album artist if available - if artist_name := album_data.get("artistName"): - artist = ItemMapping( - item_id=str(album_data.get("artistId", "")), - provider=provider.lookup_key, - name=artist_name, - media_type=MediaType.ARTIST, - ) - album.artists.append(artist) - - # Add album art if available - if album_art := safe_get(album_data, "albumArt"): - album.metadata.add_image( - MediaItemImage( - type=ImageType.THUMB, - path=album_art, - provider=provider.lookup_key, - remotely_accessible=True, - ) - ) - - return album - - -def parse_track(track_data: dict[str, Any], provider: PandoraProvider) -> Track: - """Parse Pandora track data into Music Assistant Track object.""" - track_id = str(track_data.get("pandoraId", track_data.get("trackId", ""))) - if not track_id: - track_id = str(track_data.get("musicId", "")) - - track_name = track_data.get("songName", track_data.get("trackName", "Unknown Track")) - name, version = parse_title_and_version(track_name) - - # Get duration in milliseconds (Track expects int milliseconds) - duration_ms = 0 - if track_length := track_data.get("trackLength"): - # trackLength is usually in seconds already - duration_ms = int(track_length * 1000) # Convert to milliseconds - - track = Track( - item_id=track_id, - provider=provider.lookup_key, - name=name, - version=version, - duration=duration_ms, - provider_mappings={ - ProviderMapping( - item_id=track_id, - provider_domain=provider.domain, - provider_instance=provider.instance_id, - audio_format=AudioFormat( - content_type=ContentType.AAC, - bit_rate=128, # Pandora typically uses 128kbps - ), - available=True, - ) - }, - ) - - # Add artist information - if artist_name := track_data.get("artistName"): - artist = ItemMapping( - item_id=str(track_data.get("artistMusicId", track_data.get("artistId", ""))), - provider=provider.lookup_key, - name=artist_name, - media_type=MediaType.ARTIST, - ) - track.artists.append(artist) - - # Add album information if available - if album_name := track_data.get("albumTitle", track_data.get("albumName")): - album = ItemMapping( - item_id=str(track_data.get("albumId", "")), - provider=provider.lookup_key, - name=album_name, - media_type=MediaType.ALBUM, - ) - track.album = album - - # Add track art/album art if available - if track_art := safe_get(track_data, "albumArt"): - track.metadata.add_image( - MediaItemImage( - type=ImageType.THUMB, - path=track_art, - provider=provider.lookup_key, - remotely_accessible=True, - ) - ) - - # Set explicit flag if available - if explicit := track_data.get("explicit"): - track.metadata.explicit = explicit - - return track - - -def parse_station(station_data: dict[str, Any], provider: PandoraProvider) -> Playlist: - """Parse Pandora station data into Music Assistant Playlist object.""" - station_id = str(station_data.get("stationId", "")) - - # Stations in Pandora are represented as playlists in Music Assistant - station = Playlist( - item_id=station_id, - provider=provider.lookup_key, - name=station_data.get("stationName", "Unknown Station"), - owner="Pandora Radio", - provider_mappings={ - ProviderMapping( - item_id=station_id, - provider_domain=provider.domain, - provider_instance=provider.instance_id, - ) - }, - is_editable=False, # Pandora stations are not directly editable - ) - - # Add station art if available - if station_art := safe_get(station_data, "artUrl"): - station.metadata.add_image( - MediaItemImage( - type=ImageType.THUMB, - path=station_art, - provider=provider.lookup_key, - remotely_accessible=True, - ) - ) - - return station - - -def parse_search_results(search_data: dict[str, Any], provider: PandoraProvider) -> SearchResults: - """Parse Pandora search results into Music Assistant SearchResults object.""" - results = SearchResults() - - # Parse artists - artist_list = [] - if artists := safe_get(search_data, "artists"): - for artist_data in artists: - try: - artist_list.append(parse_artist(artist_data, provider)) - except Exception as e: - provider.logger.debug("Failed to parse artist: %s", e) - results.artists = artist_list - - # Parse albums - album_list = [] - if albums := safe_get(search_data, "albums"): - for album_data in albums: - try: - album_list.append(parse_album(album_data, provider)) - except Exception as e: - provider.logger.debug("Failed to parse album: %s", e) - results.albums = album_list - - # Parse tracks/songs - track_list = [] - if tracks := safe_get(search_data, "songs"): - for track_data in tracks: - try: - track_list.append(parse_track(track_data, provider)) - except Exception as e: - provider.logger.debug("Failed to parse track: %s", e) - results.tracks = track_list - - # Parse stations (as playlists) - playlist_list = [] - if stations := safe_get(search_data, "stations"): - for station_data in stations: - try: - playlist_list.append(parse_station(station_data, provider)) - except Exception as e: - provider.logger.debug("Failed to parse station: %s", e) - results.playlists = playlist_list - - return results - - -def create_stream_details(track_id: str, provider: PandoraProvider) -> StreamDetails: - """Create StreamDetails for a Pandora track.""" - return StreamDetails( - item_id=track_id, - provider=provider.lookup_key, - audio_format=AudioFormat( - content_type=ContentType.AAC, - bit_rate=192, - ), - stream_type=StreamType.HTTP, - allow_seek=False, # Pandora radio doesn't typically allow seeking - can_seek=False, - ) diff --git a/music_assistant/providers/pandora/provider.py b/music_assistant/providers/pandora/provider.py index b892258c99..0848e32e0e 100644 --- a/music_assistant/providers/pandora/provider.py +++ b/music_assistant/providers/pandora/provider.py @@ -1,10 +1,8 @@ -"""Pandora radio provider with single track streaming.""" +"""Pandora radio provider for Music Assistant.""" from __future__ import annotations -import asyncio -import json -import time +from collections import OrderedDict from collections.abc import AsyncGenerator, Sequence from typing import Any @@ -15,7 +13,13 @@ MediaType, StreamType, ) -from music_assistant_models.errors import LoginFailed, MediaNotFoundError, UnplayableMediaError +from music_assistant_models.errors import ( + LoginFailed, + MediaNotFoundError, + ProviderUnavailableError, + ResourceTemporarilyUnavailable, + UnplayableMediaError, +) from music_assistant_models.media_items import ( AudioFormat, BrowseFolder, @@ -25,7 +29,7 @@ ProviderMapping, Radio, ) -from music_assistant_models.streamdetails import StreamDetails, StreamMetadata +from music_assistant_models.streamdetails import MultiPartPath, StreamDetails from music_assistant.helpers.throttle_retry import ThrottlerManager, throttle_with_retries from music_assistant.helpers.util import lock @@ -42,32 +46,38 @@ class PandoraProvider(MusicProvider): - """Implementation of a Pandora Radio Provider with single track streaming.""" + """Pandora Radio provider for Music Assistant. + + Provides access to Pandora radio stations with streaming support. + Stations must be created through the Pandora website or mobile app first. + + Note: This provider uses Pandora's REST API and only supports radio streaming. + Search and station creation require the GraphQL API which is not implemented. + """ _auth_token: str | None = None _csrf_token: str | None = None _user_profile: dict[str, Any] | None = None - _station_fragments: dict[str, dict[str, Any]] = {} - _station_track_positions: dict[str, int] = {} # Track position in fragment per station + _station_fragments: OrderedDict[str, dict[str, Any]] throttler: ThrottlerManager + def __init__(self, *args: Any, **kwargs: Any) -> None: + """Initialize the provider.""" + super().__init__(*args, **kwargs) + self._station_fragments = OrderedDict() + async def handle_async_init(self) -> None: """Handle async initialization of the provider.""" self.throttler = ThrottlerManager(rate_limit=10, period=60) - - try: - await self.login() - - except LoginFailed as e: - self.logger.error("Authentication failed: %s", e) - raise - except Exception as e: - self.logger.error("Failed to initialize Pandora provider: %s", e) - raise + await self.login() async def get_library_radios(self) -> AsyncGenerator[Radio, None]: - """Retrieve library/subscribed radio stations from the provider.""" + """Retrieve library/subscribed radio stations from the provider. + + Yields: + Radio objects for each station in the user's library + """ try: stations_data = await self._api_request("POST", STATIONS_ENDPOINT, data={}) stations = stations_data.get("stations", []) @@ -75,14 +85,24 @@ async def get_library_radios(self) -> AsyncGenerator[Radio, None]: for station_data in stations: try: yield self._parse_radio(station_data) - except Exception as e: + except (KeyError, ValueError, TypeError) as e: self.logger.debug("Failed to parse station: %s", e) - except Exception as e: + except (aiohttp.ClientError, ProviderUnavailableError, LoginFailed) as e: self.logger.error("Failed to retrieve stations: %s", e) async def get_radio(self, prov_radio_id: str) -> Radio: - """Get full radio details by id.""" + """Get full radio details by id. + + Args: + prov_radio_id: Provider-specific radio station ID + + Returns: + Radio object with full details + + Raises: + MediaNotFoundError: If station not found + """ try: stations_data = await self._api_request("POST", STATIONS_ENDPOINT, data={}) stations = stations_data.get("stations", []) @@ -93,12 +113,28 @@ async def get_radio(self, prov_radio_id: str) -> Radio: raise MediaNotFoundError(f"Radio station {prov_radio_id} not found") - except Exception as e: + except MediaNotFoundError: + raise + except (aiohttp.ClientError, ProviderUnavailableError, LoginFailed) as e: self.logger.error("Failed to get radio station %s: %s", prov_radio_id, e) raise MediaNotFoundError(f"Radio station {prov_radio_id} not found") from e async def get_stream_details(self, item_id: str, media_type: MediaType) -> StreamDetails: - """Get streamdetails for a radio station using custom streaming.""" + """Get stream details for a radio station. + + Builds a multi-file playlist by fetching multiple fragments from Pandora, + creating approximately 1-2 hours of continuous playback. + + Args: + item_id: Station ID + media_type: Must be MediaType.RADIO + + Returns: + StreamDetails with multi-part playlist + + Raises: + UnplayableMediaError: If media type unsupported or no tracks available + """ if media_type != MediaType.RADIO: raise UnplayableMediaError(f"Unsupported media type: {media_type}") @@ -106,197 +142,81 @@ async def get_stream_details(self, item_id: str, media_type: MediaType) -> Strea content_type = ContentType.AAC bit_rate = 192 - # Get initial metadata - stream_metadata = None + parts = [] + total_duration = 0 + target_duration = 7200 # 2 hours in seconds + max_fragments = 10 + try: - fragment_data = await self._get_station_fragment(item_id, is_start=True) - if fragment_data and fragment_data.get("tracks"): - current_position = self._station_track_positions.get(item_id, 0) - if current_position < len(fragment_data["tracks"]): - current_track = fragment_data["tracks"][current_position] - stream_metadata = StreamMetadata( - title=current_track.get("songTitle", "Unknown Title"), - artist=current_track.get("artistName"), - album=current_track.get("albumTitle"), - duration=int(current_track.get("trackLength", 0) * 1000) - if current_track.get("trackLength") - else None, - ) - except Exception as e: - self.logger.debug("Failed to get initial metadata for %s: %s", item_id, e) + # Fetch fragments until we have enough duration + for fragment_count in range(max_fragments): + is_start = fragment_count == 0 + fragment_data = await self._get_station_fragment(item_id, is_start=is_start) + + if not fragment_data or not fragment_data.get("tracks"): + break + + for track in fragment_data["tracks"]: + audio_url = track.get("audioURL") + track_duration = track.get("trackLength", 0) + + if audio_url and track_duration: + parts.append(MultiPartPath(path=audio_url, duration=track_duration)) + total_duration += track_duration + + if total_duration >= target_duration: + break + + self.logger.info( + "Built radio playlist for station %s: %d tracks, %.1f minutes total", + item_id, + len(parts), + total_duration / 60, + ) + + except (aiohttp.ClientError, ProviderUnavailableError, LoginFailed) as e: + self.logger.error("Failed to build radio playlist for %s: %s", item_id, e) + raise UnplayableMediaError(f"Could not build radio playlist: {e}") from e + + if not parts: + raise UnplayableMediaError(f"No tracks available for station {item_id}") return StreamDetails( item_id=item_id, provider=self.lookup_key, + path=parts, audio_format=AudioFormat( content_type=content_type, bit_rate=bit_rate, channels=2, ), media_type=MediaType.RADIO, - stream_type=StreamType.CUSTOM, + stream_type=StreamType.HTTP, allow_seek=False, can_seek=False, - duration=0, # Infinite radio stream - stream_metadata=stream_metadata, + duration=int(total_duration), ) - async def get_audio_stream( # noqa: PLR0915 - self, streamdetails: StreamDetails, seek_position: int = 0 - ) -> AsyncGenerator[bytes, None]: - """Stream continuous radio with proper timing.""" - station_id = streamdetails.item_id - - # Calculate streaming parameters for HIGH quality (192 kbps AAC+) - target_bitrate = 192 # kbps - bytes_per_second = (target_bitrate * 1000) / 8 # Convert to bytes/second - - self.logger.info("Starting continuous radio stream for station %s", station_id) - - try: - while True: # Infinite radio loop - # Get current fragment and position - fragment_data = self._station_fragments.get(station_id) - current_position = self._station_track_positions.get(station_id, 0) - - # Get new fragment if needed - if not fragment_data or not fragment_data.get("tracks"): - fragment_data = await self._get_station_fragment(station_id, is_start=True) - current_position = 0 - self._station_track_positions[station_id] = 0 - - # Check if we need a new fragment (position beyond current tracks) - if fragment_data and current_position >= len(fragment_data.get("tracks", [])): - fragment_data = await self._get_station_fragment(station_id, is_start=False) - current_position = 0 - self._station_track_positions[station_id] = 0 - - if not fragment_data or not fragment_data.get("tracks"): - self.logger.error("No tracks available for station %s", station_id) - await asyncio.sleep(5) - continue - - tracks = fragment_data["tracks"] - if current_position >= len(tracks): - self.logger.error( - "Track position %s beyond available tracks for station %s", - current_position, - station_id, - ) - await asyncio.sleep(5) - continue - - # Get the current track - track_data = tracks[current_position] - audio_url = track_data.get("audioURL") - - if not audio_url: - self.logger.warning( - "No audio URL for track at position %s in station %s", - current_position, - station_id, - ) - # Add silence for missing tracks - silence_duration = 10 # seconds - silence_bytes_total = int(bytes_per_second * silence_duration) - chunk_size = 8192 - - for i in range(0, silence_bytes_total, chunk_size): - chunk = b"\x00" * min(chunk_size, silence_bytes_total - i) - yield chunk - await asyncio.sleep(chunk_size / bytes_per_second) - - self._station_track_positions[station_id] = current_position + 1 - continue - - track_info = ( - f"{track_data.get('artistName', 'Unknown')} - " - f"{track_data.get('songTitle', 'Unknown')}" - ) - track_duration = track_data.get("trackLength", 0) # seconds - - self.logger.info("Now streaming: %s (%.1f seconds)", track_info, track_duration) - - try: - track_start_time = time.time() - - async with self.mass.http_session.get(audio_url) as response: - if response.status != 200: - self.logger.error( - "Failed to get audio for %s, status: %s", - track_info, - response.status, - ) - # Add silence for failed tracks - silence_duration = 10 - silence_bytes_total = int(bytes_per_second * silence_duration) - chunk_size = 8192 - - for i in range(0, silence_bytes_total, chunk_size): - chunk = b"\x00" * min(chunk_size, silence_bytes_total - i) - yield chunk - await asyncio.sleep(chunk_size / bytes_per_second) - - self._station_track_positions[station_id] = current_position + 1 - continue - - # Stream with simple chunk-based timing - async for chunk in response.content.iter_chunked(8192): - if not chunk: - break - - yield chunk - - # Sleep for the time this chunk should take to play - chunk_play_time = len(chunk) / bytes_per_second - await asyncio.sleep(chunk_play_time) - - # Check if we've been streaming long enough - elapsed = time.time() - track_start_time - if elapsed >= (track_duration - 0.1): - self.logger.debug("Ending track after %.1f seconds", elapsed) - break - - # Track completed - actual_duration = time.time() - track_start_time - self.logger.info( - "Completed streaming %s in %.1f seconds (expected: %.1f)", - track_info, - actual_duration, - track_duration, - ) + async def browse(self, path: str) -> Sequence[MediaItemType | ItemMapping | BrowseFolder]: + """Browse this provider's items. - except asyncio.CancelledError: - self.logger.info("Stream cancelled for %s", track_info) - raise - except Exception as e: - self.logger.error("Error streaming %s: %s", track_info, e) - # Add silence for error recovery - silence_duration = 5 - silence_bytes_total = int(bytes_per_second * silence_duration) - chunk_size = 8192 - - for i in range(0, silence_bytes_total, chunk_size): - chunk = b"\x00" * min(chunk_size, silence_bytes_total - i) - yield chunk - await asyncio.sleep(chunk_size / bytes_per_second) - - # Move to next track - self._station_track_positions[station_id] = current_position + 1 - - except asyncio.CancelledError: - self.logger.info("Radio stream cancelled for station %s", station_id) - raise - except Exception as e: - self.logger.error("Error in radio stream for station %s: %s", station_id, e) - raise + Args: + path: Browse path (unused, returns all stations) - async def browse(self, path: str) -> Sequence[MediaItemType | ItemMapping | BrowseFolder]: - """Browse this provider's items.""" + Returns: + List of all radio stations + """ return [radio async for radio in self.get_library_radios()] def _parse_radio(self, station_data: dict[str, Any]) -> Radio: - """Create a Radio object from station data.""" + """Create a Radio object from Pandora station data. + + Args: + station_data: Raw station data from Pandora API + + Returns: + Radio object + """ station_id = str(station_data.get("stationId", "")) station_name = station_data.get("name", "Unknown Station") @@ -332,7 +252,15 @@ def _parse_radio(self, station_data: dict[str, Any]) -> Radio: async def _get_station_fragment( self, station_id: str, is_start: bool = False ) -> dict[str, Any] | None: - """Get a fragment of tracks from a station.""" + """Get a fragment of tracks from a station. + + Args: + station_id: Pandora station ID + is_start: Whether this is the first fragment for the station + + Returns: + Fragment data with tracks, or None if request fails + """ fragment_data = { "stationId": station_id, "isStationStart": is_start, @@ -345,16 +273,27 @@ async def _get_station_fragment( try: result = await self._api_request("POST", PLAYLIST_FRAGMENT_ENDPOINT, data=fragment_data) - # Cache the fragment for this station + + # Cache the fragment with LRU behavior self._station_fragments[station_id] = result + if len(self._station_fragments) > 50: + self._station_fragments.popitem(last=False) + return result - except Exception as e: - self.logger.error("Failed to get fragment for station %s: %s", station_id, e) + except (aiohttp.ClientError, ProviderUnavailableError) as e: + self.logger.warning("Failed to get fragment for station %s: %s", station_id, e) return None @lock async def login(self, force_refresh: bool = False) -> None: - """Authenticate with Pandora.""" + """Authenticate with Pandora. + + Args: + force_refresh: Force re-authentication even if token exists + + Raises: + LoginFailed: If authentication fails + """ if not force_refresh and self._auth_token: return @@ -376,8 +315,8 @@ async def login(self, force_refresh: bool = False) -> None: async with self.mass.http_session.post( LOGIN_ENDPOINT, headers=headers, - data=json.dumps(login_data), - ssl=True, + json=login_data, + timeout=aiohttp.ClientTimeout(total=30), ) as response: if response.status != 200: raise LoginFailed(f"Login request failed with status {response.status}") @@ -398,9 +337,7 @@ async def login(self, force_refresh: bool = False) -> None: "Successfully logged in to Pandora as %s", self._user_profile["username"] ) - except LoginFailed: - raise - except Exception as e: + except (ResourceTemporarilyUnavailable, aiohttp.ClientError) as e: self.logger.error("Login failed: %s", e) raise LoginFailed(f"Authentication failed: {e}") from e @@ -412,59 +349,95 @@ async def _api_request( data: dict[str, Any] | None = None, params: dict[str, Any] | None = None, ) -> dict[str, Any]: - """Make authenticated API request to Pandora.""" + """Make authenticated API request to Pandora. - def get_auth_headers() -> dict[str, str]: - if not self._auth_token or not self._csrf_token: - raise LoginFailed("Authentication failed - tokens are missing.") - return create_auth_headers(self._csrf_token, self._auth_token) + Handles authentication token refresh on 401 errors. - async def perform_request(headers: dict[str, str]) -> aiohttp.ClientResponse: - request_kwargs: dict[str, Any] = { - "headers": headers, - "ssl": True, - } - if data is not None: - request_kwargs["data"] = json.dumps(data) - if params is not None: - request_kwargs["params"] = params - return await self.mass.http_session.request(method, endpoint, **request_kwargs) + Args: + method: HTTP method + endpoint: API endpoint URL + data: Optional request body data + params: Optional query parameters + Returns: + JSON response data + + Raises: + LoginFailed: If authentication fails + ProviderUnavailableError: If request fails + """ if not self._auth_token or not self._csrf_token: await self.login() + # After login, tokens should be set + if not self._auth_token or not self._csrf_token: + raise LoginFailed("Authentication failed - tokens are missing after login") + + # Build headers + headers = create_auth_headers(self._csrf_token, self._auth_token) + + # Build request kwargs + request_kwargs: dict[str, Any] = { + "headers": headers, + "timeout": aiohttp.ClientTimeout(total=30), + } + if data is not None: + request_kwargs["json"] = data + if params is not None: + request_kwargs["params"] = params + try: - async with await perform_request(get_auth_headers()) as response: + # First attempt + async with self.mass.http_session.request( + method, endpoint, **request_kwargs + ) as response: if response.status == 401: + # Token expired, refresh and retry once + self.logger.debug("Auth token expired, refreshing...") await self.login(force_refresh=True) - async with await perform_request(get_auth_headers()) as retry_response: + + if not self._auth_token or not self._csrf_token: + raise LoginFailed("Authentication failed - tokens missing after refresh") + + request_kwargs["headers"] = create_auth_headers( + self._csrf_token, self._auth_token + ) + + # Retry request + async with self.mass.http_session.request( + method, endpoint, **request_kwargs + ) as retry_response: if retry_response.status != 200: - error_text = await retry_response.text() self.logger.error( - "API request failed with status %s: %s", + "API request failed after retry with status %s", retry_response.status, - error_text, ) - raise aiohttp.ClientError( - f"API request failed with status {retry_response.status}: " - f"{error_text}" + raise ProviderUnavailableError( + f"API request failed with status {retry_response.status}" ) response_data: dict[str, Any] = await retry_response.json() + elif response.status != 200: - error_text = await response.text() - self.logger.error( - "API request failed with status %s: %s", response.status, error_text - ) - raise aiohttp.ClientError( - f"API request failed with status {response.status}: {error_text}" + self.logger.error("API request failed with status %s", response.status) + raise ProviderUnavailableError( + f"API request failed with status {response.status}" ) else: response_data = await response.json() handle_pandora_error(response_data) return response_data - except aiohttp.ClientError: + + except ( + LoginFailed, + ProviderUnavailableError, + MediaNotFoundError, + ResourceTemporarilyUnavailable, + ): raise - except Exception as e: - self.logger.error("API request failed: %s", e) - raise aiohttp.ClientError(f"Request failed: {e}") from e + except aiohttp.ClientError as e: + self.logger.error("Network error during API request: %s", e) + raise ResourceTemporarilyUnavailable(f"Network error: {e}") from e + except (KeyError, ValueError) as e: + self.logger.error("Invalid API response: %s", e) + raise ProviderUnavailableError(f"Invalid API response: {e}") from e From 82f5476cebd147a24363852a8df3dddbc15553d7 Mon Sep 17 00:00:00 2001 From: Gav Date: Sat, 11 Oct 2025 23:58:16 +1000 Subject: [PATCH 09/18] Final tweaks --- music_assistant/providers/pandora/manifest.json | 3 ++- music_assistant/providers/pandora/provider.py | 9 ++++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/music_assistant/providers/pandora/manifest.json b/music_assistant/providers/pandora/manifest.json index d0b04f066b..29f79252f8 100644 --- a/music_assistant/providers/pandora/manifest.json +++ b/music_assistant/providers/pandora/manifest.json @@ -6,5 +6,6 @@ "type": "music", "requirements": [], "codeowners": "@ozgav", - "multi_instance": false + "multi_instance": false, + "stage": "beta" } diff --git a/music_assistant/providers/pandora/provider.py b/music_assistant/providers/pandora/provider.py index 0848e32e0e..4964b04998 100644 --- a/music_assistant/providers/pandora/provider.py +++ b/music_assistant/providers/pandora/provider.py @@ -29,7 +29,7 @@ ProviderMapping, Radio, ) -from music_assistant_models.streamdetails import MultiPartPath, StreamDetails +from music_assistant_models.streamdetails import MultiPartPath, StreamDetails, StreamMetadata from music_assistant.helpers.throttle_retry import ThrottlerManager, throttle_with_retries from music_assistant.helpers.util import lock @@ -181,6 +181,12 @@ async def get_stream_details(self, item_id: str, media_type: MediaType) -> Strea if not parts: raise UnplayableMediaError(f"No tracks available for station {item_id}") + stream_metadata = StreamMetadata( + title=("Pandora"), + artist=None, + album=None, + duration=None, + ) return StreamDetails( item_id=item_id, provider=self.lookup_key, @@ -195,6 +201,7 @@ async def get_stream_details(self, item_id: str, media_type: MediaType) -> Strea allow_seek=False, can_seek=False, duration=int(total_duration), + stream_metadata=stream_metadata, ) async def browse(self, path: str) -> Sequence[MediaItemType | ItemMapping | BrowseFolder]: From 369e70886fc54a13e890657cdc8b497051f598ef Mon Sep 17 00:00:00 2001 From: Gav Date: Sun, 12 Oct 2025 12:47:54 +1000 Subject: [PATCH 10/18] Increase number of URLs retrieved --- music_assistant/providers/pandora/provider.py | 22 ++++++------------- 1 file changed, 7 insertions(+), 15 deletions(-) diff --git a/music_assistant/providers/pandora/provider.py b/music_assistant/providers/pandora/provider.py index 4964b04998..841e0d52fc 100644 --- a/music_assistant/providers/pandora/provider.py +++ b/music_assistant/providers/pandora/provider.py @@ -29,7 +29,7 @@ ProviderMapping, Radio, ) -from music_assistant_models.streamdetails import MultiPartPath, StreamDetails, StreamMetadata +from music_assistant_models.streamdetails import MultiPartPath, StreamDetails from music_assistant.helpers.throttle_retry import ThrottlerManager, throttle_with_retries from music_assistant.helpers.util import lock @@ -123,7 +123,7 @@ async def get_stream_details(self, item_id: str, media_type: MediaType) -> Strea """Get stream details for a radio station. Builds a multi-file playlist by fetching multiple fragments from Pandora, - creating approximately 1-2 hours of continuous playback. + creating approximately 2-3 hours of continuous playback. Args: item_id: Station ID @@ -144,16 +144,18 @@ async def get_stream_details(self, item_id: str, media_type: MediaType) -> Strea parts = [] total_duration = 0 - target_duration = 7200 # 2 hours in seconds - max_fragments = 10 + max_fragments = 30 # Fetch more fragments for longer uninterrupted playback try: - # Fetch fragments until we have enough duration + # Fetch fragments for fragment_count in range(max_fragments): is_start = fragment_count == 0 fragment_data = await self._get_station_fragment(item_id, is_start=is_start) if not fragment_data or not fragment_data.get("tracks"): + self.logger.debug( + "No more fragments available after %d fragments", fragment_count + ) break for track in fragment_data["tracks"]: @@ -164,9 +166,6 @@ async def get_stream_details(self, item_id: str, media_type: MediaType) -> Strea parts.append(MultiPartPath(path=audio_url, duration=track_duration)) total_duration += track_duration - if total_duration >= target_duration: - break - self.logger.info( "Built radio playlist for station %s: %d tracks, %.1f minutes total", item_id, @@ -181,12 +180,6 @@ async def get_stream_details(self, item_id: str, media_type: MediaType) -> Strea if not parts: raise UnplayableMediaError(f"No tracks available for station {item_id}") - stream_metadata = StreamMetadata( - title=("Pandora"), - artist=None, - album=None, - duration=None, - ) return StreamDetails( item_id=item_id, provider=self.lookup_key, @@ -201,7 +194,6 @@ async def get_stream_details(self, item_id: str, media_type: MediaType) -> Strea allow_seek=False, can_seek=False, duration=int(total_duration), - stream_metadata=stream_metadata, ) async def browse(self, path: str) -> Sequence[MediaItemType | ItemMapping | BrowseFolder]: From 1f4696addca93801a0078a5bffb9422da7e41ce8 Mon Sep 17 00:00:00 2001 From: Gav Date: Sun, 12 Oct 2025 12:57:49 +1000 Subject: [PATCH 11/18] Adjust number of fragments --- music_assistant/providers/pandora/provider.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/music_assistant/providers/pandora/provider.py b/music_assistant/providers/pandora/provider.py index 841e0d52fc..50aa9037f7 100644 --- a/music_assistant/providers/pandora/provider.py +++ b/music_assistant/providers/pandora/provider.py @@ -144,7 +144,7 @@ async def get_stream_details(self, item_id: str, media_type: MediaType) -> Strea parts = [] total_duration = 0 - max_fragments = 30 # Fetch more fragments for longer uninterrupted playback + max_fragments = 10 try: # Fetch fragments From 8c264140465dc568676872160ce066e2f6c2db5d Mon Sep 17 00:00:00 2001 From: Gav Date: Sun, 12 Oct 2025 22:54:04 +1000 Subject: [PATCH 12/18] fix constants --- music_assistant/providers/pandora/__init__.py | 3 ++- music_assistant/providers/pandora/constants.py | 4 ---- music_assistant/providers/pandora/provider.py | 3 +-- 3 files changed, 3 insertions(+), 7 deletions(-) diff --git a/music_assistant/providers/pandora/__init__.py b/music_assistant/providers/pandora/__init__.py index cd6789fa40..50b07bf3ff 100644 --- a/music_assistant/providers/pandora/__init__.py +++ b/music_assistant/providers/pandora/__init__.py @@ -8,7 +8,8 @@ from music_assistant_models.enums import ConfigEntryType, ProviderFeature from music_assistant_models.errors import SetupFailedError -from .constants import CONF_PASSWORD, CONF_USERNAME +from music_assistant.constants import CONF_PASSWORD, CONF_USERNAME + from .provider import PandoraProvider if TYPE_CHECKING: diff --git a/music_assistant/providers/pandora/constants.py b/music_assistant/providers/pandora/constants.py index 1d0b83de3e..5ae1546add 100644 --- a/music_assistant/providers/pandora/constants.py +++ b/music_assistant/providers/pandora/constants.py @@ -2,10 +2,6 @@ from __future__ import annotations -# Configuration Keys -CONF_USERNAME = "username" -CONF_PASSWORD = "password" - # API Endpoints API_BASE_URL = "https://www.pandora.com/api/v1" LOGIN_ENDPOINT = f"{API_BASE_URL}/auth/login" diff --git a/music_assistant/providers/pandora/provider.py b/music_assistant/providers/pandora/provider.py index 50aa9037f7..c8aa302498 100644 --- a/music_assistant/providers/pandora/provider.py +++ b/music_assistant/providers/pandora/provider.py @@ -31,13 +31,12 @@ ) from music_assistant_models.streamdetails import MultiPartPath, StreamDetails +from music_assistant.constants import CONF_PASSWORD, CONF_USERNAME from music_assistant.helpers.throttle_retry import ThrottlerManager, throttle_with_retries from music_assistant.helpers.util import lock from music_assistant.models.music_provider import MusicProvider from .constants import ( - CONF_PASSWORD, - CONF_USERNAME, LOGIN_ENDPOINT, PLAYLIST_FRAGMENT_ENDPOINT, STATIONS_ENDPOINT, From a934851a91c2b12c68383aa376d1f9c3fb0ef980 Mon Sep 17 00:00:00 2001 From: Gav Date: Mon, 13 Oct 2025 00:02:13 +1000 Subject: [PATCH 13/18] PR review comment --- music_assistant/helpers/audio.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/music_assistant/helpers/audio.py b/music_assistant/helpers/audio.py index 877cd7061d..f75893a9ec 100644 --- a/music_assistant/helpers/audio.py +++ b/music_assistant/helpers/audio.py @@ -375,17 +375,16 @@ async def get_stream_details( raise MediaNotFoundError(msg) # work out how to handle radio stream + # Only resolve if path is a string (traditional radio URL) + # so skip resolution for multi-file radio streams (list of URLs) if ( streamdetails.stream_type in (StreamType.ICY, StreamType.HLS, StreamType.HTTP) and streamdetails.media_type == MediaType.RADIO + and isinstance(streamdetails.path, str) ): - # Only resolve if path is a string (traditional radio URL) - # Skip resolution for multi-file radio streams (list of URLs) - if 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 + resolved_url, stream_type = await resolve_radio_stream(mass, streamdetails.path) + streamdetails.path = resolved_url + streamdetails.stream_type = stream_type # set queue_id on the streamdetails so we know what is being streamed streamdetails.queue_id = queue_item.queue_id From bb9aef824795605c0e1876ce3de3af8ad1112ba7 Mon Sep 17 00:00:00 2001 From: Gav Date: Sun, 19 Oct 2025 11:15:04 +1000 Subject: [PATCH 14/18] implement dynamic URL --- music_assistant/helpers/audio.py | 4 +- music_assistant/providers/pandora/provider.py | 663 ++++++++---------- 2 files changed, 312 insertions(+), 355 deletions(-) diff --git a/music_assistant/helpers/audio.py b/music_assistant/helpers/audio.py index f75893a9ec..28bf18812d 100644 --- a/music_assistant/helpers/audio.py +++ b/music_assistant/helpers/audio.py @@ -375,13 +375,11 @@ async def get_stream_details( raise MediaNotFoundError(msg) # work out how to handle radio stream - # Only resolve if path is a string (traditional radio URL) - # so skip resolution for multi-file radio streams (list of URLs) 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/provider.py b/music_assistant/providers/pandora/provider.py index c8aa302498..b6fa39ec08 100644 --- a/music_assistant/providers/pandora/provider.py +++ b/music_assistant/providers/pandora/provider.py @@ -1,267 +1,287 @@ -"""Pandora radio provider for Music Assistant.""" +"""Pandora music provider for Music Assistant.""" from __future__ import annotations -from collections import OrderedDict -from collections.abc import AsyncGenerator, Sequence -from typing import Any +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, - ProviderUnavailableError, - ResourceTemporarilyUnavailable, - UnplayableMediaError, -) +from music_assistant_models.errors import LoginFailed, MediaNotFoundError from music_assistant_models.media_items import ( AudioFormat, - BrowseFolder, - ItemMapping, - MediaItemImage, - MediaItemType, ProviderMapping, Radio, + SearchResults, ) -from music_assistant_models.streamdetails import MultiPartPath, StreamDetails +from music_assistant_models.streamdetails import MultiPartPath, StreamDetails, StreamMetadata -from music_assistant.constants import CONF_PASSWORD, CONF_USERNAME -from music_assistant.helpers.throttle_retry import ThrottlerManager, throttle_with_retries -from music_assistant.helpers.util import lock +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 .constants import ( - LOGIN_ENDPOINT, - PLAYLIST_FRAGMENT_ENDPOINT, - STATIONS_ENDPOINT, -) from .helpers import create_auth_headers, get_csrf_token, handle_pandora_error +if TYPE_CHECKING: + from collections.abc import AsyncGenerator -class PandoraProvider(MusicProvider): - """Pandora Radio provider for Music Assistant. + from music_assistant_models.provider import ProviderManifest - Provides access to Pandora radio stations with streaming support. - Stations must be created through the Pandora website or mobile app first. + from music_assistant.mass import MusicAssistant + from music_assistant.models import ProviderInstanceType - Note: This provider uses Pandora's REST API and only supports radio streaming. - Search and station creation require the GraphQL API which is not implemented. - """ +# API Configuration +API_BASE = "https://www.pandora.com/api/v1" +CONF_USERNAME = "username" +CONF_PASSWORD = "password" - _auth_token: str | None = None - _csrf_token: str | None = None - _user_profile: dict[str, Any] | None = None - _station_fragments: OrderedDict[str, dict[str, Any]] +# Pandora returns ~4 tracks per fragment +TRACKS_PER_FRAGMENT = 4 - throttler: ThrottlerManager +SUPPORTED_FEATURES = ( + ProviderFeature.LIBRARY_RADIOS, + ProviderFeature.BROWSE, +) - def __init__(self, *args: Any, **kwargs: Any) -> None: - """Initialize the provider.""" - super().__init__(*args, **kwargs) - self._station_fragments = OrderedDict() - async def handle_async_init(self) -> None: - """Handle async initialization of the provider.""" - self.throttler = ThrottlerManager(rate_limit=10, period=60) - await self.login() +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", + ), + ) - async def get_library_radios(self) -> AsyncGenerator[Radio, None]: - """Retrieve library/subscribed radio stations from the provider. - Yields: - Radio objects for each station in the user's library - """ - try: - stations_data = await self._api_request("POST", STATIONS_ENDPOINT, data={}) - stations = stations_data.get("stations", []) +class PandoraProvider(MusicProvider): + """Pandora Music Provider.""" - for station_data in stations: - try: - yield self._parse_radio(station_data) - except (KeyError, ValueError, TypeError) as e: - self.logger.debug("Failed to parse station: %s", e) + _auth_token: str | None = None + _user_id: str | None = None + _csrf_token: str | None = None + _on_unload_callbacks: list[Callable[[], None]] - except (aiohttp.ClientError, ProviderUnavailableError, LoginFailed) as e: - self.logger.error("Failed to retrieve stations: %s", e) + async def handle_async_init(self) -> None: + """Handle async initialization of the provider.""" + self._on_unload_callbacks = [] - async def get_radio(self, prov_radio_id: str) -> Radio: - """Get full radio details by id. + # Authenticate with Pandora + username = str(self.config.get_value(CONF_USERNAME)) + password = str(self.config.get_value(CONF_PASSWORD)) - Args: - prov_radio_id: Provider-specific radio station ID + 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 + ) + ) - Returns: - Radio object with full details + 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) - Raises: - MediaNotFoundError: If station not found - """ + async def _authenticate(self, username: str, password: str) -> None: + """Authenticate with Pandora and get auth token.""" try: - stations_data = await self._api_request("POST", STATIONS_ENDPOINT, data={}) - stations = stations_data.get("stations", []) - - for station_data in stations: - if str(station_data.get("stationId")) == prov_radio_id: - return self._parse_radio(station_data) + # First, get CSRF token + self._csrf_token = await get_csrf_token(self.mass.http_session) - raise MediaNotFoundError(f"Radio station {prov_radio_id} not found") + # Prepare login data + login_data = { + "username": username, + "password": password, + "keepLoggedIn": True, + "existingAuthToken": None, + } - except MediaNotFoundError: - raise - except (aiohttp.ClientError, ProviderUnavailableError, LoginFailed) as e: - self.logger.error("Failed to get radio station %s: %s", prov_radio_id, e) - raise MediaNotFoundError(f"Radio station {prov_radio_id} not found") from e + # Create headers with CSRF token + headers = create_auth_headers(self._csrf_token) - async def get_stream_details(self, item_id: str, media_type: MediaType) -> StreamDetails: - """Get stream details for a radio station. + 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}") - Builds a multi-file playlist by fetching multiple fragments from Pandora, - creating approximately 2-3 hours of continuous playback. + response_data: dict[str, Any] = await response.json() + handle_pandora_error(response_data) - Args: - item_id: Station ID - media_type: Must be MediaType.RADIO + self._auth_token = response_data.get("authToken") + if not self._auth_token: + raise LoginFailed("No auth token received from Pandora") - Returns: - StreamDetails with multi-part playlist + self._user_id = response_data.get("listenerId") + self.logger.info("Successfully authenticated with Pandora") - Raises: - UnplayableMediaError: If media type unsupported or no tracks available - """ - if media_type != MediaType.RADIO: - raise UnplayableMediaError(f"Unsupported media type: {media_type}") + 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 - # Fixed to HIGH quality (192 kbps AAC+) - content_type = ContentType.AAC - bit_rate = 192 + 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") - parts = [] - total_duration = 0 - max_fragments = 10 + headers = create_auth_headers(self._csrf_token, self._auth_token) - try: - # Fetch fragments - for fragment_count in range(max_fragments): - is_start = fragment_count == 0 - fragment_data = await self._get_station_fragment(item_id, is_start=is_start) - - if not fragment_data or not fragment_data.get("tracks"): - self.logger.debug( - "No more fragments available after %d fragments", fragment_count - ) - break + 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 - for track in fragment_data["tracks"]: - audio_url = track.get("audioURL") - track_duration = track.get("trackLength", 0) + async def get_library_radios(self) -> AsyncGenerator[Radio, None]: + """Retrieve library/subscribed radio stations from the provider.""" + self.logger.debug("Fetching Pandora stations") - if audio_url and track_duration: - parts.append(MultiPartPath(path=audio_url, duration=track_duration)) - total_duration += track_duration + 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 - self.logger.info( - "Built radio playlist for station %s: %d tracks, %.1f minutes total", - item_id, - len(parts), - total_duration / 60, + stations = response.get("stations", []) + self.logger.debug("Found %d Pandora stations", len(stations)) + + for station in stations: + yield Radio( + item_id=station["stationId"], + provider=self.domain, + name=station["name"], + provider_mappings={ + ProviderMapping( + item_id=station["stationId"], + provider_domain=self.domain, + provider_instance=self.instance_id, + ) + }, ) - except (aiohttp.ClientError, ProviderUnavailableError, LoginFailed) as e: - self.logger.error("Failed to build radio playlist for %s: %s", item_id, e) - raise UnplayableMediaError(f"Could not build radio playlist: {e}") from e + @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, + ) + }, + ) - if not parts: - raise UnplayableMediaError(f"No tracks available for station {item_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}", + duration=180, # Placeholder duration + ) + for i in range(1000) + ] return StreamDetails( + provider=self.instance_id, item_id=item_id, - provider=self.lookup_key, - path=parts, audio_format=AudioFormat( - content_type=content_type, - bit_rate=bit_rate, - channels=2, + content_type=ContentType.AAC, ), media_type=MediaType.RADIO, stream_type=StreamType.HTTP, - allow_seek=False, + path=parts, can_seek=False, - duration=int(total_duration), + allow_seek=False, + stream_metadata=StreamMetadata( + title="Pandora", + ), ) - async def browse(self, path: str) -> Sequence[MediaItemType | ItemMapping | BrowseFolder]: - """Browse this provider's items. - - Args: - path: Browse path (unused, returns all stations) - - Returns: - List of all radio stations - """ - return [radio async for radio in self.get_library_radios()] - - def _parse_radio(self, station_data: dict[str, Any]) -> Radio: - """Create a Radio object from Pandora station data. - - Args: - station_data: Raw station data from Pandora API - - Returns: - Radio object - """ - station_id = str(station_data.get("stationId", "")) - station_name = station_data.get("name", "Unknown Station") - - radio = Radio( - provider=self.lookup_key, - item_id=station_id, - name=station_name, - provider_mappings={ - ProviderMapping( - provider_domain=self.domain, - provider_instance=self.instance_id, - item_id=station_id, - available=True, - ) - }, + @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, ) - # Add station artwork if available - if art_list := station_data.get("art"): - if isinstance(art_list, list) and art_list: - best_art = max(art_list, key=lambda x: x.get("size", 0)) - radio.metadata.add_image( - MediaItemImage( - type=ImageType.THUMB, - path=best_art["url"], - provider=self.lookup_key, - remotely_accessible=True, - ) - ) - - return radio - - async def _get_station_fragment( - self, station_id: str, is_start: bool = False - ) -> dict[str, Any] | None: - """Get a fragment of tracks from a station. - - Args: - station_id: Pandora station ID - is_start: Whether this is the first fragment for the station - - Returns: - Fragment data with tracks, or None if request fails - """ fragment_data = { "stationId": station_id, - "isStationStart": is_start, + "isStationStart": fragment_index == 0, "fragmentRequestReason": "Normal", "audioFormat": "aacplus", "startingAtTrackId": None, @@ -270,172 +290,111 @@ async def _get_station_fragment( } try: - result = await self._api_request("POST", PLAYLIST_FRAGMENT_ENDPOINT, data=fragment_data) - - # Cache the fragment with LRU behavior - self._station_fragments[station_id] = result - if len(self._station_fragments) > 50: - self._station_fragments.popitem(last=False) - - return result - except (aiohttp.ClientError, ProviderUnavailableError) as e: - self.logger.warning("Failed to get fragment for station %s: %s", station_id, e) - return None - - @lock - async def login(self, force_refresh: bool = False) -> None: - """Authenticate with Pandora. - - Args: - force_refresh: Force re-authentication even if token exists + 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 - Raises: - LoginFailed: If authentication fails + async def _handle_stream_request(self, request: web.Request) -> web.Response: """ - if not force_refresh and self._auth_token: - return + Handle dynamic stream request. - username = self.config.get_value(CONF_USERNAME) - password = self.config.get_value(CONF_PASSWORD) + 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: - self._csrf_token = await get_csrf_token(self.mass.http_session) - - login_data = { - "username": username, - "password": password, - "keepLoggedIn": True, - "existingAuthToken": None, - } - - headers = create_auth_headers(self._csrf_token) - - async with self.mass.http_session.post( - LOGIN_ENDPOINT, - 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 = 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_profile = { - "username": response_data.get("username", username), - "listenerId": response_data.get("listenerId"), - } + track_num = int(track_num_str) + except ValueError: + return web.Response(status=400, text="Invalid track_num") + + # 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, + ) - self.logger.info( - "Successfully logged in to Pandora as %s", self._user_profile["username"] + 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") - except (ResourceTemporarilyUnavailable, aiohttp.ClientError) as e: - self.logger.error("Login failed: %s", e) - raise LoginFailed(f"Authentication failed: {e}") from e + track = tracks[track_idx] + audio_url = track.get("audioURL") - @throttle_with_retries - async def _api_request( - self, - method: str, - endpoint: str, - data: dict[str, Any] | None = None, - params: dict[str, Any] | None = None, - ) -> dict[str, Any]: - """Make authenticated API request to Pandora. - - Handles authentication token refresh on 401 errors. - - Args: - method: HTTP method - endpoint: API endpoint URL - data: Optional request body data - params: Optional query parameters - - Returns: - JSON response data + if not audio_url: + self.logger.error("No audio URL in track data") + return web.Response(status=404, text="No audio URL available") - Raises: - LoginFailed: If authentication fails - ProviderUnavailableError: If request fails - """ - if not self._auth_token or not self._csrf_token: - await self.login() + self.logger.debug( + "Redirecting to audio URL for track %d (%s - %s)", + track_num, + track.get("artistName", "Unknown"), + track.get("songTitle", "Unknown"), + ) - # After login, tokens should be set - if not self._auth_token or not self._csrf_token: - raise LoginFailed("Authentication failed - tokens are missing after login") + # Redirect to the actual audio URL + return web.Response(status=302, headers={"Location": audio_url}) - # Build headers - headers = create_auth_headers(self._csrf_token, self._auth_token) - - # Build request kwargs - request_kwargs: dict[str, Any] = { - "headers": headers, - "timeout": aiohttp.ClientTimeout(total=30), - } - if data is not None: - request_kwargs["json"] = data - if params is not None: - request_kwargs["params"] = params - - try: - # First attempt - async with self.mass.http_session.request( - method, endpoint, **request_kwargs - ) as response: - if response.status == 401: - # Token expired, refresh and retry once - self.logger.debug("Auth token expired, refreshing...") - await self.login(force_refresh=True) + except Exception as err: + self.logger.error( + "Error handling stream request for track %d: %s", + track_num, + err, + ) + return web.Response(status=500, text=f"Stream error: {err}") - if not self._auth_token or not self._csrf_token: - raise LoginFailed("Authentication failed - tokens missing after refresh") + 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 - request_kwargs["headers"] = create_auth_headers( - self._csrf_token, self._auth_token - ) + return SearchResults(radio=results) - # Retry request - async with self.mass.http_session.request( - method, endpoint, **request_kwargs - ) as retry_response: - if retry_response.status != 200: - self.logger.error( - "API request failed after retry with status %s", - retry_response.status, - ) - raise ProviderUnavailableError( - f"API request failed with status {retry_response.status}" - ) - response_data: dict[str, Any] = await retry_response.json() - - elif response.status != 200: - self.logger.error("API request failed with status %s", response.status) - raise ProviderUnavailableError( - f"API request failed with status {response.status}" - ) - else: - response_data = await response.json() - - handle_pandora_error(response_data) - return response_data - - except ( - LoginFailed, - ProviderUnavailableError, - MediaNotFoundError, - ResourceTemporarilyUnavailable, - ): - raise - except aiohttp.ClientError as e: - self.logger.error("Network error during API request: %s", e) - raise ResourceTemporarilyUnavailable(f"Network error: {e}") from e - except (KeyError, ValueError) as e: - self.logger.error("Invalid API response: %s", e) - raise ProviderUnavailableError(f"Invalid API response: {e}") from e + 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()] From 2bf9013be9c306440d7b44add81736cc56397a0c Mon Sep 17 00:00:00 2001 From: OzGav Date: Sun, 19 Oct 2025 21:28:20 +1000 Subject: [PATCH 15/18] Update music_assistant/providers/pandora/provider.py Co-authored-by: Marcel van der Veldt --- music_assistant/providers/pandora/provider.py | 1 - 1 file changed, 1 deletion(-) diff --git a/music_assistant/providers/pandora/provider.py b/music_assistant/providers/pandora/provider.py index b6fa39ec08..8d610a0742 100644 --- a/music_assistant/providers/pandora/provider.py +++ b/music_assistant/providers/pandora/provider.py @@ -249,7 +249,6 @@ async def get_stream_details(self, item_id: str, media_type: MediaType) -> Strea MultiPartPath( path=f"{self.mass.streams.base_url}/{self.instance_id}_stream?" f"station_id={item_id}&track_num={i}", - duration=180, # Placeholder duration ) for i in range(1000) ] From 8cafc0b5a7e46e59ec34e5fb73bc69c0b83ea174 Mon Sep 17 00:00:00 2001 From: OzGav Date: Sun, 19 Oct 2025 22:50:53 +1000 Subject: [PATCH 16/18] Potential fix for code scanning alert no. 34: Information exposure through an exception Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- music_assistant/providers/pandora/provider.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/music_assistant/providers/pandora/provider.py b/music_assistant/providers/pandora/provider.py index 8d610a0742..2642a63e71 100644 --- a/music_assistant/providers/pandora/provider.py +++ b/music_assistant/providers/pandora/provider.py @@ -368,7 +368,7 @@ async def _handle_stream_request(self, request: web.Request) -> web.Response: track_num, err, ) - return web.Response(status=500, text=f"Stream error: {err}") + return web.Response(status=500, text="Stream error occurred") async def search( self, From 419d154557441bdbd95496a53cbbeb8425e7def7 Mon Sep 17 00:00:00 2001 From: Gav Date: Mon, 20 Oct 2025 19:30:02 +1000 Subject: [PATCH 17/18] interim code --- music_assistant/providers/pandora/provider.py | 26 +++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/music_assistant/providers/pandora/provider.py b/music_assistant/providers/pandora/provider.py index 2642a63e71..546288fec6 100644 --- a/music_assistant/providers/pandora/provider.py +++ b/music_assistant/providers/pandora/provider.py @@ -252,7 +252,12 @@ async def get_stream_details(self, item_id: str, media_type: MediaType) -> Strea ) 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, @@ -320,6 +325,9 @@ async def _handle_stream_request(self, request: web.Request) -> web.Response: 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 @@ -352,6 +360,20 @@ async def _handle_stream_request(self, request: web.Request) -> web.Response: 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: + queue_item.streamdetails.stream_metadata = StreamMetadata( + title=track.get("songTitle", "Unknown Song"), + artist=track.get("artistName", "Unknown Artist"), + ) + 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, @@ -368,7 +390,7 @@ async def _handle_stream_request(self, request: web.Request) -> web.Response: track_num, err, ) - return web.Response(status=500, text="Stream error occurred") + return web.Response(status=500, text="A stream error occurred") async def search( self, From acf2cf3514c36b03249d06fb11266fad51dacda9 Mon Sep 17 00:00:00 2001 From: Gav Date: Tue, 28 Oct 2025 11:59:20 +1000 Subject: [PATCH 18/18] Update metadata --- music_assistant/helpers/audio.py | 2 +- music_assistant/providers/pandora/provider.py | 55 +++++++++++++++---- 2 files changed, 45 insertions(+), 12 deletions(-) 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/provider.py b/music_assistant/providers/pandora/provider.py index 546288fec6..8488883bb1 100644 --- a/music_assistant/providers/pandora/provider.py +++ b/music_assistant/providers/pandora/provider.py @@ -15,6 +15,7 @@ from music_assistant_models.enums import ( ConfigEntryType, ContentType, + ImageType, MediaType, ProviderFeature, StreamType, @@ -22,9 +23,12 @@ 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 @@ -207,18 +211,35 @@ async def get_library_radios(self) -> AsyncGenerator[Radio, None]: self.logger.debug("Found %d Pandora stations", len(stations)) for station in stations: - yield Radio( - item_id=station["stationId"], - provider=self.domain, - name=station["name"], - provider_mappings={ - ProviderMapping( - item_id=station["stationId"], - provider_domain=self.domain, - provider_instance=self.instance_id, + # 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: @@ -364,9 +385,21 @@ async def _handle_stream_request(self, request: web.Request) -> web.Response: 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",