From 274dba4a20250d4642921b95a74002bf3b1f3739 Mon Sep 17 00:00:00 2001 From: Andrew Sayre <6730289+andrewsayre@users.noreply.github.com> Date: Thu, 9 Jan 2025 15:08:41 -0600 Subject: [PATCH] Add remaining browse commands (#68) * Add get_music_source_info * Add get search criteria * Add Search * Add bounds checking for search param * Add rename playlist command * Add delete playlist * Add retrieve_metadata * Add service options to BrowseResult * Add service options to now playing media * Add set_service_option and happy-path tests * Param tests * Refactoring * Add multi-search * Optimize some tests * Add missing exports --- pyheos/__init__.py | 19 +- pyheos/command/__init__.py | 12 +- pyheos/command/browse.py | 298 +++++++- pyheos/command/player.py | 2 +- pyheos/connection.py | 10 +- pyheos/const.py | 35 +- pyheos/error.py | 2 +- pyheos/heos.py | 182 ++++- pyheos/media.py | 122 ++- pyheos/message.py | 11 +- pyheos/player.py | 33 +- pyheos/search.py | 163 ++++ pyproject.toml | 3 + tests/__init__.py | 1 + tests/fixtures/browse.delete_playlist.json | 1 + .../fixtures/browse.get_search_criteria.json | 1 + tests/fixtures/browse.get_source_info.json | 1 + tests/fixtures/browse.multi_search.json | 1 + tests/fixtures/browse.rename_playlist.json | 1 + tests/fixtures/browse.retrieve_metadata.json | 22 + tests/fixtures/browse.search.json | 2 + ...rowse.set_service_option_add_favorite.json | 1 + ...et_service_option_add_favorite_browse.json | 1 + ...rowse.set_service_option_add_playlist.json | 1 + ..._service_option_album_remove_playlist.json | 1 + ...browse.set_service_option_new_station.json | 1 + ...se.set_service_option_remove_favorite.json | 1 + ...wse.set_service_option_thumbs_up_down.json | 1 + ...owse.set_service_option_track_station.json | 1 + .../player.get_now_playing_media_changed.json | 2 +- tests/test_heos.py | 32 +- tests/test_heos_browse.py | 722 ++++++++++++++++++ tests/test_media.py | 29 +- tests/test_player.py | 3 +- 34 files changed, 1646 insertions(+), 72 deletions(-) create mode 100644 pyheos/search.py create mode 100644 tests/fixtures/browse.delete_playlist.json create mode 100644 tests/fixtures/browse.get_search_criteria.json create mode 100644 tests/fixtures/browse.get_source_info.json create mode 100644 tests/fixtures/browse.multi_search.json create mode 100644 tests/fixtures/browse.rename_playlist.json create mode 100644 tests/fixtures/browse.retrieve_metadata.json create mode 100644 tests/fixtures/browse.search.json create mode 100644 tests/fixtures/browse.set_service_option_add_favorite.json create mode 100644 tests/fixtures/browse.set_service_option_add_favorite_browse.json create mode 100644 tests/fixtures/browse.set_service_option_add_playlist.json create mode 100644 tests/fixtures/browse.set_service_option_album_remove_playlist.json create mode 100644 tests/fixtures/browse.set_service_option_new_station.json create mode 100644 tests/fixtures/browse.set_service_option_remove_favorite.json create mode 100644 tests/fixtures/browse.set_service_option_thumbs_up_down.json create mode 100644 tests/fixtures/browse.set_service_option_track_station.json create mode 100644 tests/test_heos_browse.py diff --git a/pyheos/__init__.py b/pyheos/__init__.py index f5522fc..417f070 100644 --- a/pyheos/__init__.py +++ b/pyheos/__init__.py @@ -22,15 +22,22 @@ from .group import HeosGroup from .heos import Heos, HeosOptions from .media import ( + AlbumMetadata, BrowseResult, + ImageMetadata, Media, MediaItem, MediaMusicSource, + QueueItem, + RetreiveMetadataResult, + ServiceOption, ) from .player import HeosNowPlayingMedia, HeosPlayer, PlayMode +from .search import MultiSearchResult, SearchCriteria, SearchResult, SearchStatistic from .system import HeosHost, HeosSystem __all__ = [ + "AlbumMetadata", "BrowseResult", "CallbackType", "CommandAuthenticationError", @@ -47,15 +54,23 @@ "HeosError", "HeosGroup", "HeosHost", + "HeosNowPlayingMedia", "HeosOptions", "HeosPlayer", - "HeosNowPlayingMedia", "HeosSystem", + "ImageMetadata", "Media", "MediaItem", "MediaMusicSource", - "PlayerEventCallbackType", + "MultiSearchResult", + "QueueItem", + "ServiceOption", "PlayMode", + "PlayerEventCallbackType", + "RetreiveMetadataResult", + "SearchCriteria", + "SearchResult", + "SearchStatistic", "SendType", "TargetType", ] diff --git a/pyheos/command/__init__.py b/pyheos/command/__init__.py index a2271de..0c140cc 100644 --- a/pyheos/command/__init__.py +++ b/pyheos/command/__init__.py @@ -3,12 +3,20 @@ from typing import Final # Browse commands -COMMAND_BROWSE_GET_SOURCES: Final = "browse/get_music_sources" +COMMAND_BROWSE_ADD_TO_QUEUE: Final = "browse/add_to_queue" COMMAND_BROWSE_BROWSE: Final = "browse/browse" +COMMAND_BROWSE_DELETE__PLAYLIST: Final = "browse/delete_playlist" +COMMAND_BROWSE_GET_SEARCH_CRITERIA: Final = "browse/get_search_criteria" +COMMAND_BROWSE_GET_SOURCE_INFO: Final = "browse/get_source_info" +COMMAND_BROWSE_GET_SOURCES: Final = "browse/get_music_sources" +COMMAND_BROWSE_MULTI_SEARCH: Final = "browse/multi_search" COMMAND_BROWSE_PLAY_INPUT: Final = "browse/play_input" COMMAND_BROWSE_PLAY_PRESET: Final = "browse/play_preset" COMMAND_BROWSE_PLAY_STREAM: Final = "browse/play_stream" -COMMAND_BROWSE_ADD_TO_QUEUE: Final = "browse/add_to_queue" +COMMAND_BROWSE_RENAME_PLAYLIST: Final = "browse/rename_playlist" +COMMAND_BROWSE_RETRIEVE_METADATA: Final = "browse/retrieve_metadata" +COMMAND_BROWSE_SEARCH: Final = "browse/search" +COMMAND_BROWSE_SET_SERVICE_OPTION: Final = "browse/set_service_option" # Player commands COMMAND_GET_PLAYERS: Final = "player/get_players" diff --git a/pyheos/command/browse.py b/pyheos/command/browse.py index 4a2bf37..b92e8f5 100644 --- a/pyheos/command/browse.py +++ b/pyheos/command/browse.py @@ -3,17 +3,10 @@ This module creates HEOS browse commands. -Commands not currently implemented: - 4.4.2 Get Source Info - 4.4.5 Get Source Search Criteria - 4.4.6 Search - 4.4.14 Rename HEOS Playlist - 4.4.15 Delete HEOS Playlist - 4.4.17 Retrieve Album Metadata - 4.4.19 Set service option - 4.4.20 Universal Search (Multi-Search) - - +Not implemented (commands do not exist/obsolete): + 4.4.13 Get HEOS Playlists: Refer to Browse Sources and Browse Source Containers + 4.4.16 Get HEOS History: Refer to Browse Sources and Browse Source Containers + 4.4.18 Get Service Options for now playing screen: OBSOLETE """ from typing import Any @@ -60,6 +53,60 @@ def get_music_sources(refresh: bool = False) -> HeosCommand: params[const.ATTR_REFRESH] = const.VALUE_ON return HeosCommand(command.COMMAND_BROWSE_GET_SOURCES, params) + @staticmethod + def get_music_source_info(source_id: int) -> HeosCommand: + """ + Create a HEOS command to get information about a music source. + + References: + 4.4.2 Get Source Info + """ + return HeosCommand( + command.COMMAND_BROWSE_GET_SOURCE_INFO, {const.ATTR_SOURCE_ID: source_id} + ) + + @staticmethod + def get_search_criteria(source_id: int) -> HeosCommand: + """ + Create a HEOS command to get the search criteria. + + References: + 4.4.5 Get Search Criteria + """ + return HeosCommand( + command.COMMAND_BROWSE_GET_SEARCH_CRITERIA, + {const.ATTR_SOURCE_ID: source_id}, + ) + + @staticmethod + def search( + source_id: int, + search: str, + criteria_id: int, + range_start: int | None = None, + range_end: int | None = None, + ) -> HeosCommand: + """ + Create a HEOS command to search for media. + + References: + 4.4.6 Search + """ + if search == "": + raise ValueError("'search' parameter must not be empty") + if len(search) > 128: + raise ValueError( + "'search' parameter must be less than or equal to 128 characters" + ) + params = { + const.ATTR_SOURCE_ID: source_id, + const.ATTR_SEARCH: search, + const.ATTR_SEARCH_CRITERIA_ID: criteria_id, + } + if isinstance(range_start, int) and isinstance(range_end, int): + params[const.ATTR_RANGE] = f"{range_start},{range_end}" + return HeosCommand(command.COMMAND_BROWSE_SEARCH, params) + @staticmethod def play_station( player_id: int, @@ -154,3 +201,232 @@ def add_to_queue( if media_id is not None: params[const.ATTR_MEDIA_ID] = media_id return HeosCommand(command.COMMAND_BROWSE_ADD_TO_QUEUE, params) + + @staticmethod + def rename_playlist( + source_id: int, container_id: str, new_name: str + ) -> HeosCommand: + """ + Create a HEOS command to rename a playlist. + + References: + 4.4.14 Rename HEOS Playlist + """ + if new_name == "": + raise ValueError("'new_name' parameter must not be empty") + if len(new_name) > 128: + raise ValueError( + "'new_name' parameter must be less than or equal to 128 characters" + ) + return HeosCommand( + command.COMMAND_BROWSE_RENAME_PLAYLIST, + { + const.ATTR_SOURCE_ID: source_id, + const.ATTR_CONTAINER_ID: container_id, + const.ATTR_NAME: new_name, + }, + ) + + @staticmethod + def delete_playlist(source_id: int, container_id: str) -> HeosCommand: + """ + Create a HEOS command to delete a playlist. + + References: + 4.4.15 Delete HEOS Playlist""" + return HeosCommand( + command.COMMAND_BROWSE_DELETE__PLAYLIST, + {const.ATTR_SOURCE_ID: source_id, const.ATTR_CONTAINER_ID: container_id}, + ) + + @staticmethod + def retrieve_metadata(source_it: int, container_id: str) -> HeosCommand: + """ + Create a HEOS command to retrieve metadata. + + References: + 4.4.17 Retrieve Metadata + """ + return HeosCommand( + command.COMMAND_BROWSE_RETRIEVE_METADATA, + {const.ATTR_SOURCE_ID: source_it, const.ATTR_CONTAINER_ID: container_id}, + ) + + @staticmethod + def set_service_option( + option_id: int, + source_id: int | None, + container_id: str | None, + media_id: str | None, + player_id: int | None, + name: str | None, + criteria_id: int | None, + range_start: int | None = None, + range_end: int | None = None, + ) -> HeosCommand: + """ + Create a HEOS command to set a service option. + + References: + 4.4.19 Set Service Option + """ + params: dict[str, Any] = {const.ATTR_OPTION_ID: option_id} + disallowed_params = {} + + if option_id in ( + const.SERVICE_OPTION_ADD_TRACK_TO_LIBRARY, + const.SERVICE_OPTION_ADD_STATION_TO_LIBRARY, + const.SERVICE_OPTION_REMOVE_TRACK_FROM_LIBRARY, + const.SERVICE_OPTION_REMOVE_STATION_FROM_LIBRARY, + ): + if source_id is None or media_id is None: + raise ValueError( + f"source_id and media_id parameters are required for service option_id {option_id}" + ) + disallowed_params = { + "container_id": container_id, + "player_id": player_id, + "name": name, + "criteria_id": criteria_id, + "range_start": range_start, + "range_end": range_end, + } + params[const.ATTR_SOURCE_ID] = source_id + params[const.ATTR_MEDIA_ID] = media_id + elif option_id in ( + const.SERVICE_OPTION_ADD_ALBUM_TO_LIBRARY, + const.SERVICE_OPTION_REMOVE_ALBUM_FROM_LIBRARY, + const.SERVICE_OPTION_REMOVE_PLAYLIST_FROM_LIBRARY, + ): + if source_id is None or container_id is None: + raise ValueError( + f"source_id and container_id parameters are required for service option_id {option_id}" + ) + disallowed_params = { + "media_id": media_id, + "player_id": player_id, + "name": name, + "criteria_id": criteria_id, + "range_start": range_start, + "range_end": range_end, + } + params[const.ATTR_SOURCE_ID] = source_id + params[const.ATTR_CONTAINER_ID] = container_id + elif option_id == const.SERVICE_OPTION_ADD_PLAYLIST_TO_LIBRARY: + if source_id is None or container_id is None or name is None: + raise ValueError( + f"source_id, container_id, and name parameters are required for service option_id {option_id}" + ) + disallowed_params = { + "media_id": media_id, + "player_id": player_id, + "criteria_id": criteria_id, + "range_start": range_start, + "range_end": range_end, + } + params[const.ATTR_SOURCE_ID] = source_id + params[const.ATTR_CONTAINER_ID] = container_id + params[const.ATTR_NAME] = name + elif option_id in ( + const.SERVICE_OPTION_THUMBS_UP, + const.SERVICE_OPTION_THUMBS_DOWN, + ): + if source_id is None or player_id is None: + raise ValueError( + f"source_id and player_id parameters are required for service option_id {option_id}" + ) + disallowed_params = { + "media_id": media_id, + "container_id": container_id, + "name": name, + "criteria_id": criteria_id, + "range_start": range_start, + "range_end": range_end, + } + params[const.ATTR_SOURCE_ID] = source_id + params[const.ATTR_PLAYER_ID] = player_id + elif option_id == const.SERVICE_OPTION_CREATE_NEW_STATION_BY_SEARCH_CRITERIA: + if source_id is None or name is None or criteria_id is None: + raise ValueError( + f"source_id, name, and criteria_id parameters are required for service option_id {option_id}" + ) + disallowed_params = { + "media_id": media_id, + "container_id": container_id, + "player_id": player_id, + } + params[const.ATTR_SOURCE_ID] = source_id + params[const.ATTR_SEARCH_CRITERIA_ID] = criteria_id + params[const.ATTR_NAME] = name + if isinstance(range_start, int) and isinstance(range_end, int): + params[const.ATTR_RANGE] = f"{range_start},{range_end}" + elif option_id == const.SERVICE_OPTION_ADD_TO_FAVORITES: + if not bool(player_id) ^ ( + source_id is not None and media_id is not None and name is not None + ): + raise ValueError( + f"Either parameters player_id OR source_id, media_id, and name are required for service option_id {option_id}" + ) + if player_id is not None: + if source_id is not None or media_id is not None or name is not None: + raise ValueError( + f"source_id, media_id, and name parameters are not allowed when using player_id for service option_id {option_id}" + ) + params[const.ATTR_PLAYER_ID] = player_id + else: + params[const.ATTR_SOURCE_ID] = source_id + params[const.ATTR_MEDIA_ID] = media_id + params[const.ATTR_NAME] = name + disallowed_params = { + "container_id": container_id, + "criteria_id": criteria_id, + "range_start": range_start, + "range_end": range_end, + } + elif option_id == const.SERVICE_OPTION_REMOVE_FROM_FAVORITES: + if media_id is None: + raise ValueError( + f"media_id parameter is required for service option_id {option_id}" + ) + params[const.ATTR_MEDIA_ID] = media_id + disallowed_params = { + "source_id": source_id, + "player_id": player_id, + "container_id": container_id, + "name": name, + "criteria_id": criteria_id, + "range_start": range_start, + "range_end": range_end, + } + else: + raise ValueError(f"Unknown option_id: {option_id}") + + # Raise if any disallowed parameters are provided + if any(param is not None for param in disallowed_params.values()): + raise ValueError( + f"{', '.join(disallowed_params.keys())} parameters are not allowed for service option_id {option_id}" + ) + + # return the command + return HeosCommand(command.COMMAND_BROWSE_SET_SERVICE_OPTION, params) + + @staticmethod + def multi_search( + search: str, source_ids: list[int] | None, criteria_ids: list[int] | None + ) -> HeosCommand: + """ + Create a HEOS command to perform a multi-search. + + References: + 4.4.20 Multi Search + """ + if len(search) > 128: + raise ValueError( + "'search' parameter must be less than or equal to 128 characters" + ) + params = {const.ATTR_SEARCH: search} + if source_ids is not None: + params[const.ATTR_SOURCE_ID] = ",".join(map(str, source_ids)) + if criteria_ids is not None: + params[const.ATTR_SEARCH_CRITERIA_ID] = ",".join(map(str, criteria_ids)) + return HeosCommand(command.COMMAND_BROWSE_MULTI_SEARCH, params) diff --git a/pyheos/command/player.py b/pyheos/command/player.py index 14e5d7c..19a7d5a 100644 --- a/pyheos/command/player.py +++ b/pyheos/command/player.py @@ -217,7 +217,7 @@ def save_queue(player_id: int, name: str) -> HeosCommand: References: 4.2.18 Save Queue as Playlist""" - if len(name) >= 128: + if len(name) > 128: raise ValueError("'name' must be less than or equal to 128 characters") return HeosCommand( command.COMMAND_SAVE_QUEUE, diff --git a/pyheos/connection.py b/pyheos/connection.py index eb47fc6..f1403e5 100644 --- a/pyheos/connection.py +++ b/pyheos/connection.py @@ -15,7 +15,7 @@ STATE_DISCONNECTED, STATE_RECONNECTING, ) -from .error import CommandError, CommandFailedError, HeosError, format_error_message +from .error import CommandError, CommandFailedError, HeosError, _format_error_message CLI_PORT: Final = 1255 SEPARATOR: Final = "\r\n" @@ -145,7 +145,7 @@ async def _read_handler(self, reader: asyncio.StreamReader) -> None: else: self._last_activity = datetime.now() await self._handle_message( - HeosMessage.from_raw_message(binary_result.decode()) + HeosMessage._from_raw_message(binary_result.decode()) ) async def _handle_message(self, message: HeosMessage) -> None: @@ -182,7 +182,7 @@ async def _command_impl() -> HeosMessage: except (ConnectionError, OSError, AttributeError) as error: # Occurs when the connection is broken. Run in the background to ensure connection is reset. self._register_task(self._disconnect_from_error(error)) - message = format_error_message(error) + message = _format_error_message(error) _LOGGER.debug(f"Command failed '{command.uri_masked}': {message}") raise CommandError(command.command, message) from error else: @@ -202,7 +202,7 @@ async def _command_impl() -> HeosMessage: # Occurs when the command times out _LOGGER.debug(f"Command timed out '{command.uri_masked}'") raise CommandError( - command.command, format_error_message(error) + command.command, _format_error_message(error) ) from error finally: self._pending_command_event.clear() @@ -241,7 +241,7 @@ async def connect(self) -> None: asyncio.open_connection(self._host, CLI_PORT), self._timeout ) except (OSError, ConnectionError, asyncio.TimeoutError) as err: - raise HeosError(format_error_message(err)) from err + raise HeosError(_format_error_message(err)) from err # Start read handler self._register_task(self._read_handler(reader)) diff --git a/pyheos/const.py b/pyheos/const.py index 72c0f83..120170c 100644 --- a/pyheos/const.py +++ b/pyheos/const.py @@ -10,8 +10,8 @@ DEFAULT_STEP: Final = 5 ATTR_ADD_CRITERIA_ID: Final = "aid" -ATTR_ALBUM_ID: Final = "album_id" ATTR_ALBUM: Final = "album" +ATTR_ALBUM_ID: Final = "album_id" ATTR_ARTIST: Final = "artist" ATTR_AVAILABLE: Final = "available" ATTR_COMMAND: Final = "command" @@ -24,9 +24,11 @@ ATTR_ENABLE: Final = "enable" ATTR_ERROR: Final = "error" ATTR_ERROR_ID: Final = "eid" +ATTR_ERROR_NUMBER: Final = "errno" ATTR_GROUP_ID: Final = "gid" ATTR_HEOS: Final = "heos" ATTR_ID: Final = "id" +ATTR_IMAGES: Final = "images" ATTR_IMAGE_URL: Final = "image_url" ATTR_INPUT: Final = "input" ATTR_IP_ADDRESS: Final = "ip" @@ -38,6 +40,8 @@ ATTR_MUTE: Final = "mute" ATTR_NAME: Final = "name" ATTR_NETWORK: Final = "network" +ATTR_OPTIONS: Final = "options" +ATTR_OPTION_ID: Final = "option" ATTR_PASSWORD: Final = "pw" ATTR_PAYLOAD: Final = "payload" ATTR_PLAYABLE: Final = "playable" @@ -51,16 +55,19 @@ ATTR_RESULT: Final = "result" ATTR_RETURNED: Final = "returned" ATTR_ROLE: Final = "role" +ATTR_SEARCH: Final = "search" +ATTR_SEARCH_CRITERIA_ID: Final = "scid" ATTR_SERIAL: Final = "serial" ATTR_SERVICE_USER_NAME: Final = "service_username" ATTR_SHUFFLE: Final = "shuffle" +ATTR_SIGNED_IN: Final = "signed_in" +ATTR_SIGNED_OUT: Final = "signed_out" ATTR_SONG: Final = "song" ATTR_SOURCE_ID: Final = "sid" ATTR_SOURCE_PLAYER_ID: Final = "spid" ATTR_SOURCE_QUEUE_ID: Final = "sqid" -ATTR_SIGNED_OUT: Final = "signed_out" -ATTR_SIGNED_IN: Final = "signed_in" ATTR_STATE: Final = "state" +ATTR_STATS: Final = "stats" ATTR_STATION: Final = "station" ATTR_STEP: Final = "step" ATTR_SYSTEM_ERROR_NUMBER: Final = "syserrno" @@ -70,6 +77,9 @@ ATTR_URL: Final = "url" ATTR_USER_NAME: Final = "un" ATTR_VERSION: Final = "version" +ATTR_WIDTH: Final = "width" +ATTR_WILDCARD: Final = "wildcard" + VALUE_ON: Final = "on" VALUE_OFF: Final = "off" @@ -355,6 +365,22 @@ class AddCriteriaType(IntEnum): REPLACE_AND_PLAY = 4 +# Service options +SERVICE_OPTION_ADD_TRACK_TO_LIBRARY: Final = 1 +SERVICE_OPTION_ADD_ALBUM_TO_LIBRARY: Final = 2 +SERVICE_OPTION_ADD_STATION_TO_LIBRARY: Final = 3 +SERVICE_OPTION_ADD_PLAYLIST_TO_LIBRARY: Final = 4 +SERVICE_OPTION_REMOVE_TRACK_FROM_LIBRARY: Final = 5 +SERVICE_OPTION_REMOVE_ALBUM_FROM_LIBRARY: Final = 6 +SERVICE_OPTION_REMOVE_STATION_FROM_LIBRARY: Final = 7 +SERVICE_OPTION_REMOVE_PLAYLIST_FROM_LIBRARY: Final = 8 +SERVICE_OPTION_THUMBS_UP: Final = 11 +SERVICE_OPTION_THUMBS_DOWN: Final = 12 +SERVICE_OPTION_CREATE_NEW_STATION_BY_SEARCH_CRITERIA: Final = 13 +SERVICE_OPTION_ADD_TO_FAVORITES: Final = 19 +SERVICE_OPTION_REMOVE_FROM_FAVORITES: Final = 20 + + # Signals SIGNAL_PLAYER_EVENT: Final = "player_event" SIGNAL_GROUP_EVENT: Final = "group_event" @@ -364,9 +390,6 @@ class AddCriteriaType(IntEnum): EVENT_DISCONNECTED: Final = "disconnected" EVENT_USER_CREDENTIALS_INVALID: Final = "user credentials invalid" -BASE_URI: Final = "heos://" - - # Events EVENT_PLAYER_STATE_CHANGED: Final = "event/player_state_changed" EVENT_PLAYER_NOW_PLAYING_CHANGED: Final = "event/player_now_playing_changed" diff --git a/pyheos/error.py b/pyheos/error.py index bdd6c31..49fb3aa 100644 --- a/pyheos/error.py +++ b/pyheos/error.py @@ -17,7 +17,7 @@ } -def format_error_message(error: Exception) -> str: +def _format_error_message(error: Exception) -> str: """Format the error message based on a base error.""" error_message: str = str(error) if not error_message: diff --git a/pyheos/heos.py b/pyheos/heos.py index c4029c5..404021b 100644 --- a/pyheos/heos.py +++ b/pyheos/heos.py @@ -20,8 +20,15 @@ callback_wrapper, ) from pyheos.error import CommandAuthenticationError, CommandFailedError -from pyheos.media import BrowseResult, MediaItem, MediaMusicSource, QueueItem +from pyheos.media import ( + BrowseResult, + MediaItem, + MediaMusicSource, + QueueItem, + RetreiveMetadataResult, +) from pyheos.message import HeosMessage +from pyheos.search import MultiSearchResult, SearchCriteria, SearchResult from pyheos.system import HeosHost, HeosSystem from . import const @@ -238,6 +245,43 @@ async def get_music_sources( self._music_sources_loaded = True return self._music_sources + async def get_music_source_info( + self, + source_id: int | None = None, + music_source: MediaMusicSource | None = None, + *, + refresh: bool = False, + ) -> MediaMusicSource: + """ + Get information about a specific music source. + + References: + 4.4.2 Get Source Info + """ + if source_id is None and music_source is None: + raise ValueError("Either source_id or music_source must be provided") + if source_id is not None and music_source is not None: + raise ValueError("Only one of source_id or music_source should be provided") + + # if only source_id provided, try getting from loaded + if music_source is None: + assert source_id is not None + music_source = self._music_sources.get(source_id) + else: + source_id = music_source.source_id + + if music_source is None or refresh: + # Get the latest information + result = await self._connection.command( + BrowseCommands.get_music_source_info(source_id) + ) + payload = cast(dict[str, Any], result.payload) + if music_source is None: + music_source = MediaMusicSource.from_data(payload, cast("Heos", self)) + else: + music_source._update_from_data(payload) + return music_source + async def browse( self, source_id: int, @@ -264,7 +308,7 @@ async def browse( message = await self._connection.command( BrowseCommands.browse(source_id, container_id, range_start, range_end) ) - return BrowseResult.from_data(message, cast("Heos", self)) + return BrowseResult._from_message(message, cast("Heos", self)) async def browse_media( self, @@ -298,6 +342,40 @@ async def browse_media( media.source_id, media.container_id, range_start, range_end ) + async def get_search_criteria(self, source_id: int) -> list[SearchCriteria]: + """ + Create a HEOS command to get the search criteria. + + References: + 4.4.5 Get Search Criteria + """ + result = await self._connection.command( + BrowseCommands.get_search_criteria(source_id) + ) + payload = cast(list[dict[str, str]], result.payload) + return [SearchCriteria._from_data(data) for data in payload] + + async def search( + self, + source_id: int, + search: str, + criteria_id: int, + range_start: int | None = None, + range_end: int | None = None, + ) -> SearchResult: + """ + Create a HEOS command to search for media. + + References: + 4.4.6 Search""" + + result = await self._connection.command( + BrowseCommands.search( + source_id, search, criteria_id, range_start, range_end + ) + ) + return SearchResult._from_message(result, cast("Heos", self)) + async def play_input_source( self, player_id: int, input: str, source_player_id: int | None = None ) -> None: @@ -395,6 +473,75 @@ async def add_to_queue( ) ) + async def rename_playlist( + self, source_id: int, container_id: str, new_name: str + ) -> None: + """ + Rename a HEOS playlist. + + References: + 4.4.14 Rename HEOS Playlist + """ + await self._connection.command( + BrowseCommands.rename_playlist(source_id, container_id, new_name) + ) + + async def delete_playlist(self, source_id: int, container_id: str) -> None: + """ + Create a HEOS command to delete a playlist. + + References: + 4.4.15 Delete HEOS Playlist""" + await self._connection.command( + BrowseCommands.delete_playlist(source_id, container_id) + ) + + async def retrieve_metadata( + self, source_it: int, container_id: str + ) -> RetreiveMetadataResult: + """ + Create a HEOS command to retrieve metadata. Only supported by Rhapsody/Napster music sources. + + References: + 4.4.17 Retrieve Metadata + """ + result = await self._connection.command( + BrowseCommands.retrieve_metadata(source_it, container_id) + ) + return RetreiveMetadataResult._from_message(result) + + async def set_service_option( + this, + option_id: int, + source_id: int | None = None, + container_id: str | None = None, + media_id: str | None = None, + player_id: int | None = None, + name: str | None = None, + criteria_id: int | None = None, + range_start: int | None = None, + range_end: int | None = None, + ) -> None: + """ + Create a HEOS command to set a service option. + + References: + 4.4.19 Set Service Option + """ + await this._connection.command( + BrowseCommands.set_service_option( + option_id, + source_id, + container_id, + media_id, + player_id, + name, + criteria_id, + range_start, + range_end, + ) + ) + async def play_media( self, player_id: int, @@ -476,6 +623,23 @@ async def get_playlists(self) -> Sequence[MediaItem]: result = await self.browse(const.MUSIC_SOURCE_PLAYLISTS) return result.items + async def multi_search( + self, + search: str, + source_ids: list[int] | None = None, + criteria_ids: list[int] | None = None, + ) -> MultiSearchResult: + """ + Create a HEOS command to perform a multi-search. + + References: + 4.4.20 Multi Search + """ + result = await self._connection.command( + BrowseCommands.multi_search(search, source_ids, criteria_ids) + ) + return MultiSearchResult._from_message(result, cast("Heos", self)) + class PlayerMixin(ConnectionMixin): """A mixin to provide access to the player commands.""" @@ -542,7 +706,7 @@ async def get_player_info( payload = cast(dict[str, Any], result.payload) if player is None: - player = HeosPlayer.from_data(payload, cast("Heos", self)) + player = HeosPlayer._from_data(payload, cast("Heos", self)) else: player._update_from_data(payload) await player.refresh(refresh_base_info=False) @@ -581,7 +745,7 @@ async def load_players(self) -> dict[str, list | dict]: existing.remove(player) else: # New player - player = HeosPlayer.from_data(player_data, cast("Heos", self)) + player = HeosPlayer._from_data(player_data, cast("Heos", self)) new_player_ids.append(player_id) players[player_id] = player # For any item remaining in existing, mark unavailalbe, add to updated @@ -641,7 +805,7 @@ async def get_now_playing_media( PlayerCommands.get_now_playing_media(player_id) ) instance = update or HeosNowPlayingMedia() - instance.update_from_message(result) + instance._update_from_message(result) return instance async def player_get_volume(self, player_id: int) -> int: @@ -705,7 +869,7 @@ async def player_get_play_mode(self, player_id: int) -> PlayMode: References: 4.2.13 Get Play Mode""" result = await self._connection.command(PlayerCommands.get_play_mode(player_id)) - return PlayMode.from_data(result) + return PlayMode._from_data(result) async def player_set_play_mode( self, player_id: int, repeat: const.RepeatType, shuffle: bool @@ -844,7 +1008,7 @@ async def player_check_update(self, player_id: int) -> bool: return bool(payload[const.ATTR_UPDATE] == const.VALUE_UPDATE_EXIST) -class GroupMixin(PlayerMixin): +class GroupMixin(ConnectionMixin): """A mixin to provide access to the group commands.""" def __init__(self, *args: Any, **kwargs: Any) -> None: @@ -1243,7 +1407,9 @@ async def _on_event_player(self, event: HeosMessage) -> None: """Process an event about a player.""" player_id = event.get_message_value_int(const.ATTR_PLAYER_ID) player = self.players.get(player_id) - if player and (await player.on_event(event, self._options.all_progress_events)): + if player and ( + await player._on_event(event, self._options.all_progress_events) + ): await self.dispatcher.wait_send( const.SIGNAL_PLAYER_EVENT, player_id, diff --git a/pyheos/media.py b/pyheos/media.py index 9b2dd4b..d642e64 100644 --- a/pyheos/media.py +++ b/pyheos/media.py @@ -80,6 +80,15 @@ def from_data( heos=heos, ) + def _update_from_data(self, data: dict[str, Any]) -> None: + """Update the instance with new data.""" + self.source_id = int(data[const.ATTR_SOURCE_ID]) + self.name = data[const.ATTR_NAME] + self.type = const.MediaType(data[const.ATTR_TYPE]) + self.image_url = data[const.ATTR_IMAGE_URL] + self.available = data[const.ATTR_AVAILABLE] == const.VALUE_TRUE + self.service_username = data.get(const.ATTR_SERVICE_USER_NAME) + def clone(self) -> "MediaMusicSource": """Create a new instance from the current instance.""" return MediaMusicSource( @@ -92,6 +101,11 @@ def clone(self) -> "MediaMusicSource": heos=self.heos, ) + async def refresh(self) -> None: + """Refresh the instance with the latest data.""" + assert self.heos, "Heos instance not set" + await self.heos.get_music_source_info(music_source=self, refresh=True) + async def browse(self) -> "BrowseResult": """Browse the contents of this source. @@ -193,6 +207,44 @@ async def play_media( await self.heos.play_media(player_id, self, add_criteria) +@dataclass +class ServiceOption: + """Define a service option.""" + + context: str + id: int + name: str + + @staticmethod + def _from_options( + data: list[dict[str, list[dict[str, Any]]]] | None, + ) -> list["ServiceOption"]: + """Create a list of instances from the provided data.""" + options: list[ServiceOption] = [] + if data is None: + return options + + # Unpack the options and flatten structure. Example payload: + # [{"play": [{"id": 19, "name": "Add to HEOS Favorites"}]}] + for context in data: + for context_key, context_options in context.items(): + options.extend( + [ + ServiceOption.__from_data(context_key, item) + for item in context_options + ] + ) + + return options + + @staticmethod + def __from_data(context: str, data: dict[str, str]) -> "ServiceOption": + """Create a new instance from the provided data.""" + return ServiceOption( + context=context, id=int(data[const.ATTR_ID]), name=data[const.ATTR_NAME] + ) + + @dataclass class BrowseResult: """Define the result of a browse operation.""" @@ -201,18 +253,19 @@ class BrowseResult: returned: int source_id: int items: Sequence[MediaItem] = field(repr=False, hash=False, compare=False) + options: Sequence[ServiceOption] = field(repr=False, hash=False, compare=False) container_id: str | None = None heos: Optional["Heos"] = field(repr=False, hash=False, compare=False, default=None) - @classmethod - def from_data( - cls, message: HeosMessage, heos: Optional["Heos"] = None + @staticmethod + def _from_message( + message: HeosMessage, heos: Optional["Heos"] = None ) -> "BrowseResult": """Create a new instance from the provided data.""" source_id = message.get_message_value_int(const.ATTR_SOURCE_ID) container_id = message.message.get(const.ATTR_CONTAINER_ID) - return cls( + return BrowseResult( count=message.get_message_value_int(const.ATTR_COUNT), returned=message.get_message_value_int(const.ATTR_RETURNED), source_id=source_id, @@ -223,5 +276,66 @@ def from_data( for item in cast(Sequence[dict], message.payload) ] ), + options=ServiceOption._from_options(message.options), heos=heos, ) + + +@dataclass +class ImageMetadata: + """Define metadata for an image.""" + + image_url: str + width: int + + @staticmethod + def _from_data(data: dict[str, Any]) -> "ImageMetadata": + """Create a new instance from the provided data.""" + return ImageMetadata( + image_url=data[const.ATTR_IMAGE_URL], + width=int(data[const.ATTR_WIDTH]), + ) + + +@dataclass +class AlbumMetadata: + """Define metadata for an album.""" + + album_id: str + images: Sequence[ImageMetadata] = field(repr=False, hash=False, compare=False) + + @staticmethod + def _from_data(data: dict[str, Any]) -> "AlbumMetadata": + """Create a new instance from the provided data.""" + return AlbumMetadata( + album_id=data[const.ATTR_ALBUM_ID], + images=[ + ImageMetadata._from_data(cast(dict[str, Any], image)) + for image in data[const.ATTR_IMAGES] + ], + ) + + +@dataclass +class RetreiveMetadataResult: + "Define the result of a retrieve metadata operation." + + source_id: int + container_id: str + returned: int + count: int + metadata: Sequence[AlbumMetadata] = field(repr=False, hash=False, compare=False) + + @staticmethod + def _from_message(message: HeosMessage) -> "RetreiveMetadataResult": + "Create a new instance from the provided data." + return RetreiveMetadataResult( + source_id=message.get_message_value_int(const.ATTR_SOURCE_ID), + container_id=message.get_message_value(const.ATTR_CONTAINER_ID), + returned=message.get_message_value_int(const.ATTR_RETURNED), + count=message.get_message_value_int(const.ATTR_COUNT), + metadata=[ + AlbumMetadata._from_data(item) + for item in cast(Sequence[dict[str, Any]], message.payload) + ], + ) diff --git a/pyheos/message.py b/pyheos/message.py index 452d15c..b3c6eed 100644 --- a/pyheos/message.py +++ b/pyheos/message.py @@ -8,6 +8,7 @@ from pyheos import const +BASE_URI: Final = "heos://" QUOTE_MAP: Final = {"&": "%26", "=": "%3D", "%": "%25"} MASKED_PARAMS: Final = {const.ATTR_PASSWORD} MASK: Final = "********" @@ -37,7 +38,7 @@ def _get_uri(self, mask: bool = False) -> str: if self.parameters else "" ) - return f"{const.BASE_URI}{self.command}{query_string}" + return f"{BASE_URI}{self.command}{query_string}" @classmethod def _quote(cls, value: Any) -> str: @@ -67,6 +68,7 @@ class HeosMessage: result: bool = True message: dict[str, str] = field(default_factory=dict) payload: dict[str, Any] | list[Any] | None = None + options: list[dict[str, list[dict[str, Any]]]] | None = None _raw_message: str | None = field( init=False, hash=False, repr=False, compare=False, default=None @@ -76,12 +78,12 @@ def __repr__(self) -> str: """Get a string representaton of the message.""" return self._raw_message or f"{self.command} {self.message}" - @classmethod - def from_raw_message(cls, raw_message: str) -> "HeosMessage": + @staticmethod + def _from_raw_message(raw_message: str) -> "HeosMessage": """Create a HeosMessage from a raw message.""" container = json.loads(raw_message) heos = container[const.ATTR_HEOS] - instance = cls( + instance = HeosMessage( command=str(heos[const.ATTR_COMMAND]), result=bool( heos.get(const.ATTR_RESULT, const.VALUE_SUCCESS) == const.VALUE_SUCCESS @@ -90,6 +92,7 @@ def from_raw_message(cls, raw_message: str) -> "HeosMessage": parse_qsl(heos.get(const.ATTR_MESSAGE, ""), keep_blank_values=True) ), payload=container.get(const.ATTR_PAYLOAD), + options=container.get(const.ATTR_OPTIONS), ) instance._raw_message = raw_message return instance diff --git a/pyheos/player.py b/pyheos/player.py index 1f0fe29..3004f4d 100644 --- a/pyheos/player.py +++ b/pyheos/player.py @@ -7,7 +7,7 @@ from typing import TYPE_CHECKING, Any, Optional, cast from pyheos.dispatch import DisconnectType, EventCallbackType, callback_wrapper -from pyheos.media import MediaItem, QueueItem +from pyheos.media import MediaItem, QueueItem, ServiceOption from pyheos.message import HeosMessage from . import const @@ -36,12 +36,15 @@ class HeosNowPlayingMedia: supported_controls: Sequence[str] = field( default_factory=lambda: const.CONTROLS_ALL, init=False ) + options: Sequence[ServiceOption] = field( + repr=False, hash=False, compare=False, default_factory=list + ) def __post_init__(self, *args: Any, **kwargs: Any) -> None: """Pst initialize the now playing media.""" self._update_supported_controls() - def update_from_message(self, message: HeosMessage) -> None: + def _update_from_message(self, message: HeosMessage) -> None: """Update the current instance from another instance.""" data = cast(dict[str, Any], message.payload) self.type = data.get(const.ATTR_TYPE) @@ -52,13 +55,14 @@ def update_from_message(self, message: HeosMessage) -> None: self.image_url = data.get(const.ATTR_IMAGE_URL) self.album_id = data.get(const.ATTR_ALBUM_ID) self.media_id = data.get(const.ATTR_MEDIA_ID) - self.queue_id = self.get_optional_int(data.get(const.ATTR_QUEUE_ID)) - self.source_id = self.get_optional_int(data.get(const.ATTR_SOURCE_ID)) + self.queue_id = self.__get_optional_int(data.get(const.ATTR_QUEUE_ID)) + self.source_id = self.__get_optional_int(data.get(const.ATTR_SOURCE_ID)) + self.options = ServiceOption._from_options(message.options) self._update_supported_controls() self.clear_progress() @staticmethod - def get_optional_int(value: Any) -> int | None: + def __get_optional_int(value: Any) -> int | None: try: return int(str(value)) except (TypeError, ValueError): @@ -76,7 +80,7 @@ def _update_supported_controls(self) -> None: ) self.supported_controls = new_supported_controls - def on_event(self, event: HeosMessage, all_progress_events: bool) -> bool: + def _on_event(self, event: HeosMessage, all_progress_events: bool) -> bool: """Update the position/duration from an event.""" if all_progress_events or self.current_position is None: self.current_position = event.get_message_value_int( @@ -101,10 +105,10 @@ class PlayMode: repeat: const.RepeatType shuffle: bool - @classmethod - def from_data(cls, data: HeosMessage) -> "PlayMode": + @staticmethod + def _from_data(data: HeosMessage) -> "PlayMode": """Create a new instance from the provided data.""" - return cls( + return PlayMode( repeat=const.RepeatType(data.get_message_value(const.ATTR_REPEAT)), shuffle=data.get_message_value(const.ATTR_SHUFFLE) == const.VALUE_ON, ) @@ -145,14 +149,13 @@ def __get_optional_int(value: str | None) -> int | None: return int(value) return None - @classmethod - def from_data( - cls, + @staticmethod + def _from_data( data: dict[str, Any], heos: Optional["Heos"] = None, ) -> "HeosPlayer": """Create a new instance from the provided data.""" - return cls( + return HeosPlayer( name=data[const.ATTR_NAME], player_id=int(data[const.ATTR_PLAYER_ID]), model=data[const.ATTR_MODEL], @@ -177,7 +180,7 @@ def _update_from_data(self, data: dict[str, Any]) -> None: self.line_out = int(data[const.ATTR_LINE_OUT]) self.group_id = HeosPlayer.__get_optional_int(data.get(const.ATTR_GROUP_ID)) - async def on_event(self, event: HeosMessage, all_progress_events: bool) -> bool: + async def _on_event(self, event: HeosMessage, all_progress_events: bool) -> bool: """Updates the player based on the received HEOS event. This is an internal method invoked by the Heos class and is not intended for direct use. @@ -185,7 +188,7 @@ async def on_event(self, event: HeosMessage, all_progress_events: bool) -> bool: Returns: True if the player event changed state, other wise False.""" if event.command == const.EVENT_PLAYER_NOW_PLAYING_PROGRESS: - return self.now_playing_media.on_event(event, all_progress_events) + return self.now_playing_media._on_event(event, all_progress_events) if event.command == const.EVENT_PLAYER_STATE_CHANGED: self.state = const.PlayState(event.get_message_value(const.ATTR_STATE)) if self.state == const.PlayState.PLAY: diff --git a/pyheos/search.py b/pyheos/search.py new file mode 100644 index 0000000..46a89a3 --- /dev/null +++ b/pyheos/search.py @@ -0,0 +1,163 @@ +"""Define the search module.""" + +import re +from collections.abc import Sequence +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Final, Optional, cast + +from pyheos import const +from pyheos.media import MediaItem +from pyheos.message import HeosMessage + +if TYPE_CHECKING: + from pyheos.heos import Heos + +TUPLE_MATCHER: Final = re.compile(r"\(([0-9,-]+)\)") + + +@dataclass +class SearchCriteria: + """Define the search criteria for a music source.""" + + name: str + criteria_id: int + wildcard: bool + container_id: str | None = None + playable: bool = False + + @staticmethod + def _from_data(data: dict[str, str]) -> "SearchCriteria": + """Create a new instance from the provided data.""" + return SearchCriteria( + name=data[const.ATTR_NAME], + criteria_id=int(data[const.ATTR_SEARCH_CRITERIA_ID]), + wildcard=data[const.ATTR_WILDCARD] == const.VALUE_YES, + container_id=data.get(const.ATTR_CONTAINER_ID), + playable=data.get(const.ATTR_PLAYABLE) == const.VALUE_YES, + ) + + +@dataclass +class SearchResult: + """Define the search result.""" + + source_id: int + criteria_id: int + search: str + returned: int + count: int + items: Sequence[MediaItem] = field(repr=False, hash=False, compare=False) + heos: Optional["Heos"] = field(repr=False, hash=False, compare=False, default=None) + + @staticmethod + def _from_message(message: HeosMessage, heos: "Heos") -> "SearchResult": + """Create a new instance from a message.""" + source_id = message.get_message_value_int(const.ATTR_SOURCE_ID) + + return SearchResult( + heos=heos, + source_id=source_id, + criteria_id=message.get_message_value_int(const.ATTR_SEARCH_CRITERIA_ID), + search=message.get_message_value(const.ATTR_SEARCH), + returned=message.get_message_value_int(const.ATTR_RETURNED), + count=message.get_message_value_int(const.ATTR_COUNT), + items=list( + [ + MediaItem.from_data(item, source_id, None, heos) + for item in cast(Sequence[dict[str, str]], message.payload) + ] + ), + ) + + +@dataclass +class MultiSearchResult: + """Define the results of a multi-search.""" + + source_ids: Sequence[int] + criteria_ids: Sequence[int] + search: str + returned: int + count: int + items: Sequence[MediaItem] = field(repr=False, hash=False, compare=False) + statistics: Sequence["SearchStatistic"] = field( + repr=False, hash=False, compare=False + ) + errors: Sequence["SearchStatistic"] = field(repr=False, hash=False, compare=False) + heos: Optional["Heos"] = field(repr=False, hash=False, compare=False, default=None) + + @staticmethod + def _from_message(message: HeosMessage, heos: "Heos") -> "MultiSearchResult": + """Create a new instance from a message.""" + source_ids = message.get_message_value(const.ATTR_SOURCE_ID).split(",") + criteria_ids = message.get_message_value(const.ATTR_SEARCH_CRITERIA_ID).split( + "," + ) + statisics = SearchStatistic._from_string( + message.get_message_value(const.ATTR_STATS) + ) + items: list[MediaItem] = [] + # In order to determine the source_id of the result, we match up the index with how many items were returned for a given source + payload = cast(list[dict[str, str]], message.payload) + index = 0 + for stat in statisics: + assert stat.returned is not None + for _ in range(stat.returned): + items.append( + MediaItem.from_data(payload[index], stat.source_id, heos=heos) + ) + index += 1 + + return MultiSearchResult( + heos=heos, + source_ids=[int(source_id) for source_id in source_ids], + criteria_ids=[int(criteria_id) for criteria_id in criteria_ids], + search=message.get_message_value(const.ATTR_SEARCH), + returned=message.get_message_value_int(const.ATTR_RETURNED), + count=message.get_message_value_int(const.ATTR_COUNT), + items=items, + statistics=statisics, + errors=SearchStatistic._from_string( + message.get_message_value(const.ATTR_ERROR_NUMBER) + ), + ) + + +@dataclass +class SearchStatistic: + """Define the search statistics.""" + + source_id: int + criteria_id: int + returned: int | None = None + count: int | None = None + error_number: int | None = None + + @staticmethod + def _from_string(data: str) -> list["SearchStatistic"]: + """Create a new instance from the provided tuple.""" + # stats=(10,2,2,2),(10,1,0,0),(1,0,57,57),(10,3,15,15) + # errno=(13,0,2),(8,0,-1061) + stats: list[SearchStatistic] = [] + matches = TUPLE_MATCHER.findall(data) + for match in matches: + stats_tuple = match.split(",") + + if len(stats_tuple) == 3: + stats.append( + SearchStatistic( + source_id=int(stats_tuple[0]), + criteria_id=int(stats_tuple[1]), + error_number=int(stats_tuple[2]), + ) + ) + else: + stats.append( + SearchStatistic( + source_id=int(stats_tuple[0]), + criteria_id=int(stats_tuple[1]), + returned=int(stats_tuple[2]), + count=int(stats_tuple[3]), + ) + ) + return stats diff --git a/pyproject.toml b/pyproject.toml index dcfc51b..6f80a6c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -365,3 +365,6 @@ exclude_lines = ["if TYPE_CHECKING:"] show_missing = true skip_empty = true sort = "Name" + +[tool.codespell] +skip = "./tests/fixtures/*" \ No newline at end of file diff --git a/tests/__init__.py b/tests/__init__.py index e04b2fb..44e47af 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -473,6 +473,7 @@ async def _get_response(self, response: str, query: dict) -> str: const.ATTR_PLAYER_ID: "{player_id}", const.ATTR_STATE: "{state}", const.ATTR_LEVEL: "{level}", + const.ATTR_OPTIONS: "{options}", } for key, token in keys.items(): value = query.get(key) diff --git a/tests/fixtures/browse.delete_playlist.json b/tests/fixtures/browse.delete_playlist.json new file mode 100644 index 0000000..8d928e1 --- /dev/null +++ b/tests/fixtures/browse.delete_playlist.json @@ -0,0 +1 @@ +{"heos": {"command": "browse/delete_playlist", "result": "success", "message": "sid=1025&cid=763965"}} \ No newline at end of file diff --git a/tests/fixtures/browse.get_search_criteria.json b/tests/fixtures/browse.get_search_criteria.json new file mode 100644 index 0000000..be4b49e --- /dev/null +++ b/tests/fixtures/browse.get_search_criteria.json @@ -0,0 +1 @@ +{"heos": {"command": "browse/get_search_criteria", "result": "success", "message": "sid=10"}, "payload": [{"name": "Artist", "scid": 1, "wildcard": "no"}, {"name": "Album", "scid": 2, "wildcard": "no"}, {"name": "Track", "scid": 3, "wildcard": "no", "cid": "SEARCHED_TRACKS-", "playable": "yes"}, {"name": "Playlist", "scid": 6, "wildcard": "no"}]} \ No newline at end of file diff --git a/tests/fixtures/browse.get_source_info.json b/tests/fixtures/browse.get_source_info.json new file mode 100644 index 0000000..c781ae7 --- /dev/null +++ b/tests/fixtures/browse.get_source_info.json @@ -0,0 +1 @@ +{"heos": {"command": "browse/get_source_info", "result": "success", "message": ""}, "payload": {"name": "Pandora", "image_url": "https://production.ws.skyegloup.com:443/media/images/service/logos/pandora.png", "type": "music_service", "sid": 1, "available": "true", "service_username": "email@email.com"}} \ No newline at end of file diff --git a/tests/fixtures/browse.multi_search.json b/tests/fixtures/browse.multi_search.json new file mode 100644 index 0000000..cc1eaee --- /dev/null +++ b/tests/fixtures/browse.multi_search.json @@ -0,0 +1 @@ +{"heos": {"command": "browse/multi_search", "result": "success", "message": "search=Tangerine Rays&sid=1,4,8,13,10&scid=0,1,2,3&returned=74&count=74&stats=(10,2,2,2),(10,1,0,0),(1,0,57,57),(10,3,15,15)&errno=(13,0,2),(8,0,-1061)"}, "payload": [{"container": "yes", "type": "album", "artist": "ZEDD", "cid": "LIBALBUM-401193042", "playable": "yes", "name": "Tangerine Rays", "image_url": "http://resources.wimpmusic.com/images/42831a87/1001/4335/a832/fb8bdf6781db/160x160.jpg"}, {"container": "yes", "type": "album", "artist": "ZEDD", "cid": "LIBALBUM-401192835", "playable": "yes", "name": "Tangerine Rays", "image_url": "http://resources.wimpmusic.com/images/f073e403/ee90/4d9e/80a4/731b57de9f55/160x160.jpg"}, {"container": "no", "mid": "CREATE_STATION-R66030", "type": "station", "playable": "yes", "name": "Tangerine Dream", "image_url": ""}, {"container": "no", "mid": "CREATE_STATION-R696162", "type": "station", "playable": "yes", "name": "X-Rays", "image_url": ""}, {"container": "no", "mid": "CREATE_STATION-R400422", "type": "station", "playable": "yes", "name": "Tangerine Kitty", "image_url": ""}, {"container": "no", "mid": "CREATE_STATION-R4004275", "type": "station", "playable": "yes", "name": "Ferrari Simmons %26 RaySean", "image_url": ""}, {"container": "no", "mid": "CREATE_STATION-R172591", "type": "station", "playable": "yes", "name": "Ray Stevens (Holiday)", "image_url": ""}, {"container": "no", "mid": "CREATE_STATION-R5180582", "type": "station", "playable": "yes", "name": "Dr. Hook %26 Ray Sawyer", "image_url": ""}, {"container": "no", "mid": "CREATE_STATION-R153854", "type": "station", "playable": "yes", "name": "X-Ray Spex", "image_url": ""}, {"container": "no", "mid": "CREATE_STATION-S133862497", "type": "station", "playable": "yes", "name": "Tangerine Rays by Zedd, Bea Miller %26 ellis", "image_url": ""}, {"container": "no", "mid": "CREATE_STATION-S142439068", "type": "station", "playable": "yes", "name": "Tangerine Rays by Zedd, Bea Miller %26 ellis", "image_url": ""}, {"container": "no", "mid": "CREATE_STATION-S142439069", "type": "station", "playable": "yes", "name": "Tangerine Rays (Instrumental) by Zedd, Bea Miller %26 ellis", "image_url": ""}, {"container": "no", "mid": "CREATE_STATION-S142439070", "type": "station", "playable": "yes", "name": "Tangerine Rays (Acapella) by Zedd, Bea Miller %26 ellis", "image_url": ""}, {"container": "no", "mid": "CREATE_STATION-S136888654", "type": "station", "playable": "yes", "name": "Tangerine Rays (8-Bit Bea Miller, Ellis %26 Zedd Emulation) by 8-Bit Arcade", "image_url": ""}, {"container": "no", "mid": "CREATE_STATION-S32964", "type": "station", "playable": "yes", "name": "Tangerine (Remaster) by Led Zeppelin", "image_url": ""}, {"container": "no", "mid": "CREATE_STATION-S5786562", "type": "station", "playable": "yes", "name": "Tangerine Sky by Kottonmouth Kings", "image_url": ""}, {"container": "no", "mid": "CREATE_STATION-S164056", "type": "station", "playable": "yes", "name": "Tangerine by Eliane Elias", "image_url": ""}, {"container": "no", "mid": "CREATE_STATION-S5615318", "type": "station", "playable": "yes", "name": "Tangerine by Herb Alpert %26 The Tijuana Brass", "image_url": ""}, {"container": "no", "mid": "CREATE_STATION-S46560612", "type": "station", "playable": "yes", "name": "TANGERINE DREAM by Snoh Aalegra", "image_url": ""}, {"container": "no", "mid": "CREATE_STATION-S7989950", "type": "station", "playable": "yes", "name": "Sun Rays Like Stilts by Tommy Guerrero", "image_url": ""}, {"container": "no", "mid": "CREATE_STATION-S571786", "type": "station", "playable": "yes", "name": "Tangerine (Remastered) by Les Brown", "image_url": ""}, {"container": "no", "mid": "CREATE_STATION-S13186508", "type": "station", "playable": "yes", "name": "Sun Rays Vol. 2 by Chillout Lounge", "image_url": ""}, {"container": "no", "mid": "CREATE_STATION-S1454325", "type": "station", "playable": "yes", "name": "Say Goodbye to the Tangerine Sky by Kottonmouth Kings", "image_url": ""}, {"container": "no", "mid": "CREATE_STATION-S1926771", "type": "station", "playable": "yes", "name": "Rays of Light by 2002", "image_url": ""}, {"container": "no", "mid": "CREATE_STATION-S28616466", "type": "station", "playable": "yes", "name": "Los Santos City Map by Tangerine Dream", "image_url": ""}, {"container": "no", "mid": "CREATE_STATION-S48872068", "type": "station", "playable": "yes", "name": "Tangerine by Glass Animals", "image_url": ""}, {"container": "no", "mid": "CREATE_STATION-S16481097", "type": "station", "playable": "yes", "name": "Speed Dragon by Tangerine Dream", "image_url": ""}, {"container": "no", "mid": "CREATE_STATION-S1082443", "type": "station", "playable": "yes", "name": "Tangerine by Mary Louise Knutson", "image_url": ""}, {"container": "no", "mid": "CREATE_STATION-S11825304", "type": "station", "playable": "yes", "name": "Identity Proven Matrix by Tangerine Dream", "image_url": ""}, {"container": "no", "mid": "CREATE_STATION-S24154834", "type": "station", "playable": "yes", "name": "First Rays of Light by Celestial Alignment", "image_url": ""}, {"container": "no", "mid": "CREATE_STATION-S16550589", "type": "station", "playable": "yes", "name": "Tangerine by Amane", "image_url": ""}, {"container": "no", "mid": "CREATE_STATION-S1127632", "type": "station", "playable": "yes", "name": "Metaphor Part One by Tangerine Dream", "image_url": ""}, {"container": "no", "mid": "CREATE_STATION-S18892466", "type": "station", "playable": "yes", "name": "Sequent 'C' (Remastered 2018) by Tangerine Dream", "image_url": ""}, {"container": "no", "mid": "CREATE_STATION-S30194818", "type": "station", "playable": "yes", "name": "Tangerine (Live at Whittemore Center Arena) (Durham) (NH) (02.19.96) by Dave Matthews Band", "image_url": ""}, {"container": "no", "mid": "CREATE_STATION-S15007209", "type": "station", "playable": "yes", "name": "The Sun Whose Rays (Live At Teatro La Fenice, Venice / 2006) by Keith Jarrett", "image_url": ""}, {"container": "no", "mid": "CREATE_STATION-S11970982", "type": "station", "playable": "yes", "name": "Tangerine (feat. Big Sean) by Miley Cyrus", "image_url": ""}, {"container": "no", "mid": "CREATE_STATION-S5930743", "type": "station", "playable": "yes", "name": "Morning Rays (Album Version) (feat. Richard Tee) by Tom Scott", "image_url": ""}, {"container": "no", "mid": "CREATE_STATION-S13653697", "type": "station", "playable": "yes", "name": "Cosmic Rays by Charlie Parker Quartet", "image_url": ""}, {"container": "no", "mid": "CREATE_STATION-S2082136", "type": "station", "playable": "yes", "name": "Tangerine by First Aid Kit", "image_url": ""}, {"container": "no", "mid": "CREATE_STATION-S1898039", "type": "station", "playable": "yes", "name": "Seven Rays by 2002", "image_url": ""}, {"container": "no", "mid": "CREATE_STATION-S28616469", "type": "station", "playable": "yes", "name": "Stratosfear 2019 by Tangerine Dream", "image_url": ""}, {"container": "no", "mid": "CREATE_STATION-S30144", "type": "station", "playable": "yes", "name": "Your X-Rays Have Just Come Back From The Lab And We Think We Know What Your Problem Is by Jets To Brazil", "image_url": ""}, {"container": "no", "mid": "CREATE_STATION-S40299096", "type": "station", "playable": "yes", "name": "Tangerine by Barii", "image_url": ""}, {"container": "no", "mid": "CREATE_STATION-S1127647", "type": "station", "playable": "yes", "name": "Tangines On And Running by Tangerine Dream", "image_url": ""}, {"container": "no", "mid": "CREATE_STATION-S38892893", "type": "station", "playable": "yes", "name": "Father And Son (Resurrection 2) by Tangerine Dream", "image_url": ""}, {"container": "no", "mid": "CREATE_STATION-S28616468", "type": "station", "playable": "yes", "name": "Yellowstone Park 2019 by Tangerine Dream", "image_url": ""}, {"container": "no", "mid": "CREATE_STATION-S8963484", "type": "station", "playable": "yes", "name": "X-Rays by Jackie Mason", "image_url": ""}, {"container": "no", "mid": "CREATE_STATION-S6870549", "type": "station", "playable": "yes", "name": "Italian X Rays by Steve Miller Band", "image_url": ""}, {"container": "no", "mid": "CREATE_STATION-S5811178", "type": "station", "playable": "yes", "name": "Tangerine (Remastered 2001) by Joe Pass", "image_url": ""}, {"container": "no", "mid": "CREATE_STATION-S35125374", "type": "station", "playable": "yes", "name": "Tyger 2013 by Tangerine Dream", "image_url": ""}, {"container": "no", "mid": "CREATE_STATION-S6442778", "type": "station", "playable": "yes", "name": "The First Rays of This Forever's Light by Matt Borghi", "image_url": ""}, {"container": "no", "mid": "CREATE_STATION-S13653699", "type": "station", "playable": "yes", "name": "Cosmic Rays (Alternate Take) by Charlie Parker Quartet", "image_url": ""}, {"container": "no", "mid": "CREATE_STATION-S36767691", "type": "station", "playable": "yes", "name": "Tangerine Sour by Emancipator %26 9 Theory", "image_url": ""}, {"container": "no", "mid": "CREATE_STATION-S1098124", "type": "station", "playable": "yes", "name": "Moon Rays (2007 Digital Remaster/Rudy Van Gelder Edition) by Horace Silver Quintet", "image_url": ""}, {"container": "no", "mid": "CREATE_STATION-S16550591", "type": "station", "playable": "yes", "name": "Tangerine by Amane", "image_url": ""}, {"container": "no", "mid": "CREATE_STATION-S30475082", "type": "station", "playable": "yes", "name": "Tangerine by Lou Donaldson", "image_url": ""}, {"container": "no", "mid": "CREATE_STATION-S17131911", "type": "station", "playable": "yes", "name": "Morgenstern, Pt. 4 by Schiller %26 Tangerine Dream", "image_url": ""}, {"container": "no", "mid": "CREATE_STATION-S1609452", "type": "station", "playable": "yes", "name": "Ceremony of the Seven Rays by Deborah Van Dyke", "image_url": ""}, {"container": "no", "mid": "CREATE_STATION-S9747275", "type": "station", "playable": "yes", "name": "Tangerine by Bob Brookmeyer %26 Stan Getz Quintet", "image_url": ""}, {"container": "no", "mid": "401192836", "type": "song", "artist": "ZEDD", "album": "Tangerine Rays", "album_id": "401192835", "playable": "yes", "name": "Tangerine Rays", "image_url": "http://resources.wimpmusic.com/images/f073e403/ee90/4d9e/80a4/731b57de9f55/640x640.jpg"}, {"container": "no", "mid": "383020264", "type": "song", "artist": "ZEDD", "album": "Telos", "album_id": "383020262", "playable": "yes", "name": "Tangerine Rays", "image_url": "http://resources.wimpmusic.com/images/28fe4513/7d90/4b62/b3df/2cb09ec35477/640x640.jpg"}, {"container": "no", "mid": "401192838", "type": "song", "artist": "ZEDD", "album": "Tangerine Rays", "album_id": "401192835", "playable": "yes", "name": "Tangerine Rays", "image_url": "http://resources.wimpmusic.com/images/f073e403/ee90/4d9e/80a4/731b57de9f55/640x640.jpg"}, {"container": "no", "mid": "383020288", "type": "song", "artist": "ZEDD", "album": "Telos", "album_id": "383020285", "playable": "yes", "name": "Tangerine Rays", "image_url": "http://resources.wimpmusic.com/images/36dc43cb/aa36/44cf/bb54/aca54d5991b6/640x640.jpg"}, {"container": "no", "mid": "383978029", "type": "song", "artist": "ZEDD", "album": "Telos", "album_id": "383978026", "playable": "yes", "name": "Tangerine Rays", "image_url": "http://resources.wimpmusic.com/images/fec0bead/36dd/4357/8e3d/d0af125bb913/640x640.jpg"}, {"container": "no", "mid": "401193043", "type": "song", "artist": "ZEDD", "album": "Tangerine Rays", "album_id": "401193042", "playable": "yes", "name": "Tangerine Rays", "image_url": "http://resources.wimpmusic.com/images/42831a87/1001/4335/a832/fb8bdf6781db/640x640.jpg"}, {"container": "no", "mid": "401192837", "type": "song", "artist": "ZEDD", "album": "Tangerine Rays", "album_id": "401192835", "playable": "yes", "name": "Tangerine Rays", "image_url": "http://resources.wimpmusic.com/images/f073e403/ee90/4d9e/80a4/731b57de9f55/640x640.jpg"}, {"container": "no", "mid": "401193045", "type": "song", "artist": "ZEDD", "album": "Tangerine Rays", "album_id": "401193042", "playable": "yes", "name": "Tangerine Rays", "image_url": "http://resources.wimpmusic.com/images/42831a87/1001/4335/a832/fb8bdf6781db/640x640.jpg"}, {"container": "no", "mid": "401193044", "type": "song", "artist": "ZEDD", "album": "Tangerine Rays", "album_id": "401193042", "playable": "yes", "name": "Tangerine Rays", "image_url": "http://resources.wimpmusic.com/images/42831a87/1001/4335/a832/fb8bdf6781db/640x640.jpg"}, {"container": "no", "mid": "389676834", "type": "song", "artist": "Backing Business", "album": "Pristine Karaoke, Vol. 170", "album_id": "389676760", "playable": "yes", "name": "Tangerine Rays (Karaoke Version Originally Performed by Bea Miller, Ellis %26 Zedd)", "image_url": "http://resources.wimpmusic.com/images/4efa15cf/1a95/423a/8b05/f656281da36e/640x640.jpg"}, {"container": "no", "mid": "355503127", "type": "song", "artist": "Tangerine Jazz", "album": "Jazz for Improving Concentration in Spring", "album_id": "355503098", "playable": "yes", "name": "Study Alcove Gentle Rays", "image_url": "http://resources.wimpmusic.com/images/6214ae8c/36a7/4cfd/91c6/12165df564cc/640x640.jpg"}, {"container": "no", "mid": "376883749", "type": "song", "artist": "Tangerine Jazz", "album": "サマー・ブリーズとコーヒータイム", "album_id": "376883744", "playable": "yes", "name": "Gathering Summer Rays", "image_url": "http://resources.wimpmusic.com/images/7c7f3b86/24ea/459d/8640/a139dcd28f7e/640x640.jpg"}, {"container": "no", "mid": "377809865", "type": "song", "artist": "Tangerine Jazz", "album": "Summer Bossa Time at the Beachside", "album_id": "377809861", "playable": "yes", "name": "Gathering Summer Rays", "image_url": "http://resources.wimpmusic.com/images/3bbfc24b/9939/4a2d/b538/4f034f4cf305/640x640.jpg"}, {"container": "no", "mid": "355503109", "type": "song", "artist": "Tangerine Jazz", "album": "Jazz for Improving Concentration in Spring", "album_id": "355503098", "playable": "yes", "name": "First Rays Symmetry", "image_url": "http://resources.wimpmusic.com/images/6214ae8c/36a7/4cfd/91c6/12165df564cc/640x640.jpg"}, {"container": "no", "mid": "355149731", "type": "song", "artist": "Tangerine Jazz", "album": "Instrumental BGM Matching a Relaxed New Life", "album_id": "355149701", "playable": "yes", "name": "Morning Rays and Fresh Starts", "image_url": "http://resources.wimpmusic.com/images/fbfe5e8b/b775/4d97/9053/8f0ac7daf4fd/640x640.jpg"}]} diff --git a/tests/fixtures/browse.rename_playlist.json b/tests/fixtures/browse.rename_playlist.json new file mode 100644 index 0000000..372e20a --- /dev/null +++ b/tests/fixtures/browse.rename_playlist.json @@ -0,0 +1 @@ +{"heos": {"command": "browse/rename_playlist", "result": "success", "message": "sid=1025&cid=171566&name=New Name"}} \ No newline at end of file diff --git a/tests/fixtures/browse.retrieve_metadata.json b/tests/fixtures/browse.retrieve_metadata.json new file mode 100644 index 0000000..ffb9a64 --- /dev/null +++ b/tests/fixtures/browse.retrieve_metadata.json @@ -0,0 +1,22 @@ +{ + "heos": { + "command": "browse/retrieve_metadata", + "result": "success", + "message": "sid=6&cid=123456&returned=1&count=1" + }, + "payload": [ + { + "album_id": "7890", + "images": [ + { + "image_url": "http://resources.wimpmusic.com/images/fbfe5e8b/b775/4d97/9053/8f0ac7daf4fd/640x640.jpg", + "width": 640 + }, + { + "image_url": "http://resources.wimpmusic.com/images/fbfe5e8b/b775/4d97/9053/8f0ac7daf4fd/320x320.jpg", + "width": 320 + } + ] + } + ] +} \ No newline at end of file diff --git a/tests/fixtures/browse.search.json b/tests/fixtures/browse.search.json new file mode 100644 index 0000000..e7a4cc9 --- /dev/null +++ b/tests/fixtures/browse.search.json @@ -0,0 +1,2 @@ + +{"heos": {"command": "browse/search", "result": "success", "message": "sid=10&scid=3&search=Tangerine Rays&returned=15&count=15"}, "payload": [{"container": "no", "mid": "401192836", "type": "song", "artist": "ZEDD", "album": "Tangerine Rays", "album_id": "401192835", "playable": "yes", "name": "Tangerine Rays", "image_url": "http://resources.wimpmusic.com/images/f073e403/ee90/4d9e/80a4/731b57de9f55/640x640.jpg"}, {"container": "no", "mid": "383020264", "type": "song", "artist": "ZEDD", "album": "Telos", "album_id": "383020262", "playable": "yes", "name": "Tangerine Rays", "image_url": "http://resources.wimpmusic.com/images/28fe4513/7d90/4b62/b3df/2cb09ec35477/640x640.jpg"}, {"container": "no", "mid": "401192838", "type": "song", "artist": "ZEDD", "album": "Tangerine Rays", "album_id": "401192835", "playable": "yes", "name": "Tangerine Rays", "image_url": "http://resources.wimpmusic.com/images/f073e403/ee90/4d9e/80a4/731b57de9f55/640x640.jpg"}, {"container": "no", "mid": "383020288", "type": "song", "artist": "ZEDD", "album": "Telos", "album_id": "383020285", "playable": "yes", "name": "Tangerine Rays", "image_url": "http://resources.wimpmusic.com/images/36dc43cb/aa36/44cf/bb54/aca54d5991b6/640x640.jpg"}, {"container": "no", "mid": "383978029", "type": "song", "artist": "ZEDD", "album": "Telos", "album_id": "383978026", "playable": "yes", "name": "Tangerine Rays", "image_url": "http://resources.wimpmusic.com/images/fec0bead/36dd/4357/8e3d/d0af125bb913/640x640.jpg"}, {"container": "no", "mid": "401193043", "type": "song", "artist": "ZEDD", "album": "Tangerine Rays", "album_id": "401193042", "playable": "yes", "name": "Tangerine Rays", "image_url": "http://resources.wimpmusic.com/images/42831a87/1001/4335/a832/fb8bdf6781db/640x640.jpg"}, {"container": "no", "mid": "401192837", "type": "song", "artist": "ZEDD", "album": "Tangerine Rays", "album_id": "401192835", "playable": "yes", "name": "Tangerine Rays", "image_url": "http://resources.wimpmusic.com/images/f073e403/ee90/4d9e/80a4/731b57de9f55/640x640.jpg"}, {"container": "no", "mid": "401193045", "type": "song", "artist": "ZEDD", "album": "Tangerine Rays", "album_id": "401193042", "playable": "yes", "name": "Tangerine Rays", "image_url": "http://resources.wimpmusic.com/images/42831a87/1001/4335/a832/fb8bdf6781db/640x640.jpg"}, {"container": "no", "mid": "401193044", "type": "song", "artist": "ZEDD", "album": "Tangerine Rays", "album_id": "401193042", "playable": "yes", "name": "Tangerine Rays", "image_url": "http://resources.wimpmusic.com/images/42831a87/1001/4335/a832/fb8bdf6781db/640x640.jpg"}, {"container": "no", "mid": "389676834", "type": "song", "artist": "Backing Business", "album": "Pristine Karaoke, Vol. 170", "album_id": "389676760", "playable": "yes", "name": "Tangerine Rays (Karaoke Version Originally Performed by Bea Miller, Ellis %26 Zedd)", "image_url": "http://resources.wimpmusic.com/images/4efa15cf/1a95/423a/8b05/f656281da36e/640x640.jpg"}, {"container": "no", "mid": "355503127", "type": "song", "artist": "Tangerine Jazz", "album": "Jazz for Improving Concentration in Spring", "album_id": "355503098", "playable": "yes", "name": "Study Alcove Gentle Rays", "image_url": "http://resources.wimpmusic.com/images/6214ae8c/36a7/4cfd/91c6/12165df564cc/640x640.jpg"}, {"container": "no", "mid": "376883749", "type": "song", "artist": "Tangerine Jazz", "album": "サマー・ブリーズとコーヒータイム", "album_id": "376883744", "playable": "yes", "name": "Gathering Summer Rays", "image_url": "http://resources.wimpmusic.com/images/7c7f3b86/24ea/459d/8640/a139dcd28f7e/640x640.jpg"}, {"container": "no", "mid": "377809865", "type": "song", "artist": "Tangerine Jazz", "album": "Summer Bossa Time at the Beachside", "album_id": "377809861", "playable": "yes", "name": "Gathering Summer Rays", "image_url": "http://resources.wimpmusic.com/images/3bbfc24b/9939/4a2d/b538/4f034f4cf305/640x640.jpg"}, {"container": "no", "mid": "355503109", "type": "song", "artist": "Tangerine Jazz", "album": "Jazz for Improving Concentration in Spring", "album_id": "355503098", "playable": "yes", "name": "First Rays Symmetry", "image_url": "http://resources.wimpmusic.com/images/6214ae8c/36a7/4cfd/91c6/12165df564cc/640x640.jpg"}, {"container": "no", "mid": "355149731", "type": "song", "artist": "Tangerine Jazz", "album": "Instrumental BGM Matching a Relaxed New Life", "album_id": "355149701", "playable": "yes", "name": "Morning Rays and Fresh Starts", "image_url": "http://resources.wimpmusic.com/images/fbfe5e8b/b775/4d97/9053/8f0ac7daf4fd/640x640.jpg"}]} \ No newline at end of file diff --git a/tests/fixtures/browse.set_service_option_add_favorite.json b/tests/fixtures/browse.set_service_option_add_favorite.json new file mode 100644 index 0000000..5233802 --- /dev/null +++ b/tests/fixtures/browse.set_service_option_add_favorite.json @@ -0,0 +1 @@ +{"heos": {"command": "browse/set_service_option", "result": "success", "message": "option=19&pid={player_id}"}} \ No newline at end of file diff --git a/tests/fixtures/browse.set_service_option_add_favorite_browse.json b/tests/fixtures/browse.set_service_option_add_favorite_browse.json new file mode 100644 index 0000000..40b1a63 --- /dev/null +++ b/tests/fixtures/browse.set_service_option_add_favorite_browse.json @@ -0,0 +1 @@ +{"heos": {"command": "browse/set_service_option", "result": "success", "message": "option=19&sid=1&mid=123456&name=Test Radio"}} \ No newline at end of file diff --git a/tests/fixtures/browse.set_service_option_add_playlist.json b/tests/fixtures/browse.set_service_option_add_playlist.json new file mode 100644 index 0000000..d8c125f --- /dev/null +++ b/tests/fixtures/browse.set_service_option_add_playlist.json @@ -0,0 +1 @@ +{"heos": {"command": "browse/set_service_option", "result": "success", "message": "option={option}&sid=1&cid=1234&name=Test Playlist"}} \ No newline at end of file diff --git a/tests/fixtures/browse.set_service_option_album_remove_playlist.json b/tests/fixtures/browse.set_service_option_album_remove_playlist.json new file mode 100644 index 0000000..42fad96 --- /dev/null +++ b/tests/fixtures/browse.set_service_option_album_remove_playlist.json @@ -0,0 +1 @@ +{"heos": {"command": "browse/set_service_option", "result": "success", "message": "option={option}&sid=1&cid=1234"}} \ No newline at end of file diff --git a/tests/fixtures/browse.set_service_option_new_station.json b/tests/fixtures/browse.set_service_option_new_station.json new file mode 100644 index 0000000..d805e2b --- /dev/null +++ b/tests/fixtures/browse.set_service_option_new_station.json @@ -0,0 +1 @@ +{"heos": {"command": "browse/set_service_option", "result": "success", "message": "option={option}&sid=1&scid=1234&name=Test&range=0,14"}} \ No newline at end of file diff --git a/tests/fixtures/browse.set_service_option_remove_favorite.json b/tests/fixtures/browse.set_service_option_remove_favorite.json new file mode 100644 index 0000000..dee2ffb --- /dev/null +++ b/tests/fixtures/browse.set_service_option_remove_favorite.json @@ -0,0 +1 @@ +{"heos": {"command": "browse/set_service_option", "result": "success", "message": "option=20&mid=4277097921440801039"}} \ No newline at end of file diff --git a/tests/fixtures/browse.set_service_option_thumbs_up_down.json b/tests/fixtures/browse.set_service_option_thumbs_up_down.json new file mode 100644 index 0000000..6da4917 --- /dev/null +++ b/tests/fixtures/browse.set_service_option_thumbs_up_down.json @@ -0,0 +1 @@ +{"heos": {"command": "browse/set_service_option", "result": "success", "message": "option={option}&sid=1&pid={player_id}"}} \ No newline at end of file diff --git a/tests/fixtures/browse.set_service_option_track_station.json b/tests/fixtures/browse.set_service_option_track_station.json new file mode 100644 index 0000000..5c0d79c --- /dev/null +++ b/tests/fixtures/browse.set_service_option_track_station.json @@ -0,0 +1 @@ +{"heos": {"command": "browse/set_service_option", "result": "success", "message": "option={option}&sid=1&mid=1234"}} \ No newline at end of file diff --git a/tests/fixtures/player.get_now_playing_media_changed.json b/tests/fixtures/player.get_now_playing_media_changed.json index 3a1bc24..e3493a0 100644 --- a/tests/fixtures/player.get_now_playing_media_changed.json +++ b/tests/fixtures/player.get_now_playing_media_changed.json @@ -1 +1 @@ -{"heos": {"command": "player/get_now_playing_media", "result": "success", "message": "pid=1"}, "payload": {"type": "station", "song": "I've Been Waiting (feat. Fall Out Boy)", "station": "Today's Hits Radio", "album": "I've Been Waiting (Single) (Explicit)", "artist": "Lil Peep & ILoveMakonnen", "image_url": "http://media/url", "album_id": "1", "mid": "2PxuY99Qty", "qid": 1, "sid": 1}, "options": [{"play": [{"id": 11, "name": "Thumbs Up"}, {"id": 12, "name": "Thumbs Down"}, {"id": 19, "name": "Add to HEOS Favorites"}]}]} \ No newline at end of file +{"heos": {"command": "player/get_now_playing_media", "result": "success", "message": "pid=1"}, "payload": {"type": "station", "song": "I've Been Waiting (feat. Fall Out Boy)", "station": "Today's Hits Radio", "album": "I've Been Waiting (Single) (Explicit)", "artist": "Lil Peep & ILoveMakonnen", "image_url": "http://media/url", "album_id": "1", "mid": "2PxuY99Qty", "qid": 1, "sid": 1}, "options": [{"play": [{"id": 11, "name": "Thumbs Up"}, {"id": 12, "name": "Thumbs Down"}, {"id": 20, "name": "Remove from HEOS Favorites"}]}]} \ No newline at end of file diff --git a/tests/test_heos.py b/tests/test_heos.py index 47a6441..983f70c 100644 --- a/tests/test_heos.py +++ b/tests/test_heos.py @@ -528,9 +528,10 @@ async def test_get_player_info_by_id_already_loaded_refresh(heos: Heos) -> None: ], ) async def test_get_player_info_invalid_parameters_raises( - heos: Heos, player_id: int | None, player: HeosPlayer | None, error: str + player_id: int | None, player: HeosPlayer | None, error: str ) -> None: """Test retrieving player info with invalid parameters raises.""" + heos = Heos(HeosOptions("127.0.0.1")) with pytest.raises(ValueError, match=error): await heos.get_player_info(player_id=player_id, player=player) @@ -620,6 +621,11 @@ async def test_player_now_playing_changed_event( assert now_playing.queue_id == 1 assert now_playing.source_id == 13 assert now_playing.supported_controls == const.CONTROLS_ALL + assert len(now_playing.options) == 3 + option = now_playing.options[2] + assert option.id == 19 + assert option.name == "Add to HEOS Favorites" + assert option.context == "play" # Attach dispatch handler signal = asyncio.Event() @@ -660,6 +666,11 @@ async def handler(player_id: int, event: str) -> None: assert now_playing.current_position_updated is None assert now_playing.duration is None assert now_playing.supported_controls == const.CONTROLS_FORWARD_ONLY + assert len(now_playing.options) == 3 + option = now_playing.options[2] + assert option.id == 20 + assert option.name == "Remove from HEOS Favorites" + assert option.context == "play" @calls_player_commands() @@ -1084,10 +1095,10 @@ async def test_browse_media_music_source( async def test_browse_media_music_source_unavailable_rasises( - heos: Heos, media_music_source_unavailable: MediaMusicSource, ) -> None: """Test browse with an unavailable MediaMusicSource raises.""" + heos = Heos(HeosOptions("127.0.0.1")) with pytest.raises(ValueError, match="Source is not available to browse"): await heos.browse_media(media_music_source_unavailable) @@ -1112,19 +1123,19 @@ async def test_browse_media_item(heos: Heos, media_item_album: MediaItem) -> Non async def test_browse_media_item_not_browsable_raises( - heos: Heos, media_item_song: MediaItem + media_item_song: MediaItem, ) -> None: """Test browse with an not browsable MediaItem raises.""" + heos = Heos(HeosOptions("127.0.0.1")) with pytest.raises( ValueError, match="Only media sources and containers can be browsed" ): await heos.browse_media(media_item_song) -async def test_play_media_unplayable_raises( - heos: Heos, media_item_album: MediaItem -) -> None: +async def test_play_media_unplayable_raises(media_item_album: MediaItem) -> None: """Test play media with unplayable source raises.""" + heos = Heos(HeosOptions("127.0.0.1")) media_item_album.playable = False with pytest.raises( @@ -1149,9 +1160,10 @@ async def test_play_media_song(heos: Heos, media_item_song: MediaItem) -> None: async def test_play_media_song_missing_container_raises( - heos: Heos, media_item_song: MediaItem + media_item_song: MediaItem, ) -> None: """Test play song succeeseds.""" + heos = Heos(HeosOptions("127.0.0.1")) media_item_song.container_id = None with pytest.raises( @@ -1189,9 +1201,10 @@ async def test_play_media_station(heos: Heos, media_item_station: MediaItem) -> async def test_play_media_station_missing_media_id_raises( - heos: Heos, media_item_station: MediaItem + media_item_station: MediaItem, ) -> None: """Test play song succeeseds.""" + heos = Heos(HeosOptions("127.0.0.1")) media_item_station.media_id = None with pytest.raises( @@ -1414,9 +1427,10 @@ async def test_get_group_info_by_id_already_loaded_refresh(heos: Heos) -> None: ], ) async def test_get_group_info_invalid_parameters_raises( - heos: Heos, group_id: int | None, group: HeosGroup | None, error: str + group_id: int | None, group: HeosGroup | None, error: str ) -> None: """Test retrieving group info with invalid parameters raises.""" + heos = Heos(HeosOptions("127.0.0.1")) with pytest.raises(ValueError, match=error): await heos.get_group_info(group_id=group_id, group=group) diff --git a/tests/test_heos_browse.py b/tests/test_heos_browse.py new file mode 100644 index 0000000..032dcba --- /dev/null +++ b/tests/test_heos_browse.py @@ -0,0 +1,722 @@ +"""Tests for the browse mixin of the Heos module.""" + +from typing import Any + +import pytest + +from pyheos import const +from pyheos.heos import Heos, HeosOptions +from pyheos.media import MediaMusicSource +from tests import calls_command, value +from tests.common import MediaMusicSources + + +@calls_command("browse.get_source_info", {const.ATTR_SOURCE_ID: 123456}) +async def test_get_music_source_by_id(heos: Heos) -> None: + """Test retrieving music source by id.""" + source = await heos.get_music_source_info(123456) + assert source.source_id == 1 + assert source.name == "Pandora" + assert ( + source.image_url + == "https://production.ws.skyegloup.com:443/media/images/service/logos/pandora.png" + ) + assert source.type == const.MediaType.MUSIC_SERVICE + assert source.available + assert source.service_username == "email@email.com" + + +@calls_command("browse.get_music_sources") +async def test_get_music_source_info_by_id_already_loaded(heos: Heos) -> None: + """Test retrieving music source info by id for already loaded does not update.""" + sources = await heos.get_music_sources() + original_source = sources[const.MUSIC_SOURCE_FAVORITES] + retrived_source = await heos.get_music_source_info(original_source.source_id) + assert original_source == retrived_source + + +@calls_command( + "browse.get_source_info", + {const.ATTR_SOURCE_ID: MediaMusicSources.FAVORITES.source_id}, +) +async def test_get_music_source_info_by_id_already_loaded_refresh( + heos: Heos, media_music_source: MediaMusicSource +) -> None: + """Test retrieving player info by player id for already loaded player updates.""" + heos.music_sources[media_music_source.source_id] = media_music_source + media_music_source.available = False + retrived_source = await heos.get_music_source_info( + media_music_source.source_id, refresh=True + ) + assert media_music_source == retrived_source + assert media_music_source.available + + +@pytest.mark.parametrize( + ("source_id", "music_source", "error"), + [ + (None, None, "Either source_id or music_source must be provided"), + ( + 1, + object(), + "Only one of source_id or music_source should be provided", + ), + ], +) +async def test_get_music_source_info_invalid_parameters_raises( + source_id: int | None, music_source: MediaMusicSource | None, error: str +) -> None: + """Test retrieving player info with invalid parameters raises.""" + heos = Heos(HeosOptions("127.0.0.1")) + with pytest.raises(ValueError, match=error): + await heos.get_music_source_info(source_id=source_id, music_source=music_source) + + +@calls_command( + "browse.get_search_criteria", {const.ATTR_SOURCE_ID: const.MUSIC_SOURCE_TIDAL} +) +async def test_get_search_criteria(heos: Heos) -> None: + """Test retrieving search criteria.""" + criteria = await heos.get_search_criteria(const.MUSIC_SOURCE_TIDAL) + assert len(criteria) == 4 + item = criteria[2] + assert item.name == "Track" + assert item.criteria_id == 3 + assert item.wildcard is False + assert item.container_id == "SEARCHED_TRACKS-" + assert item.playable is True + + +@calls_command( + "browse.search", + { + const.ATTR_SOURCE_ID: const.MUSIC_SOURCE_TIDAL, + const.ATTR_SEARCH_CRITERIA_ID: 3, + const.ATTR_SEARCH: "Tangerine Rays", + }, +) +async def test_search(heos: Heos) -> None: + """Test the search method.""" + + result = await heos.search(const.MUSIC_SOURCE_TIDAL, "Tangerine Rays", 3) + + assert result.source_id == const.MUSIC_SOURCE_TIDAL + assert result.criteria_id == 3 + assert result.search == "Tangerine Rays" + assert result.returned == 15 + assert result.count == 15 + assert len(result.items) == 15 + + +@pytest.mark.parametrize( + ("search", "error"), + [ + ("", "'search' parameter must not be empty"), + ("x" * 129, "'search' parameter must be less than or equal to 128 characters"), + ], +) +async def test_search_invalid_raises(heos: Heos, search: str, error: str) -> None: + """Test the search method with an invalid search raises.""" + + with pytest.raises( + ValueError, + match=error, + ): + await heos.search(const.MUSIC_SOURCE_TIDAL, search, 3) + + +@calls_command( + "browse.search", + { + const.ATTR_SOURCE_ID: const.MUSIC_SOURCE_TIDAL, + const.ATTR_SEARCH_CRITERIA_ID: 3, + const.ATTR_SEARCH: "Tangerine Rays", + const.ATTR_RANGE: "0,14", + }, +) +async def test_search_with_range(heos: Heos) -> None: + """Test the search method.""" + + result = await heos.search( + const.MUSIC_SOURCE_TIDAL, "Tangerine Rays", 3, range_start=0, range_end=14 + ) + + assert result.source_id == const.MUSIC_SOURCE_TIDAL + assert result.criteria_id == 3 + assert result.search == "Tangerine Rays" + assert result.returned == 15 + assert result.count == 15 + assert len(result.items) == 15 + + +@calls_command( + "browse.rename_playlist", + { + const.ATTR_SOURCE_ID: const.MUSIC_SOURCE_PLAYLISTS, + const.ATTR_CONTAINER_ID: 171566, + const.ATTR_NAME: "New Name", + }, +) +async def test_rename_playlist(heos: Heos) -> None: + """Test renaming a playlist.""" + await heos.rename_playlist(const.MUSIC_SOURCE_PLAYLISTS, "171566", "New Name") + + +@pytest.mark.parametrize( + ("name", "error"), + [ + ("", "'new_name' parameter must not be empty"), + ( + "x" * 129, + "'new_name' parameter must be less than or equal to 128 characters", + ), + ], +) +async def test_rename_playlist_invalid_name_raises( + heos: Heos, name: str, error: str +) -> None: + """Test renaming a playlist.""" + with pytest.raises( + ValueError, + match=error, + ): + await heos.rename_playlist(const.MUSIC_SOURCE_PLAYLISTS, "171566", name) + + +@calls_command( + "browse.delete_playlist", + { + const.ATTR_SOURCE_ID: const.MUSIC_SOURCE_PLAYLISTS, + const.ATTR_CONTAINER_ID: 171566, + }, +) +async def test_delete_playlist(heos: Heos) -> None: + """Test deleting a playlist.""" + await heos.delete_playlist(const.MUSIC_SOURCE_PLAYLISTS, "171566") + + +@calls_command( + "browse.retrieve_metadata", + { + const.ATTR_SOURCE_ID: const.MUSIC_SOURCE_NAPSTER, + const.ATTR_CONTAINER_ID: 123456, + }, +) +async def test_retrieve_metadata(heos: Heos) -> None: + """Test deleting a playlist.""" + result = await heos.retrieve_metadata(const.MUSIC_SOURCE_NAPSTER, "123456") + assert result.source_id == const.MUSIC_SOURCE_NAPSTER + assert result.container_id == "123456" + assert result.returned == 1 + assert result.count == 1 + assert len(result.metadata) == 1 + metadata = result.metadata[0] + assert metadata.album_id == "7890" + assert len(metadata.images) == 2 + image = metadata.images[0] + assert ( + image.image_url + == "http://resources.wimpmusic.com/images/fbfe5e8b/b775/4d97/9053/8f0ac7daf4fd/640x640.jpg" + ) + assert image.width == 640 + + +@calls_command( + "browse.set_service_option_add_favorite", + { + const.ATTR_OPTION_ID: const.SERVICE_OPTION_ADD_TO_FAVORITES, + const.ATTR_PLAYER_ID: 1, + }, +) +async def test_set_service_option_add_favorite_play(heos: Heos) -> None: + """Test setting a service option for adding to favorites.""" + await heos.set_service_option(const.SERVICE_OPTION_ADD_TO_FAVORITES, player_id=1) + + +@calls_command( + "browse.set_service_option_add_favorite_browse", + { + const.ATTR_OPTION_ID: const.SERVICE_OPTION_ADD_TO_FAVORITES, + const.ATTR_SOURCE_ID: const.MUSIC_SOURCE_PANDORA, + const.ATTR_MEDIA_ID: 123456, + const.ATTR_NAME: "Test Radio", + }, +) +async def test_set_service_option_add_favorite_browse(heos: Heos) -> None: + """Test setting a service option for adding to favorites.""" + await heos.set_service_option( + const.SERVICE_OPTION_ADD_TO_FAVORITES, + source_id=const.MUSIC_SOURCE_PANDORA, + media_id="123456", + name="Test Radio", + ) + + +@calls_command( + "browse.set_service_option_remove_favorite", + { + const.ATTR_OPTION_ID: const.SERVICE_OPTION_REMOVE_FROM_FAVORITES, + const.ATTR_MEDIA_ID: 4277097921440801039, + }, +) +async def test_set_service_option_remove_favorite(heos: Heos) -> None: + """Test setting a service option for adding to favorites.""" + await heos.set_service_option( + const.SERVICE_OPTION_REMOVE_FROM_FAVORITES, media_id="4277097921440801039" + ) + + +@pytest.mark.parametrize( + "option", [const.SERVICE_OPTION_THUMBS_UP, const.SERVICE_OPTION_THUMBS_DOWN] +) +@calls_command( + "browse.set_service_option_thumbs_up_down", + { + const.ATTR_OPTION_ID: value(arg_name="option"), + const.ATTR_SOURCE_ID: const.MUSIC_SOURCE_PANDORA, + const.ATTR_PLAYER_ID: 1, + }, +) +async def test_set_service_option_thumbs_up_down(heos: Heos, option: int) -> None: + """Test setting thumbs up/down.""" + await heos.set_service_option( + option, + source_id=const.MUSIC_SOURCE_PANDORA, + player_id=1, + ) + + +@pytest.mark.parametrize( + "option", + [ + const.SERVICE_OPTION_ADD_TRACK_TO_LIBRARY, + const.SERVICE_OPTION_ADD_STATION_TO_LIBRARY, + const.SERVICE_OPTION_REMOVE_TRACK_FROM_LIBRARY, + const.SERVICE_OPTION_REMOVE_STATION_FROM_LIBRARY, + ], +) +@calls_command( + "browse.set_service_option_track_station", + { + const.ATTR_OPTION_ID: value(arg_name="option"), + const.ATTR_SOURCE_ID: const.MUSIC_SOURCE_PANDORA, + const.ATTR_MEDIA_ID: 1234, + }, +) +async def test_set_service_option_track_station(heos: Heos, option: int) -> None: + """Test setting track and station options.""" + await heos.set_service_option( + option, + source_id=const.MUSIC_SOURCE_PANDORA, + media_id="1234", + ) + + +@pytest.mark.parametrize( + "option", + [ + const.SERVICE_OPTION_ADD_ALBUM_TO_LIBRARY, + const.SERVICE_OPTION_REMOVE_ALBUM_FROM_LIBRARY, + const.SERVICE_OPTION_REMOVE_PLAYLIST_FROM_LIBRARY, + ], +) +@calls_command( + "browse.set_service_option_album_remove_playlist", + { + const.ATTR_OPTION_ID: value(arg_name="option"), + const.ATTR_SOURCE_ID: const.MUSIC_SOURCE_PANDORA, + const.ATTR_CONTAINER_ID: 1234, + }, +) +async def test_set_service_option_album_remove_playlist( + heos: Heos, option: int +) -> None: + """Test setting albumn options and remove playlist options.""" + await heos.set_service_option( + option, + source_id=const.MUSIC_SOURCE_PANDORA, + container_id="1234", + ) + + +@calls_command( + "browse.set_service_option_add_playlist", + { + const.ATTR_OPTION_ID: const.SERVICE_OPTION_ADD_PLAYLIST_TO_LIBRARY, + const.ATTR_SOURCE_ID: const.MUSIC_SOURCE_PANDORA, + const.ATTR_CONTAINER_ID: 1234, + const.ATTR_NAME: "Test Playlist", + }, +) +async def test_set_service_option_add_playlist(heos: Heos) -> None: + """Test setting albumn options and remove playlist options.""" + await heos.set_service_option( + const.SERVICE_OPTION_ADD_PLAYLIST_TO_LIBRARY, + source_id=const.MUSIC_SOURCE_PANDORA, + container_id="1234", + name="Test Playlist", + ) + + +@calls_command( + "browse.set_service_option_new_station", + { + const.ATTR_OPTION_ID: const.SERVICE_OPTION_CREATE_NEW_STATION_BY_SEARCH_CRITERIA, + const.ATTR_SOURCE_ID: const.MUSIC_SOURCE_PANDORA, + const.ATTR_SEARCH_CRITERIA_ID: 1234, + const.ATTR_NAME: "Test", + const.ATTR_RANGE: "0,14", + }, +) +async def test_set_service_option_new_station(heos: Heos) -> None: + """Test setting creating a new station option.""" + await heos.set_service_option( + const.SERVICE_OPTION_CREATE_NEW_STATION_BY_SEARCH_CRITERIA, + source_id=const.MUSIC_SOURCE_PANDORA, + criteria_id=1234, + name="Test", + range_start=0, + range_end=14, + ) + + +@pytest.mark.parametrize( + ("kwargs", "error"), + [ + ( + {"option_id": 200}, + "Unknown option_id", + ), + # SERVICE_OPTION_ADD_PLAYLIST_TO_LIBRARY + ( + {"option_id": const.SERVICE_OPTION_ADD_PLAYLIST_TO_LIBRARY}, + "source_id, container_id, and name parameters are required", + ), + ( + { + "option_id": const.SERVICE_OPTION_ADD_PLAYLIST_TO_LIBRARY, + "source_id": 1234, + }, + "source_id, container_id, and name parameters are required", + ), + ( + { + "option_id": const.SERVICE_OPTION_ADD_PLAYLIST_TO_LIBRARY, + "container_id": 1234, + }, + "source_id, container_id, and name parameters are required", + ), + ( + { + "option_id": const.SERVICE_OPTION_ADD_PLAYLIST_TO_LIBRARY, + "name": 1234, + }, + "source_id, container_id, and name parameters are required", + ), + ( + { + "option_id": const.SERVICE_OPTION_ADD_PLAYLIST_TO_LIBRARY, + "source_id": 1234, + "name": 1234, + "media_id": 1234, + "container_id": 1234, + }, + "parameters are not allowed", + ), + # SERVICE_OPTION_CREATE_NEW_STATION_BY_SEARCH_CRITERIA + ( + {"option_id": const.SERVICE_OPTION_CREATE_NEW_STATION_BY_SEARCH_CRITERIA}, + "source_id, name, and criteria_id parameters are required", + ), + ( + { + "option_id": const.SERVICE_OPTION_CREATE_NEW_STATION_BY_SEARCH_CRITERIA, + "source_id": 1234, + }, + "source_id, name, and criteria_id parameters are required", + ), + ( + { + "option_id": const.SERVICE_OPTION_CREATE_NEW_STATION_BY_SEARCH_CRITERIA, + "name": 1234, + }, + "source_id, name, and criteria_id parameters are required", + ), + ( + { + "option_id": const.SERVICE_OPTION_CREATE_NEW_STATION_BY_SEARCH_CRITERIA, + "criteria_id": 1234, + }, + "source_id, name, and criteria_id parameters are required", + ), + ( + { + "option_id": const.SERVICE_OPTION_CREATE_NEW_STATION_BY_SEARCH_CRITERIA, + "criteria_id": 1234, + "name": 1234, + }, + "source_id, name, and criteria_id parameters are required", + ), + ( + { + "option_id": const.SERVICE_OPTION_CREATE_NEW_STATION_BY_SEARCH_CRITERIA, + "criteria_id": 1234, + "source_id": 1234, + }, + "source_id, name, and criteria_id parameters are required", + ), + ( + { + "option_id": const.SERVICE_OPTION_CREATE_NEW_STATION_BY_SEARCH_CRITERIA, + "name": 1234, + "source_id": 1234, + }, + "source_id, name, and criteria_id parameters are required", + ), + ( + { + "option_id": const.SERVICE_OPTION_CREATE_NEW_STATION_BY_SEARCH_CRITERIA, + "criteria_id": 1234, + "name": 1234, + "source_id": 1234, + "player_id": 1234, + }, + "parameters are not allowed", + ), + # SERVICE_OPTION_REMOVE_FROM_FAVORITES + ( + {"option_id": const.SERVICE_OPTION_REMOVE_FROM_FAVORITES}, + "media_id parameter is required", + ), + ( + { + "option_id": const.SERVICE_OPTION_REMOVE_FROM_FAVORITES, + "media_id": 1234, + "container_id": 1234, + }, + "parameters are not allowed", + ), + ], +) +async def test_set_sevice_option_invalid_raises( + kwargs: dict[str, Any], error: str +) -> None: + """Test calling with invalid combinations of parameters raises.""" + heos = Heos(HeosOptions("127.0.0.1")) + + with pytest.raises(ValueError, match=error): + await heos.set_service_option(**kwargs) + + +@pytest.mark.parametrize( + "option", + [ + const.SERVICE_OPTION_ADD_TRACK_TO_LIBRARY, + const.SERVICE_OPTION_ADD_STATION_TO_LIBRARY, + const.SERVICE_OPTION_REMOVE_TRACK_FROM_LIBRARY, + const.SERVICE_OPTION_REMOVE_STATION_FROM_LIBRARY, + ], +) +@pytest.mark.parametrize( + ("kwargs", "error"), + [ + ( + {}, + "source_id and media_id parameters are required", + ), + ( + {"media_id": 1234}, + "source_id and media_id parameters are required", + ), + ( + {"source_id": 1234}, + "source_id and media_id parameters are required", + ), + ( + {"source_id": 1234, "media_id": 1234, "container_id": 1234}, + "parameters are not allowed for service option_id", + ), + ], +) +async def test_set_sevice_option_invalid_track_station_raises( + option: int, kwargs: dict[str, Any], error: str +) -> None: + """Test calling with invalid combinations of parameters raises.""" + heos = Heos(HeosOptions("127.0.0.1")) + with pytest.raises(ValueError, match=error): + await heos.set_service_option(option_id=option, **kwargs) + + +@pytest.mark.parametrize( + "option", + [ + const.SERVICE_OPTION_ADD_ALBUM_TO_LIBRARY, + const.SERVICE_OPTION_REMOVE_ALBUM_FROM_LIBRARY, + const.SERVICE_OPTION_REMOVE_PLAYLIST_FROM_LIBRARY, + ], +) +@pytest.mark.parametrize( + ("kwargs", "error"), + [ + ( + {}, + "source_id and container_id parameters are required", + ), + ( + {"source_id": 1234}, + "source_id and container_id parameters are required", + ), + ( + {"container_id": 1234}, + "source_id and container_id parameters are required", + ), + ( + {"source_id": 1234, "media_id": 1234, "container_id": 1234}, + "parameters are not allowed for service option_id", + ), + ], +) +async def test_set_sevice_option_invalid_album_remove_playlist_raises( + option: int, kwargs: dict[str, Any], error: str +) -> None: + """Test calling with invalid combinations of parameters raises.""" + heos = Heos(HeosOptions("127.0.0.1")) + with pytest.raises(ValueError, match=error): + await heos.set_service_option(option_id=option, **kwargs) + + +@pytest.mark.parametrize( + "option", + [ + const.SERVICE_OPTION_THUMBS_UP, + const.SERVICE_OPTION_THUMBS_DOWN, + ], +) +@pytest.mark.parametrize( + ("kwargs", "error"), + [ + ( + {}, + "source_id and player_id parameters are required", + ), + ( + {"source_id": 1234}, + "source_id and player_id parameters are required", + ), + ( + {"player_id": 1234}, + "source_id and player_id parameters are required", + ), + ( + {"source_id": 1234, "player_id": 1234, "container_id": 1234}, + "parameters are not allowed for service option_id", + ), + ], +) +async def test_set_sevice_option_invalid_thumbs_up_down_raises( + option: int, kwargs: dict[str, Any], error: str +) -> None: + """Test calling with invalid combinations of parameters raises.""" + heos = Heos(HeosOptions("127.0.0.1")) + with pytest.raises(ValueError, match=error): + await heos.set_service_option(option_id=option, **kwargs) + + +@pytest.mark.parametrize( + ("kwargs", "error"), + [ + ( + {}, + "Either parameters player_id OR source_id, media_id, and name are required", + ), + ( + {"source_id": 1234}, + "Either parameters player_id OR source_id, media_id, and name are required", + ), + ( + {"media_id": 1234}, + "Either parameters player_id OR source_id, media_id, and name are required", + ), + ( + {"name": 1234}, + "Either parameters player_id OR source_id, media_id, and name are required", + ), + ( + {"source_id": 1234, "media_id": 1234}, + "Either parameters player_id OR source_id, media_id, and name are required", + ), + ( + {"source_id": 1234, "name": 1234}, + "Either parameters player_id OR source_id, media_id, and name are required", + ), + ( + {"media_id": 1234, "name": 1234}, + "Either parameters player_id OR source_id, media_id, and name are required", + ), + ( + {"player_id": 1234, "media_id": 1234}, + "source_id, media_id, and name parameters are not allowed", + ), + ( + {"player_id": 1234, "source_id": 1234}, + "source_id, media_id, and name parameters are not allowed", + ), + ( + {"player_id": 1234, "name": 1234}, + "source_id, media_id, and name parameters are not allowed", + ), + ( + {"source_id": 1234, "media_id": 1234, "name": 1234, "container_id": 1234}, + "parameters are not allowed for service option_id", + ), + ( + {"player_id": 1234, "container_id": 1234}, + "parameters are not allowed for service option_id", + ), + ], +) +async def test_set_sevice_option_invalid_add_favorite_raises( + kwargs: dict[str, Any], error: str +) -> None: + """Test calling with invalid combinations of parameters raises.""" + heos = Heos(HeosOptions("127.0.0.1")) + with pytest.raises(ValueError, match=error): + await heos.set_service_option( + option_id=const.SERVICE_OPTION_ADD_TO_FAVORITES, **kwargs + ) + + +@calls_command( + "browse.multi_search", + { + const.ATTR_SEARCH: "Tangerine Rays", + const.ATTR_SOURCE_ID: "1,4,8,13,10", + const.ATTR_SEARCH_CRITERIA_ID: "0,1,2,3", + }, +) +async def test_multi_search(heos: Heos) -> None: + """Test the multi-search command.""" + result = await heos.multi_search( + "Tangerine Rays", + [1, 4, 8, 13, 10], + [0, 1, 2, 3], + ) + + assert result.search == "Tangerine Rays" + assert result.source_ids == [1, 4, 8, 13, 10] + assert result.criteria_ids == [0, 1, 2, 3] + assert result.returned == 74 + assert result.count == 74 + assert len(result.items) == 74 + assert len(result.statistics) == 4 + assert len(result.errors) == 2 + + +async def test_multi_search_invalid_search_rasis() -> None: + """Test the multi-search command.""" + heos = Heos(HeosOptions("127.0.0.1")) + with pytest.raises( + ValueError, + match="'search' parameter must be less than or equal to 128 characters", + ): + await heos.multi_search("x" * 129) diff --git a/tests/test_media.py b/tests/test_media.py index 7c75875..9077fb3 100644 --- a/tests/test_media.py +++ b/tests/test_media.py @@ -10,7 +10,7 @@ from pyheos.media import BrowseResult, MediaItem, MediaMusicSource from pyheos.message import HeosMessage from tests import calls_command -from tests.common import MediaItems +from tests.common import MediaItems, MediaMusicSources async def test_media_music_source_from_data() -> None: @@ -50,6 +50,13 @@ async def test_media_music_source_browse( assert result.returned == 3 assert result.source_id == const.MUSIC_SOURCE_FAVORITES + + assert len(result.options) == 1 + option = result.options[0] + assert option.context == "browse" + assert option.name == "Remove from HEOS Favorites" + assert option.id == 20 + # further testing of the result is done in test_browse_result_from_data @@ -72,7 +79,7 @@ async def test_browse_result_from_data() -> None: ], ) - result = BrowseResult.from_data(message, heos) + result = BrowseResult._from_message(message, heos) assert result.returned == 1 assert result.count == 1 @@ -205,6 +212,24 @@ async def test_media_item_browse(media_item_device: MediaItem) -> None: assert len(result.items) == 8 +@calls_command( + "browse.get_source_info", + {const.ATTR_SOURCE_ID: MediaMusicSources.FAVORITES.source_id}, +) +async def test_refresh(media_music_source: MediaMusicSource) -> None: + """Test refresh updates the data.""" + await media_music_source.refresh() + assert media_music_source.source_id == 1 + assert media_music_source.name == "Pandora" + assert ( + media_music_source.image_url + == "https://production.ws.skyegloup.com:443/media/images/service/logos/pandora.png" + ) + assert media_music_source.type == const.MediaType.MUSIC_SERVICE + assert media_music_source.available + assert media_music_source.service_username == "email@email.com" + + @calls_command( "browse.add_to_queue_track", { diff --git a/tests/test_player.py b/tests/test_player.py index 992633f..b278567 100644 --- a/tests/test_player.py +++ b/tests/test_player.py @@ -23,7 +23,7 @@ def test_from_data() -> None: const.ATTR_LINE_OUT: 1, const.ATTR_SERIAL: "1234567890", } - player = HeosPlayer.from_data(data, None) + player = HeosPlayer._from_data(data, None) assert player.name == "Back Patio" assert player.player_id == 1 @@ -440,6 +440,7 @@ async def test_now_playing_media_unavailable(player: HeosPlayer) -> None: assert player.now_playing_media.image_url is None assert player.now_playing_media.album_id is None assert player.now_playing_media.media_id is None + assert player.now_playing_media.options == [] @calls_commands(