diff --git a/homeassistant/components/squeezebox/browse_media.py b/homeassistant/components/squeezebox/browse_media.py index c1a24d775651..b42a176f2280 100644 --- a/homeassistant/components/squeezebox/browse_media.py +++ b/homeassistant/components/squeezebox/browse_media.py @@ -46,6 +46,7 @@ MEDIA_TYPE_TO_SQUEEZEBOX: dict[str | MediaType, str] = { "genres": "genres", "new music": "new music", "album artists": "album artists", + "artist tracks": "titles", MediaType.ALBUM: "album", MediaType.ARTIST: "artist", MediaType.TRACK: "title", @@ -61,6 +62,7 @@ SQUEEZEBOX_ID_BY_TYPE: dict[str | MediaType, str] = { "albums": "album_id", MediaType.ARTIST: "artist_id", "artists": "artist_id", + "artist tracks": "artist_id", MediaType.TRACK: "track_id", "tracks": "track_id", MediaType.PLAYLIST: "playlist_id", @@ -87,6 +89,7 @@ CONTENT_TYPE_MEDIA_CLASS: dict[str | MediaType, dict[str, MediaClass | str]] = { "genres": {"item": MediaClass.DIRECTORY, "children": MediaClass.GENRE}, "new music": {"item": MediaClass.DIRECTORY, "children": MediaClass.ALBUM}, "album artists": {"item": MediaClass.DIRECTORY, "children": MediaClass.ARTIST}, + "artist tracks": {"item": MediaClass.DIRECTORY, "children": MediaClass.TRACK}, MediaType.ALBUM: {"item": MediaClass.ALBUM, "children": MediaClass.TRACK}, MediaType.ARTIST: {"item": MediaClass.ARTIST, "children": MediaClass.ALBUM}, MediaType.TRACK: {"item": MediaClass.TRACK, "children": ""}, @@ -101,7 +104,7 @@ CONTENT_TYPE_TO_CHILD_TYPE: dict[ str | MediaType | None, ] = { MediaType.ALBUM: MediaType.TRACK, - MediaType.PLAYLIST: MediaType.PLAYLIST, + MediaType.PLAYLIST: MediaType.TRACK, MediaType.ARTIST: MediaType.ALBUM, MediaType.GENRE: MediaType.ARTIST, "artists": MediaType.ARTIST, @@ -113,12 +116,20 @@ CONTENT_TYPE_TO_CHILD_TYPE: dict[ "radios": MediaClass.APP, "new music": MediaType.ALBUM, "album artists": MediaType.ARTIST, + "artist tracks": MediaType.TRACK, MediaType.APPS: MediaType.APP, MediaType.APP: MediaType.TRACK, "favorite": None, "track": MediaType.TRACK, } +# LMS ignores the search query when it lists the tracks of a playlist +SEARCHABLE_TYPES: set[str | MediaType] = { + MediaType.ALBUM, + MediaType.ARTIST, + MediaType.GENRE, +} + @dataclass class BrowseData: @@ -358,6 +369,7 @@ async def build_item_response( media_class=CONTENT_TYPE_MEDIA_CLASS[item_type]["item"], can_expand=bool(CONTENT_TYPE_MEDIA_CLASS[item_type]["children"]), can_play=True, + can_search=item_type in SEARCHABLE_TYPES, ) assert child_media.media_class is not None @@ -397,6 +409,7 @@ async def build_item_response( can_play=any(child.can_play for child in children), children=children, can_expand=True, + can_search=search_type in SEARCHABLE_TYPES, ) diff --git a/homeassistant/components/squeezebox/media_player.py b/homeassistant/components/squeezebox/media_player.py index ffa12268d54a..0e7250d4678b 100644 --- a/homeassistant/components/squeezebox/media_player.py +++ b/homeassistant/components/squeezebox/media_player.py @@ -665,7 +665,7 @@ class SqueezeBoxMediaPlayerEntity(SqueezeboxEntity, MediaPlayerEntity): _valid_type_list = [ key for key in self._browse_data.content_type_media_class - if key not in ["apps", "app", "radios", "radio"] + if key not in ["apps", "app", "radios", "radio", "artist tracks"] ] _media_content_type_list = ( @@ -687,6 +687,10 @@ class SqueezeBoxMediaPlayerEntity(SqueezeboxEntity, MediaPlayerEntity): }, ) + if query.media_content_id and MediaType.ARTIST in _media_content_type_list: + # LMS matches the albums of an artist by album title only + _media_content_type_list.append("artist tracks") + search_response_list: list[BrowseMedia] = [] for _content_type in _media_content_type_list: diff --git a/tests/components/squeezebox/test_media_browser.py b/tests/components/squeezebox/test_media_browser.py index ee0cfaf50157..cdca9c77594c 100644 --- a/tests/components/squeezebox/test_media_browser.py +++ b/tests/components/squeezebox/test_media_browser.py @@ -1,6 +1,7 @@ """Test the media browser interface.""" -from unittest.mock import MagicMock, patch +from typing import Any +from unittest.mock import ANY, MagicMock, call, patch import pytest @@ -70,17 +71,17 @@ async def test_async_browse_media_root( @pytest.mark.parametrize( - ("category", "child_count"), + ("category", "child_count", "can_search"), [ - ("favorites", 4), - ("artists", 4), - ("albums", 4), - ("playlists", 4), - ("genres", 4), - ("new music", 4), - ("album artists", 4), - ("apps", 3), - ("radios", 3), + ("favorites", 4, False), + ("artists", 4, True), + ("albums", 4, True), + ("playlists", 4, False), + ("genres", 4, True), + ("new music", 4, True), + ("album artists", 4, True), + ("apps", 3, False), + ("radios", 3, False), ], ) async def test_async_browse_media_with_subitems( @@ -89,6 +90,7 @@ async def test_async_browse_media_with_subitems( hass_ws_client: WebSocketGenerator, category: str, child_count: int, + can_search: bool, ) -> None: """Test each category with subitems.""" with patch( @@ -110,6 +112,7 @@ async def test_async_browse_media_with_subitems( category_level = response["result"] assert category_level["title"] == MEDIA_TYPE_TO_SQUEEZEBOX[category] assert category_level["children"][0]["title"] == "Fake Item 1" + assert category_level["children"][0]["can_search"] is can_search assert len(category_level["children"]) == child_count # Look up a subitem @@ -128,6 +131,49 @@ async def test_async_browse_media_with_subitems( assert response["success"] search = response["result"] assert search["title"] == "Fake Item 1" + assert search["can_search"] is can_search + + +async def test_async_browse_playlist( + hass: HomeAssistant, + lms: MagicMock, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test the children of a playlist are tracks that can be played.""" + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "media_player/browse_media", + "entity_id": "media_player.test_player", + "media_content_id": FAKE_VALID_ITEM_ID, + "media_content_type": MediaType.PLAYLIST, + } + ) + response = await client.receive_json() + assert response["success"] + child = response["result"]["children"][0] + assert child["media_class"] == MediaClass.TRACK + assert child["media_content_type"] == MediaType.TRACK + assert child["media_content_id"] == FAKE_VALID_ITEM_ID + assert not child["can_expand"] + assert child["can_play"] + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: "media_player.test_player", + ATTR_MEDIA_CONTENT_TYPE: child["media_content_type"], + ATTR_MEDIA_CONTENT_ID: child["media_content_id"], + }, + blocking=True, + ) + player = (await lms.async_get_players())[0] + player.async_browse.assert_called_with( + "titles", limit=ANY, browse_id=("track_id", FAKE_VALID_ITEM_ID) + ) + player.async_load_playlist.assert_called_once() async def test_async_browse_media_for_apps( @@ -214,6 +260,60 @@ async def test_async_search_media( assert category_level[0]["title"] == "Fake Item 1" +async def test_async_search_media_in_artist( + hass: HomeAssistant, + lms: MagicMock, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test a search inside an artist returns matching albums and tracks.""" + + async def mock_browse( + category: str, + limit: int, + browse_id: tuple[str, str] | None = None, + search_query: str | None = None, + ) -> dict[str, Any]: + items = { + "artist": [{"id": "1", "title": "Love Album"}], + "titles": [{"id": "2", "title": "Love Song"}], + } + return {"title": category, "items": items[category]} + + player = (await lms.async_get_players())[0] + player.async_browse.side_effect = mock_browse + + 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": "42", + "media_content_type": MediaType.ARTIST, + "search_query": "love", + } + ) + response = await client.receive_json() + assert response["success"] + assert [ + ( + item["title"], + item["media_class"], + item["media_content_type"], + item["media_content_id"], + item["can_search"], + ) + for item in response["result"]["result"] + ] == [ + ("Love Album", MediaClass.ALBUM, MediaType.ALBUM, "1", True), + ("Love Song", MediaClass.TRACK, MediaType.TRACK, "2", False), + ] + assert player.async_browse.call_args_list == [ + call("artist", limit=ANY, browse_id=("artist_id", "42"), search_query="love"), + call("titles", limit=ANY, browse_id=("artist_id", "42"), search_query="love"), + ] + + async def test_async_search_media_invalid_filter( hass: HomeAssistant, config_entry: MockConfigEntry, @@ -241,10 +341,18 @@ async def test_async_search_media_invalid_filter( assert len(response["result"]["result"]) == 0 +@pytest.mark.parametrize( + "media_content_type", + [ + pytest.param("Fake Type", id="unknown"), + pytest.param("artist tracks", id="internal"), + ], +) async def test_async_search_media_invalid_type( hass: HomeAssistant, config_entry: MockConfigEntry, hass_ws_client: WebSocketGenerator, + media_content_type: str, ) -> None: """Test search_media action with invalid media_content_type.""" with patch( @@ -258,7 +366,7 @@ async def test_async_search_media_invalid_type( "type": "media_player/search_media", "entity_id": "media_player.test_player", "media_content_id": "", - "media_content_type": "Fake Type", + "media_content_type": media_content_type, "search_query": "Fake Item 1", }, ) @@ -266,6 +374,7 @@ async def test_async_search_media_invalid_type( assert not response["success"] err_message = "If specified, Media content type must be one of" assert err_message in response["error"]["message"] + assert "artist tracks" not in response["error"]["message"] async def test_async_search_media_not_found(