From dfd241dd1a3431900e4381b0c0a3db4e57f59456 Mon Sep 17 00:00:00 2001 From: Pete Sage <76050312+PeteRager@users.noreply.github.com> Date: Mon, 18 May 2026 08:39:12 -0400 Subject: [PATCH] Add search to Sonos (#170891) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../components/sonos/media_browser.py | 46 ++++++++ .../components/sonos/media_player.py | 15 +++ homeassistant/components/sonos/strings.json | 3 + tests/components/sonos/conftest.py | 13 ++- .../sonos/snapshots/test_media_browser.ambr | 34 ++++++ .../sonos/snapshots/test_media_player.ambr | 4 +- tests/components/sonos/test_media_browser.py | 108 ++++++++++++++---- 7 files changed, 195 insertions(+), 28 deletions(-) diff --git a/homeassistant/components/sonos/media_browser.py b/homeassistant/components/sonos/media_browser.py index c81c54af790..20e5a51b6ac 100644 --- a/homeassistant/components/sonos/media_browser.py +++ b/homeassistant/components/sonos/media_browser.py @@ -17,8 +17,11 @@ from homeassistant.components.media_player import ( BrowseMedia, MediaClass, MediaType, + SearchMedia, + SearchMediaQuery, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers.network import is_internal_request from .const import ( @@ -209,6 +212,49 @@ async def async_browse_media( return response +async def async_search_media( + hass: HomeAssistant, + media: SonosMedia, + get_browse_image_url: GetBrowseImageUrlType, + query: SearchMediaQuery, +) -> SearchMedia: + """Search media.""" + media_content_type = query.media_content_type or MediaType.TRACK + search_type = MEDIA_TYPES_TO_SONOS.get(media_content_type) + if search_type is None: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_media_content_type", + translation_placeholders={ + "media_content_type": media_content_type, + }, + ) + items = await hass.async_add_executor_job( + partial( + media.library.get_music_library_information, + search_type, + search_term=query.search_query, + full_album_art_uri=True, + complete_result=True, + ) + ) + result = [] + for item in items: + with suppress(UnknownMediaType): + result.append( + item_payload( + item, + get_thumbnail_url=partial( + get_thumbnail_url_full, + media, + is_internal_request(hass), + get_browse_image_url, + ), + ) + ) + return SearchMedia(result=result) + + def build_item_response( media_library: MusicLibrary, payload: dict[str, str], get_thumbnail_url=None ) -> BrowseMedia | None: diff --git a/homeassistant/components/sonos/media_player.py b/homeassistant/components/sonos/media_player.py index b49b70cc204..8e4bdebfb76 100644 --- a/homeassistant/components/sonos/media_player.py +++ b/homeassistant/components/sonos/media_player.py @@ -35,6 +35,8 @@ from homeassistant.components.media_player import ( MediaPlayerState, MediaType, RepeatMode, + SearchMedia, + SearchMediaQuery, async_process_play_media_url, ) from homeassistant.components.plex import PLEX_URI_SCHEME @@ -124,6 +126,7 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): | MediaPlayerEntityFeature.PLAY_MEDIA | MediaPlayerEntityFeature.PREVIOUS_TRACK | MediaPlayerEntityFeature.REPEAT_SET + | MediaPlayerEntityFeature.SEARCH_MEDIA | MediaPlayerEntityFeature.SEEK | MediaPlayerEntityFeature.SELECT_SOURCE | MediaPlayerEntityFeature.SHUFFLE_SET @@ -806,6 +809,18 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): media_content_type, ) + async def async_search_media( + self, + query: SearchMediaQuery, + ) -> SearchMedia: + """Search the music library for media matching the query.""" + return await media_browser.async_search_media( + self.hass, + self.media, + self.get_browse_image_url, + query, + ) + async def async_join_players(self, group_members: list[str]) -> None: """Join `group_members` as a player group with the current player.""" speakers = [] diff --git a/homeassistant/components/sonos/strings.json b/homeassistant/components/sonos/strings.json index 386dcfb452f..f2e01da70fa 100644 --- a/homeassistant/components/sonos/strings.json +++ b/homeassistant/components/sonos/strings.json @@ -124,6 +124,9 @@ "invalid_media": { "message": "Could not find media in library: {media_id}" }, + "invalid_media_content_type": { + "message": "Media content type {media_content_type} is not supported" + }, "invalid_sonos_playlist": { "message": "Could not find Sonos playlist: {name}" }, diff --git a/tests/components/sonos/conftest.py b/tests/components/sonos/conftest.py index 45b882dde73..df4a23dd27e 100644 --- a/tests/components/sonos/conftest.py +++ b/tests/components/sonos/conftest.py @@ -499,6 +499,10 @@ class MockMusicServiceItem: self.parent_id = parent_id self.album_art_uri: None | str = album_art_uri + def get_uri(self) -> str: + """Return URI.""" + return self.item_id.replace("S://", "x-file-cifs://") + def list_from_json_fixture(file_name: str) -> list[MockMusicServiceItem]: """Create a list of music service items from a json fixture file.""" @@ -636,7 +640,10 @@ def mock_browse_by_idstring( def mock_get_music_library_information( - search_type: str, search_term: str | None = None, full_album_art_uri: bool = True + search_type: str, + search_term: str | None = None, + full_album_art_uri: bool = True, + complete_result: bool = False, ) -> list[MockMusicServiceItem]: """Mock the call to get music library information.""" if search_type == "albums" and search_term == "Abbey Road": @@ -670,7 +677,9 @@ def music_library_fixture( music_library = MagicMock() music_library.get_sonos_favorites.return_value = sonos_favorites music_library.browse_by_idstring = Mock(side_effect=mock_browse_by_idstring) - music_library.get_music_library_information = mock_get_music_library_information + music_library.get_music_library_information = Mock( + side_effect=mock_get_music_library_information + ) music_library.browse = Mock(return_value=music_library_browse_categories) music_library.build_album_art_full_uri = Mock( return_value="build_album_art_full_uri.jpg" diff --git a/tests/components/sonos/snapshots/test_media_browser.ambr b/tests/components/sonos/snapshots/test_media_browser.ambr index 08b0696f88e..4bbada61ce3 100644 --- a/tests/components/sonos/snapshots/test_media_browser.ambr +++ b/tests/components/sonos/snapshots/test_media_browser.ambr @@ -382,3 +382,37 @@ }), ]) # --- +# name: test_search_media + dict({ + 'result': list([ + dict({ + 'can_expand': False, + 'can_play': True, + 'can_search': False, + 'children': list([ + ]), + 'children_media_class': None, + 'media_class': 'track', + 'media_content_id': 'x-file-cifs://192.168.42.10/music/The%20Beatles/Abbey%20Road/01%20Come%20Together.mp3', + 'media_content_type': 'track', + 'not_shown': 0, + 'thumbnail': 'http://example.com/abbey_road.jpg', + 'title': 'Come Together', + }), + dict({ + 'can_expand': False, + 'can_play': True, + 'can_search': False, + 'children': list([ + ]), + 'children_media_class': None, + 'media_class': 'track', + 'media_content_id': 'x-file-cifs://192.168.42.10/music/The%20Beatles/Abbey%20Road/03%20Something.mp3', + 'media_content_type': 'track', + 'not_shown': 0, + 'thumbnail': 'http://example.com/abbey_road.jpg', + 'title': 'Something', + }), + ]), + }) +# --- diff --git a/tests/components/sonos/snapshots/test_media_player.ambr b/tests/components/sonos/snapshots/test_media_player.ambr index 9fb98183fe1..7f9e94c3e32 100644 --- a/tests/components/sonos/snapshots/test_media_player.ambr +++ b/tests/components/sonos/snapshots/test_media_player.ambr @@ -31,7 +31,7 @@ 'platform': 'sonos', 'previous_unique_id': None, 'suggested_object_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': None, 'unique_id': 'RINCON_test', 'unit_of_measurement': None, @@ -49,7 +49,7 @@ 'media_content_type': , 'repeat': , 'shuffle': False, - 'supported_features': , + 'supported_features': , 'volume_level': 0.19, }), 'context': , diff --git a/tests/components/sonos/test_media_browser.py b/tests/components/sonos/test_media_browser.py index a29f2dad9c7..88e10dde19b 100644 --- a/tests/components/sonos/test_media_browser.py +++ b/tests/components/sonos/test_media_browser.py @@ -1,7 +1,7 @@ """Tests for the Sonos Media Browser.""" from functools import partial -from unittest.mock import MagicMock +from unittest.mock import MagicMock, Mock import pytest from syrupy.assertion import SnapshotAssertion @@ -13,7 +13,7 @@ from homeassistant.components.media_player import ( MediaClass, MediaType, ) -from homeassistant.components.sonos.const import MEDIA_TYPE_DIRECTORY +from homeassistant.components.sonos.const import MEDIA_TYPE_DIRECTORY, SONOS_TRACKS from homeassistant.components.sonos.media_browser import ( build_item_response, get_media, @@ -22,32 +22,11 @@ from homeassistant.components.sonos.media_browser import ( from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant -from .conftest import SoCoMockFactory +from .conftest import MockMusicServiceItem, SoCoMockFactory from tests.typing import WebSocketGenerator -class MockMusicServiceItem: - """Mocks a Soco MusicServiceItem.""" - - def __init__( - self, - title: str, - item_id: str, - parent_id: str, - item_class: str, - ) -> None: - """Initialize the mock item.""" - self.title = title - self.item_id = item_id - self.item_class = item_class - self.parent_id = parent_id - - def get_uri(self) -> str: - """Return URI.""" - return self.item_id.replace("S://", "x-file-cifs://") - - def mock_browse_by_idstring( search_type: str, idstring: str, start=0, max_items=100, full_album_art_uri=False ) -> list[MockMusicServiceItem]: @@ -340,3 +319,84 @@ async def test_browse_media_library_folders( assert response["success"] assert response["result"] == snapshot assert soco_mock.music_library.browse_by_idstring.call_count == 1 + + +async def test_search_media( + hass: HomeAssistant, + soco_factory: SoCoMockFactory, + async_autosetup_sonos, + soco, + discover, + hass_ws_client: WebSocketGenerator, + snapshot: SnapshotAssertion, +) -> None: + """Test the async_search_media method returns tracks matching the query.""" + soco_mock = soco_factory.mock_list.get("192.168.42.2") + mock_items = [ + MockMusicServiceItem( + "Come Together", + "S://192.168.42.10/music/The%20Beatles/Abbey%20Road/01%20Come%20Together.mp3", + "A:ALBUM/Abbey%20Road", + "object.item.audioItem.musicTrack", + album_art_uri="http://example.com/abbey_road.jpg", + ), + MockMusicServiceItem( + "Something", + "S://192.168.42.10/music/The%20Beatles/Abbey%20Road/03%20Something.mp3", + "A:ALBUM/Abbey%20Road", + "object.item.audioItem.musicTrack", + album_art_uri="http://example.com/abbey_road.jpg", + ), + ] + soco_mock.music_library.get_music_library_information = Mock( + return_value=mock_items + ) + + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "media_player/search_media", + "entity_id": "media_player.zone_a", + "search_query": "Come Together", + } + ) + response = await client.receive_json() + assert response["success"] + + assert response["result"] == snapshot + + assert soco_mock.music_library.get_music_library_information.call_args.args == ( + SONOS_TRACKS, + ) + assert soco_mock.music_library.get_music_library_information.call_args.kwargs == { + "search_term": "Come Together", + "full_album_art_uri": True, + "complete_result": True, + } + + +async def test_search_media_invalid_media_content_type( + hass: HomeAssistant, + async_autosetup_sonos, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test that async_search_media raises on an unsupported media_content_type.""" + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "media_player/search_media", + "entity_id": "media_player.zone_a", + "media_content_type": "movie", + "media_content_id": "some_id", + "search_query": "test", + } + ) + response = await client.receive_json() + assert not response["success"] + assert response["error"]["code"] == "home_assistant_error" + assert response["error"]["translation_key"] == "invalid_media_content_type" + assert response["error"]["translation_placeholders"] == { + "media_content_type": "movie" + }