From 32e6e18894cd44096291e13295310033632bf85b Mon Sep 17 00:00:00 2001 From: peteS-UK Date: Mon, 4 Nov 2024 21:34:31 +0000 Subject: [PATCH] initial --- .../components/squeezebox/icons.json | 6 + .../components/squeezebox/media_player.py | 196 +++++++++++++++++- .../components/squeezebox/services.yaml | 79 +++++++ .../components/squeezebox/strings.json | 84 +++++++- .../squeezebox/test_media_player.py | 158 +++++++++++++- 5 files changed, 509 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/squeezebox/icons.json b/homeassistant/components/squeezebox/icons.json index 29911ddad77..c0ca40efd93 100644 --- a/homeassistant/components/squeezebox/icons.json +++ b/homeassistant/components/squeezebox/icons.json @@ -27,6 +27,12 @@ }, "call_query": { "service": "mdi:database" + }, + "search": { + "service": "mdi:folder-search-outline" + }, + "play": { + "service": "mdi:folder-play-outline" } } } diff --git a/homeassistant/components/squeezebox/media_player.py b/homeassistant/components/squeezebox/media_player.py index 48015f86ba0..bbf87121b16 100644 --- a/homeassistant/components/squeezebox/media_player.py +++ b/homeassistant/components/squeezebox/media_player.py @@ -27,7 +27,13 @@ from homeassistant.components.media_player import ( ) from homeassistant.config_entries import SOURCE_INTEGRATION_DISCOVERY from homeassistant.const import ATTR_COMMAND, CONF_HOST, CONF_PORT, Platform -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import ( + HomeAssistant, + ServiceCall, + ServiceResponse, + SupportsResponse, + callback, +) from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import ( config_validation as cv, @@ -73,6 +79,8 @@ if TYPE_CHECKING: SERVICE_CALL_METHOD = "call_method" SERVICE_CALL_QUERY = "call_query" +SERVICE_SEARCH = "search" +SERVICE_PLAY = "play" ATTR_QUERY_RESULT = "query_result" @@ -80,7 +88,13 @@ _LOGGER = logging.getLogger(__name__) ATTR_PARAMETERS = "parameters" +ATTR_RETURN_ITEMS = "return_items" +ATTR_SEARCH_STRING = "search_string" +ATTR_PLAYLIST_ACTION = "playlist_action" +ATTR_SEARCH_TYPE = "search_type" ATTR_OTHER_PLAYER = "other_player" +ATTR_TAGS = "tags" + ATTR_TO_PROPERTY = [ ATTR_QUERY_RESULT, @@ -134,6 +148,27 @@ async def async_setup_entry( ) # Register entity services + + async def async_call_query_helper( + entity: SqueezeBoxMediaPlayerEntity, serviceCall: ServiceCall + ) -> ServiceResponse | None: + return await entity.async_call_query( + serviceCall.data[ATTR_COMMAND], + serviceCall.data.get(ATTR_PARAMETERS, []), + serviceCall.return_response, + ) + + async def async_search_service_helper( + entity: SqueezeBoxMediaPlayerEntity, serviceCall: ServiceCall + ) -> ServiceResponse | None: + return await entity.async_search_service( + serviceCall.data[ATTR_COMMAND], + serviceCall.data[ATTR_RETURN_ITEMS], + serviceCall.data.get(ATTR_SEARCH_STRING), + serviceCall.data.get(ATTR_TAGS), + serviceCall.return_response, + ) + platform = entity_platform.async_get_current_platform() platform.async_register_entity_service( SERVICE_CALL_METHOD, @@ -150,12 +185,35 @@ async def async_setup_entry( { vol.Required(ATTR_COMMAND): cv.string, vol.Optional(ATTR_PARAMETERS): vol.All( - cv.ensure_list, vol.Length(min=1), [cv.string] + cv.ensure_list, + vol.Length(min=1), + [cv.string], ), }, - "async_call_query", + async_call_query_helper, + supports_response=SupportsResponse.OPTIONAL, + ) + platform.async_register_entity_service( + SERVICE_SEARCH, + { + vol.Required(ATTR_COMMAND): cv.string, + vol.Required(ATTR_RETURN_ITEMS): int, + vol.Optional(ATTR_SEARCH_STRING): cv.string, + vol.Optional(ATTR_TAGS): cv.string, + }, + async_search_service_helper, + supports_response=SupportsResponse.OPTIONAL, + ) + platform.async_register_entity_service( + SERVICE_PLAY, + { + vol.Required(ATTR_COMMAND): cv.string, + vol.Required(ATTR_SEARCH_TYPE): cv.string, + vol.Required(ATTR_SEARCH_STRING): cv.string, + vol.Required(ATTR_PLAYLIST_ACTION): cv.string, + }, + "async_play_service", ) - # Start server discovery task if not already running entry.async_on_unload(async_at_start(hass, start_server_discovery)) @@ -594,8 +652,11 @@ class SqueezeBoxMediaPlayerEntity( await self._player.async_query(*all_params) async def async_call_query( - self, command: str, parameters: list[str] | None = None - ) -> None: + self, + command: str, + parameters: list[str] | None = None, + response: bool | None = False, + ) -> ServiceResponse | None: """Call Squeezebox JSON/RPC method where we care about the result. Additional parameters are added to the command to form the list of @@ -604,8 +665,127 @@ class SqueezeBoxMediaPlayerEntity( all_params = [command] if parameters: all_params.extend(parameters) - self._query_result = await self._player.async_query(*all_params) - _LOGGER.debug("call_query got result %s", self._query_result) + query_result = await self._player.async_query(*all_params) + _LOGGER.debug("call_query got result %s", query_result) + if response: + return query_result + self._query_result = query_result + self.async_write_ha_state() + return None + + async def async_search_service( + self, + command: str, + return_items: int, + search_string: str | None = None, + tags: str | None = None, + response: bool | None = None, + ) -> None: + """Call Squeezebox JSON/RPC method to search media library.""" + match command: + case "albums": + _param = [ + "0", + str(return_items), + ("tags:" + tags) if tags else "tags:laay", + "search:" + search_string if search_string is not None else "", + ] + case "favorites": + _param = [ + "items", + "0", + str(return_items), + "search:" + search_string if search_string is not None else "", + ] + case "artists": + _param = [ + "0", + str(return_items), + ("tags:" + tags) if tags else "", + "search:" + search_string if search_string is not None else "", + ] + case "genres": + _param = [ + "0", + str(return_items), + ("tags:" + tags) if tags else "", + "search:" + search_string if search_string is not None else "", + ] + case "tracks": + _param = [ + "0", + str(return_items), + ("tags:" + tags) if tags else "tags:aglQrTy", + "search:" + search_string if search_string is not None else "", + ] + case "playlists": + _param = [ + "0", + str(return_items), + ("tags:" + tags) if tags else "", + "search:" + search_string if search_string is not None else "", + ] + case "players": + _param = ["0", str(return_items)] + case _: + _LOGGER.debug("Invalid Search Service Command") + return None + + return await self.async_call_query(command, _param, response) + + async def async_play_service( + self, command: str, search_type: str, search_string: str, playlist_action: str + ) -> None: + """Call Squeezebox JSON/RPC method to search media library.""" + if search_type == "text": + match command: + case "favorite": + _param = [ + "favorites", + "items", + "0", + "1", + "search:" + search_string if search_string is not None else "", + ] + result_loop = "loop_loop" + _type = "Favorites" + case _: + _param = [ + command + "s", + "items0", + "1", + "search:" + search_string if search_string is not None else "", + ] + result_loop = command + "s_loop" + _type = command + + query_result = await self._player.async_query(*_param) + + if query_result["count"] == 0: + raise ServiceValidationError("Search returned zero results") + + if query_result["count"] > 1: + raise ServiceValidationError( + f"Search returned {query_result['count']} results. Each search must return only one result" + ) + + _id = str(query_result[result_loop][0]["id"]) + + else: + _id = search_string + + if command == "favorite": + _type = "Favorites" + else: + _type = command + + match playlist_action: + case "add": + await self.async_play_media(_type, _id, enqueue=MediaPlayerEnqueue.ADD) + case "next": + await self.async_play_media(_type, _id, enqueue=MediaPlayerEnqueue.NEXT) + case _: + await self.async_play_media(_type, _id) self.async_write_ha_state() async def async_join_players(self, group_members: list[str]) -> None: diff --git a/homeassistant/components/squeezebox/services.yaml b/homeassistant/components/squeezebox/services.yaml index 07885ae5dd6..45307d619bb 100644 --- a/homeassistant/components/squeezebox/services.yaml +++ b/homeassistant/components/squeezebox/services.yaml @@ -30,3 +30,82 @@ call_query: advanced: true selector: object: +search: + target: + entity: + integration: squeezebox + domain: media_player + fields: + command: + required: true + selector: + select: + translation_key: "search_command_selector" + multiple: false + options: + - albums + - artists + - tracks + - playlists + - genres + - favorites + - players + return_items: + required: true + selector: + number: + min: 1 + max: 10000 + mode: box + search_string: + required: false + example: Revolver + selector: + text: + tags: + required: false + selector: + text: +play: + target: + entity: + integration: squeezebox + domain: media_player + fields: + command: + required: true + selector: + select: + translation_key: "play_command_selector" + multiple: false + options: + - album + - artist + - track + - playlist + - genre + - favorite + search_type: + required: true + selector: + select: + translation_key: "search_type_selector" + multiple: false + options: + - text + - item + search_string: + required: true + example: Revolver + selector: + text: + playlist_action: + required: true + selector: + select: + translation_key: "playlist_type_selector" + multiple: false + options: + - play + - add + - next diff --git a/homeassistant/components/squeezebox/strings.json b/homeassistant/components/squeezebox/strings.json index ed569989b56..0b14bfbfb49 100644 --- a/homeassistant/components/squeezebox/strings.json +++ b/homeassistant/components/squeezebox/strings.json @@ -49,7 +49,7 @@ }, "call_query": { "name": "Call query", - "description": "Calls a custom Squeezebox JSONRPC API. Result will be stored in 'query_result' attribute of the Squeezebox entity.", + "description": "Calls a custom Squeezebox JSONRPC API. Result will be stored in 'query_result' attribute of the Squeezebox entity if no result variable is given.", "fields": { "command": { "name": "Command", @@ -60,6 +60,50 @@ "description": "[%key:component::squeezebox::services::call_method::fields::parameters::description%]" } } + }, + "search": { + "name": "Search music library & players", + "description": "Search for items in the music library & players.", + "fields": { + "command": { + "name": "Items", + "description": "The items to search for on LMS." + }, + "return_items": { + "name": "Number of Items", + "description": "The number of items to return." + }, + "search_string": { + "name": "Search String", + "description": "Limit the search to items matching the search string." + }, + "tags": { + "name": "Tags", + "description": "Override default tags for search" + } + } + }, + "play": { + "name": "Play music", + "description": "Play an item in the music library", + "fields": { + "command": { + "name": "Items", + "description": "The items to play on LMS." + }, + "search_type": { + "name": "Search Type", + "description": "Full Text search for a string, or directly provide an item_id" + }, + "search_string": { + "name": "Search String", + "description": "Search for items matching the search string or item_id." + }, + "playlist_action": { + "name": "Playlist Action", + "description": "How should the item be added to the playlist" + } + } } }, "entity": { @@ -118,5 +162,41 @@ } } } + }, + "selector": { + "search_command_selector": { + "options": { + "albums": "Albums", + "artists": "Artists", + "tracks": "Tracks", + "playlists": "Playlists", + "genres": "Genres", + "favorites": "Favorites", + "players": "Players" + } + }, + "play_command_selector": { + "options": { + "album": "Album", + "artist": "Artist", + "track": "Track", + "playlist": "Playlist", + "genre": "Genre", + "favorite": "Favorite" + } + }, + "search_type_selector": { + "options": { + "text": "Full Text", + "item": "Item ID" + } + }, + "playlist_type_selector": { + "options": { + "play": "Replace the current playlist and play the item", + "add": "Add the item to the end of the current playlist", + "next": "Play the item next, after the current item on the playlist" + } + } } -} +} \ No newline at end of file diff --git a/tests/components/squeezebox/test_media_player.py b/tests/components/squeezebox/test_media_player.py index f3292f1b469..070dde49fc8 100644 --- a/tests/components/squeezebox/test_media_player.py +++ b/tests/components/squeezebox/test_media_player.py @@ -42,8 +42,11 @@ from homeassistant.components.squeezebox.const import ( ) from homeassistant.components.squeezebox.media_player import ( ATTR_PARAMETERS, + ATTR_RETURN_ITEMS, + ATTR_SEARCH_STRING, SERVICE_CALL_METHOD, SERVICE_CALL_QUERY, + SERVICE_SEARCH, ) from homeassistant.const import ( ATTR_COMMAND, @@ -109,9 +112,12 @@ async def test_squeezebox_player_rediscovery( # Make the player appear unavailable configured_player.connected = False - freezer.tick(timedelta(seconds=PLAYER_UPDATE_INTERVAL)) - async_fire_time_changed(hass) - await hass.async_block_till_done() + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "media_player.test_player"}, + blocking=True, + ) assert hass.states.get("media_player.test_player").state == STATE_UNAVAILABLE # Make the player available again @@ -120,7 +126,7 @@ async def test_squeezebox_player_rediscovery( async_fire_time_changed(hass) await hass.async_block_till_done() - freezer.tick(timedelta(seconds=PLAYER_UPDATE_INTERVAL)) + freezer.tick(timedelta(seconds=SENSOR_UPDATE_INTERVAL)) async_fire_time_changed(hass) await hass.async_block_till_done() assert hass.states.get("media_player.test_player").state == MediaPlayerState.IDLE @@ -776,6 +782,150 @@ async def test_squeezebox_call_method( ) +async def test_squeezebox_search_albums( + hass: HomeAssistant, configured_player: MagicMock +) -> None: + """Test query service call.""" + await hass.services.async_call( + DOMAIN, + SERVICE_SEARCH, + { + ATTR_ENTITY_ID: "media_player.test_player", + ATTR_COMMAND: "albums", + ATTR_RETURN_ITEMS: 1, + ATTR_SEARCH_STRING: "searchstring", + }, + blocking=True, + ) + configured_player.async_query.assert_called_once_with( + "albums", "0", "1", "tags:laay", "search:searchstring" + ) + + +async def test_squeezebox_search_favorites( + hass: HomeAssistant, configured_player: MagicMock +) -> None: + """Test query service call.""" + + await hass.services.async_call( + DOMAIN, + SERVICE_SEARCH, + { + ATTR_ENTITY_ID: "media_player.test_player", + ATTR_COMMAND: "favorites", + ATTR_RETURN_ITEMS: 1, + ATTR_SEARCH_STRING: "searchstring", + }, + blocking=True, + ) + configured_player.async_query.assert_called_once_with( + "favorites", "items", "0", "1", "search:searchstring" + ) + + +async def test_squeezebox_search_artists( + hass: HomeAssistant, configured_player: MagicMock +) -> None: + """Test query service call.""" + + await hass.services.async_call( + DOMAIN, + SERVICE_SEARCH, + { + ATTR_ENTITY_ID: "media_player.test_player", + ATTR_COMMAND: "artists", + ATTR_RETURN_ITEMS: 1, + ATTR_SEARCH_STRING: "searchstring", + }, + blocking=True, + ) + configured_player.async_query.assert_called_once_with( + "artists", "0", "1", "search:searchstring" + ) + + +async def test_squeezebox_search_genres( + hass: HomeAssistant, configured_player: MagicMock +) -> None: + """Test query service call.""" + + await hass.services.async_call( + DOMAIN, + SERVICE_SEARCH, + { + ATTR_ENTITY_ID: "media_player.test_player", + ATTR_COMMAND: "genres", + ATTR_RETURN_ITEMS: 1, + ATTR_SEARCH_STRING: "searchstring", + }, + blocking=True, + ) + configured_player.async_query.assert_called_once_with( + "genres", "0", "1", "search:searchstring" + ) + + +async def test_squeezebox_search_tracks( + hass: HomeAssistant, configured_player: MagicMock +) -> None: + """Test query service call.""" + + await hass.services.async_call( + DOMAIN, + SERVICE_SEARCH, + { + ATTR_ENTITY_ID: "media_player.test_player", + ATTR_COMMAND: "tracks", + ATTR_RETURN_ITEMS: 1, + ATTR_SEARCH_STRING: "searchstring", + }, + blocking=True, + ) + configured_player.async_query.assert_called_once_with( + "tracks", "0", "1", "tags:aglQrTy", "search:searchstring" + ) + + +async def test_squeezebox_search_playlists( + hass: HomeAssistant, configured_player: MagicMock +) -> None: + """Test query service call.""" + + await hass.services.async_call( + DOMAIN, + SERVICE_SEARCH, + { + ATTR_ENTITY_ID: "media_player.test_player", + ATTR_COMMAND: "playlists", + ATTR_RETURN_ITEMS: 1, + ATTR_SEARCH_STRING: "searchstring", + }, + blocking=True, + ) + configured_player.async_query.assert_called_once_with( + "playlists", "0", "1", "search:searchstring" + ) + + +async def test_squeezebox_search_players( + hass: HomeAssistant, configured_player: MagicMock +) -> None: + """Test query service call.""" + + await hass.services.async_call( + DOMAIN, + SERVICE_SEARCH, + { + ATTR_ENTITY_ID: "media_player.test_player", + ATTR_COMMAND: "players", + ATTR_RETURN_ITEMS: 1, + ATTR_SEARCH_STRING: "searchstring", + }, + blocking=True, + ) + configured_player.async_query.assert_called_once_with("players", "0", "1") + + async def test_squeezebox_invalid_state( hass: HomeAssistant, configured_player: MagicMock, freezer: FrozenDateTimeFactory ) -> None: