From c7745e0d0290c09d9e1e1cdd81d0e223caa7c85b Mon Sep 17 00:00:00 2001 From: peteS-UK <64092177+peteS-UK@users.noreply.github.com> Date: Mon, 26 May 2025 14:01:17 +0100 Subject: [PATCH] Add support for SEARCH_MEDIA feature (#143261) * initial * initial * add tests * Update for list return * translate exception * tests for errors * review tweaks * test fix * force content_type to lowercase * Allow media_content_type = None * new test --- .../components/squeezebox/browse_media.py | 19 ++- .../components/squeezebox/media_player.py | 71 ++++++++++ .../components/squeezebox/strings.json | 3 + tests/components/squeezebox/conftest.py | 21 ++- .../snapshots/test_media_player.ambr | 4 +- .../squeezebox/test_media_browser.py | 124 ++++++++++++++++++ 6 files changed, 237 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/squeezebox/browse_media.py b/homeassistant/components/squeezebox/browse_media.py index 6e1ec8b37c4..03df289a2fd 100644 --- a/homeassistant/components/squeezebox/browse_media.py +++ b/homeassistant/components/squeezebox/browse_media.py @@ -50,21 +50,33 @@ MEDIA_TYPE_TO_SQUEEZEBOX: dict[str | MediaType, str] = { MediaType.GENRE: "genre", MediaType.APPS: "apps", "radios": "radios", + "favorite": "favorite", } SQUEEZEBOX_ID_BY_TYPE: dict[str | MediaType, str] = { MediaType.ALBUM: "album_id", + "albums": "album_id", MediaType.ARTIST: "artist_id", + "artists": "artist_id", MediaType.TRACK: "track_id", + "tracks": "track_id", MediaType.PLAYLIST: "playlist_id", + "playlists": "playlist_id", MediaType.GENRE: "genre_id", + "genres": "genre_id", + "favorite": "item_id", "favorites": "item_id", MediaType.APPS: "item_id", + "app": "item_id", + "radios": "item_id", + "radio": "item_id", } CONTENT_TYPE_MEDIA_CLASS: dict[str | MediaType, dict[str, MediaClass | str]] = { "favorites": {"item": MediaClass.DIRECTORY, "children": MediaClass.TRACK}, + "favorite": {"item": "favorite", "children": ""}, "radios": {"item": MediaClass.DIRECTORY, "children": MediaClass.APP}, + "radio": {"item": MediaClass.DIRECTORY, "children": MediaClass.APP}, "artists": {"item": MediaClass.DIRECTORY, "children": MediaClass.ARTIST}, "albums": {"item": MediaClass.DIRECTORY, "children": MediaClass.ALBUM}, "tracks": {"item": MediaClass.DIRECTORY, "children": MediaClass.TRACK}, @@ -100,6 +112,7 @@ CONTENT_TYPE_TO_CHILD_TYPE: dict[ "album artists": MediaType.ARTIST, MediaType.APPS: MediaType.APP, MediaType.APP: MediaType.TRACK, + "favorite": None, } @@ -191,7 +204,7 @@ def _build_response_favorites(item: dict[str, Any]) -> BrowseMedia: return BrowseMedia( media_content_id=item["id"], title=item["title"], - media_content_type="favorites", + media_content_type="favorite", media_class=CONTENT_TYPE_MEDIA_CLASS[MediaType.TRACK]["item"], can_expand=bool(item.get("hasitems")), can_play=bool(item["isaudio"] and item.get("url")), @@ -236,6 +249,7 @@ async def build_item_response( search_id = payload["search_id"] search_type = payload["search_type"] + search_query = payload.get("search_query") assert ( search_type is not None ) # async_browse_media will not call this function if search_type is None @@ -252,6 +266,7 @@ async def build_item_response( browse_data.media_type_to_squeezebox[search_type], limit=browse_limit, browse_id=browse_id, + search_query=search_query, ) if result is not None and result.get("items"): @@ -261,7 +276,7 @@ async def build_item_response( for item in result["items"]: # Force the item id to a string in case it's numeric from some lms item["id"] = str(item.get("id", "")) - if search_type == "favorites": + if search_type in ["favorites", "favorite"]: child_media = _build_response_favorites(item) elif search_type in ["apps", "radios"]: diff --git a/homeassistant/components/squeezebox/media_player.py b/homeassistant/components/squeezebox/media_player.py index 873bedd13fb..1e803c0e1ef 100644 --- a/homeassistant/components/squeezebox/media_player.py +++ b/homeassistant/components/squeezebox/media_player.py @@ -23,6 +23,8 @@ from homeassistant.components.media_player import ( MediaPlayerState, MediaType, RepeatMode, + SearchMedia, + SearchMediaQuery, async_process_play_media_url, ) from homeassistant.config_entries import SOURCE_INTEGRATION_DISCOVERY @@ -204,6 +206,7 @@ class SqueezeBoxMediaPlayerEntity(SqueezeboxEntity, MediaPlayerEntity): | MediaPlayerEntityFeature.GROUPING | MediaPlayerEntityFeature.MEDIA_ENQUEUE | MediaPlayerEntityFeature.MEDIA_ANNOUNCE + | MediaPlayerEntityFeature.SEARCH_MEDIA ) _attr_has_entity_name = True _attr_name = None @@ -545,6 +548,74 @@ class SqueezeBoxMediaPlayerEntity(SqueezeboxEntity, MediaPlayerEntity): await self._player.async_index(index) await self.coordinator.async_refresh() + async def async_search_media( + self, + query: SearchMediaQuery, + ) -> SearchMedia: + """Search the media player.""" + + _valid_type_list = [ + key + for key in self._browse_data.content_type_media_class + if key not in ["apps", "app", "radios", "radio"] + ] + + _media_content_type_list = ( + query.media_content_type.lower().replace(", ", ",").split(",") + if query.media_content_type + else ["albums", "tracks", "artists", "genres"] + ) + + if query.media_content_type and set(_media_content_type_list).difference( + _valid_type_list + ): + _LOGGER.debug("Invalid Media Content Type: %s", query.media_content_type) + + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_search_media_content_type", + translation_placeholders={ + "media_content_type": ", ".join(_valid_type_list) + }, + ) + + search_response_list: list[BrowseMedia] = [] + + for _content_type in _media_content_type_list: + payload = { + "search_type": _content_type, + "search_id": query.media_content_id, + "search_query": query.search_query, + } + + try: + search_response_list.append( + await build_item_response( + self, + self._player, + payload, + self.browse_limit, + self._browse_data, + ) + ) + except BrowseError: + _LOGGER.debug("Search Failure: Payload %s", payload) + + result: list[BrowseMedia] = [] + + for search_response in search_response_list: + # Apply the media_filter_classes to the result if specified + if query.media_filter_classes and search_response.children: + search_response.children = [ + child + for child in search_response.children + if child.media_content_type in query.media_filter_classes + ] + if search_response.children: + result.extend(list(search_response.children)) + + return SearchMedia(result=result) + async def async_set_repeat(self, repeat: RepeatMode) -> None: """Set the repeat mode.""" if repeat == RepeatMode.ALL: diff --git a/homeassistant/components/squeezebox/strings.json b/homeassistant/components/squeezebox/strings.json index b004234c327..a8c0b4bb0ae 100644 --- a/homeassistant/components/squeezebox/strings.json +++ b/homeassistant/components/squeezebox/strings.json @@ -196,6 +196,9 @@ }, "update_restart_failed": { "message": "Error trying to update LMS Plugins: Restart failed." + }, + "invalid_search_media_content_type": { + "message": "If specified, Media content type must be one of {media_content_type}" } } } diff --git a/tests/components/squeezebox/conftest.py b/tests/components/squeezebox/conftest.py index fb2428ba758..2cbc1305bcb 100644 --- a/tests/components/squeezebox/conftest.py +++ b/tests/components/squeezebox/conftest.py @@ -131,11 +131,15 @@ async def mock_async_play_announcement(media_id: str) -> bool: async def mock_async_browse( - media_type: MediaType, limit: int, browse_id: tuple | None = None + media_type: MediaType, + limit: int, + browse_id: tuple | None = None, + search_query: str | None = None, ) -> dict | None: """Mock the async_browse method of pysqueezebox.Player.""" child_types = { "favorites": "favorites", + "favorite": "favorite", "new music": "album", "album artists": "artists", "albums": "album", @@ -224,6 +228,21 @@ async def mock_async_browse( "items": fake_items, } return None + + if search_query: + if search_query not in [x["title"] for x in fake_items]: + return None + + for item in fake_items: + if ( + item["title"] == search_query + and item["item_type"] == child_types[media_type] + ): + return { + "title": media_type, + "items": [item], + } + if ( media_type in MEDIA_TYPE_TO_SQUEEZEBOX.values() or media_type == "app-fakecommand" diff --git a/tests/components/squeezebox/snapshots/test_media_player.ambr b/tests/components/squeezebox/snapshots/test_media_player.ambr index 7540a448882..5e2e59f447e 100644 --- a/tests/components/squeezebox/snapshots/test_media_player.ambr +++ b/tests/components/squeezebox/snapshots/test_media_player.ambr @@ -65,7 +65,7 @@ 'original_name': None, 'platform': 'squeezebox', 'previous_unique_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': None, 'unique_id': 'aa:bb:cc:dd:ee:ff', 'unit_of_measurement': None, @@ -84,7 +84,7 @@ }), 'repeat': , 'shuffle': False, - 'supported_features': , + 'supported_features': , 'volume_level': 0.01, }), 'context': , diff --git a/tests/components/squeezebox/test_media_browser.py b/tests/components/squeezebox/test_media_browser.py index f1ba187a699..093e4f186d4 100644 --- a/tests/components/squeezebox/test_media_browser.py +++ b/tests/components/squeezebox/test_media_browser.py @@ -10,6 +10,7 @@ from homeassistant.components.media_player import ( DOMAIN as MEDIA_PLAYER_DOMAIN, SERVICE_PLAY_MEDIA, BrowseError, + MediaClass, MediaType, ) from homeassistant.components.squeezebox.browse_media import ( @@ -170,6 +171,129 @@ async def test_async_browse_media_for_apps( assert "Fake Invalid Item 1" not in search +@pytest.mark.parametrize( + ("category", "media_filter_classes"), + [ + ("favorites", None), + ("artists", None), + ("albums", None), + ("playlists", None), + ("genres", None), + ("new music", None), + ("album artists", None), + ("albums", [MediaClass.ALBUM]), + ], +) +async def test_async_search_media( + hass: HomeAssistant, + config_entry: MockConfigEntry, + hass_ws_client: WebSocketGenerator, + category: str, + media_filter_classes: list[MediaClass] | None, +) -> None: + """Test each category with subitems.""" + with patch( + "homeassistant.components.squeezebox.browse_media.is_internal_request", + return_value=False, + ): + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "media_player/search_media", + "entity_id": "media_player.test_player", + "media_content_id": "", + "media_content_type": category, + "search_query": "Fake Item 1", + "media_filter_classes": media_filter_classes, + } + ) + response = await client.receive_json() + assert response["success"] + category_level = response["result"]["result"] + assert category_level[0]["title"] == "Fake Item 1" + + +async def test_async_search_media_invalid_filter( + hass: HomeAssistant, + config_entry: MockConfigEntry, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test search_media action with invalid media_filter_class.""" + with patch( + "homeassistant.components.squeezebox.browse_media.is_internal_request", + return_value=False, + ): + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "media_player/search_media", + "entity_id": "media_player.test_player", + "media_content_id": "", + "media_content_type": "albums", + "search_query": "Fake Item 1", + "media_filter_classes": "movie", + } + ) + response = await client.receive_json() + assert response["success"] + assert len(response["result"]["result"]) == 0 + + +async def test_async_search_media_invalid_type( + hass: HomeAssistant, + config_entry: MockConfigEntry, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test search_media action with invalid media_content_type.""" + with patch( + "homeassistant.components.squeezebox.browse_media.is_internal_request", + return_value=False, + ): + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "media_player/search_media", + "entity_id": "media_player.test_player", + "media_content_id": "", + "media_content_type": "Fake Type", + "search_query": "Fake Item 1", + }, + ) + response = await client.receive_json() + assert not response["success"] + err_message = "If specified, Media content type must be one of" + assert err_message in response["error"]["message"] + + +async def test_async_search_media_not_found( + hass: HomeAssistant, + config_entry: MockConfigEntry, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test trying to play an item that doesn't exist.""" + with patch( + "homeassistant.components.squeezebox.browse_media.is_internal_request", + return_value=False, + ): + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "media_player/search_media", + "entity_id": "media_player.test_player", + "media_content_id": "", + "media_content_type": "", + "search_query": "Unknown Item", + }, + ) + response = await client.receive_json() + + assert len(response["result"]["result"]) == 0 + + async def test_generate_playlist_for_app( hass: HomeAssistant, hass_ws_client: WebSocketGenerator,