From 2f18058fe739cabc0495a14e00df3ced60009ba2 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 14 Jan 2022 09:28:25 -0800 Subject: [PATCH] Allow browsing favorites in Sonos media browser (#64082) * Allow browsing favorites in Sonos media browser * Group favorites by type, add thumbnails * Update homeassistant/components/sonos/media_player.py * Apply suggestions from code review Co-authored-by: Martin Hjelmare * Keep favorite groups ordering consistent * Skip root folder if only one child available Co-authored-by: Jason Lawrence Co-authored-by: Martin Hjelmare --- homeassistant/components/sonos/const.py | 3 + homeassistant/components/sonos/favorites.py | 4 + .../components/sonos/media_browser.py | 122 +++++++++++++++++- .../components/sonos/media_player.py | 91 +++++++++---- 4 files changed, 191 insertions(+), 29 deletions(-) diff --git a/homeassistant/components/sonos/const.py b/homeassistant/components/sonos/const.py index e9ea02b335c..bbeaceb08cd 100644 --- a/homeassistant/components/sonos/const.py +++ b/homeassistant/components/sonos/const.py @@ -43,6 +43,7 @@ SONOS_GENRE = "genres" SONOS_ALBUM_ARTIST = "album_artists" SONOS_TRACKS = "tracks" SONOS_COMPOSER = "composers" +SONOS_RADIO = "radio" SONOS_STATE_PLAYING = "PLAYING" SONOS_STATE_TRANSITIONING = "TRANSITIONING" @@ -76,6 +77,7 @@ SONOS_TO_MEDIA_CLASSES = { "object.container.playlistContainer.sameArtist": MEDIA_CLASS_ARTIST, "object.container.playlistContainer": MEDIA_CLASS_PLAYLIST, "object.item.audioItem.musicTrack": MEDIA_CLASS_TRACK, + "object.item.audioItem.audioBroadcast": MEDIA_CLASS_GENRE, } SONOS_TO_MEDIA_TYPES = { @@ -120,6 +122,7 @@ SONOS_TYPES_MAPPING = { "object.container.playlistContainer.sameArtist": SONOS_ARTIST, "object.container.playlistContainer": SONOS_PLAYLISTS, "object.item.audioItem.musicTrack": SONOS_TRACKS, + "object.item.audioItem.audioBroadcast": SONOS_RADIO, } LIBRARY_TITLES_MAPPING = { diff --git a/homeassistant/components/sonos/favorites.py b/homeassistant/components/sonos/favorites.py index adf31b0f507..2b362014ecf 100644 --- a/homeassistant/components/sonos/favorites.py +++ b/homeassistant/components/sonos/favorites.py @@ -33,6 +33,10 @@ class SonosFavorites(SonosHouseholdCoordinator): favorites = self._favorites.copy() return iter(favorites) + def lookup_by_item_id(self, item_id: str) -> DidlFavorite | None: + """Return the favorite object with the provided item_id.""" + return next((fav for fav in self._favorites if fav.item_id == item_id), None) + async def async_update_entities( self, soco: SoCo, update_id: int | None = None ) -> None: diff --git a/homeassistant/components/sonos/media_browser.py b/homeassistant/components/sonos/media_browser.py index 179fc62e0cc..9a07b13c25d 100644 --- a/homeassistant/components/sonos/media_browser.py +++ b/homeassistant/components/sonos/media_browser.py @@ -120,19 +120,60 @@ def item_payload(item, get_thumbnail_url=None): ) +def root_payload(media_library, favorites, get_thumbnail_url): + """Return root payload for Sonos.""" + has_local_library = bool( + media_library.browse_by_idstring( + "tracks", + "", + max_items=1, + ) + ) + + if not (favorites or has_local_library): + raise BrowseError("No media available") + + if not has_local_library: + return favorites_payload(favorites) + if not favorites: + return library_payload(media_library, get_thumbnail_url) + + children = [ + BrowseMedia( + title="Favorites", + media_class=MEDIA_CLASS_DIRECTORY, + media_content_id="", + media_content_type="favorites", + can_play=False, + can_expand=True, + ), + BrowseMedia( + title="Music Library", + media_class=MEDIA_CLASS_DIRECTORY, + media_content_id="", + media_content_type="library", + can_play=False, + can_expand=True, + ), + ] + + return BrowseMedia( + title="Sonos", + media_class=MEDIA_CLASS_DIRECTORY, + media_content_id="", + media_content_type="root", + can_play=False, + can_expand=True, + children=children, + ) + + def library_payload(media_library, get_thumbnail_url=None): """ Create response payload to describe contents of a specific library. Used by async_browse_media. """ - if not media_library.browse_by_idstring( - "tracks", - "", - max_items=1, - ): - raise BrowseError("Local library not found") - children = [] for item in media_library.browse(): with suppress(UnknownMediaType): @@ -149,6 +190,73 @@ def library_payload(media_library, get_thumbnail_url=None): ) +def favorites_payload(favorites): + """ + Create response payload to describe contents of a specific library. + + Used by async_browse_media. + """ + children = [] + + group_types = {fav.reference.item_class for fav in favorites} + for group_type in sorted(group_types): + media_content_type = SONOS_TYPES_MAPPING[group_type] + children.append( + BrowseMedia( + title=media_content_type.title(), + media_class=SONOS_TO_MEDIA_CLASSES[group_type], + media_content_id=group_type, + media_content_type="favorites_folder", + can_play=False, + can_expand=True, + ) + ) + + return BrowseMedia( + title="Favorites", + media_class=MEDIA_CLASS_DIRECTORY, + media_content_id="", + media_content_type="favorites", + can_play=False, + can_expand=True, + children=children, + ) + + +def favorites_folder_payload(favorites, media_content_id): + """Create response payload to describe all items of a type of favorite. + + Used by async_browse_media. + """ + children = [] + content_type = SONOS_TYPES_MAPPING[media_content_id] + + for favorite in favorites: + if favorite.reference.item_class != media_content_id: + continue + children.append( + BrowseMedia( + title=favorite.title, + media_class=SONOS_TO_MEDIA_CLASSES[favorite.reference.item_class], + media_content_id=favorite.item_id, + media_content_type="favorite_item_id", + can_play=True, + can_expand=False, + thumbnail=getattr(favorite, "album_art_uri", None), + ) + ) + + return BrowseMedia( + title=content_type.title(), + media_class=MEDIA_CLASS_DIRECTORY, + media_content_id="", + media_content_type="favorites", + can_play=False, + can_expand=True, + children=children, + ) + + def get_media_type(item): """Extract media type of item.""" if item.item_class == "object.item.audioItem.musicTrack": diff --git a/homeassistant/components/sonos/media_player.py b/homeassistant/components/sonos/media_player.py index b55c6cb4b13..49cbe5ae165 100644 --- a/homeassistant/components/sonos/media_player.py +++ b/homeassistant/components/sonos/media_player.py @@ -13,6 +13,7 @@ from soco.core import ( PLAY_MODE_BY_MEANING, PLAY_MODES, ) +from soco.data_structures import DidlFavorite import voluptuous as vol from homeassistant.components.media_player import MediaPlayerEntity @@ -54,6 +55,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.network import is_internal_request +from . import media_browser from .const import ( DATA_SONOS, DOMAIN as SONOS_DOMAIN, @@ -67,7 +69,6 @@ from .const import ( ) from .entity import SonosEntity from .helpers import soco_error -from .media_browser import build_item_response, get_media, library_payload from .speaker import SonosMedia, SonosSpeaker _LOGGER = logging.getLogger(__name__) @@ -419,22 +420,37 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): soco = self.coordinator.soco if source == SOURCE_LINEIN: soco.switch_to_line_in() - elif source == SOURCE_TV: + return + + if source == SOURCE_TV: soco.switch_to_tv() + return + + self._play_favorite_by_name(source) + + def _play_favorite_by_name(self, name: str) -> None: + """Play a favorite by name.""" + fav = [fav for fav in self.speaker.favorites if fav.title == name] + + if len(fav) != 1: + return + + src = fav.pop() + self._play_favorite(src) + + def _play_favorite(self, favorite: DidlFavorite) -> None: + """Play a favorite.""" + uri = favorite.reference.get_uri() + soco = self.coordinator.soco + if soco.music_source_from_uri(uri) in [ + MUSIC_SRC_RADIO, + MUSIC_SRC_LINE_IN, + ]: + soco.play_uri(uri, title=favorite.title) else: - fav = [fav for fav in self.speaker.favorites if fav.title == source] - if len(fav) == 1: - src = fav.pop() - uri = src.reference.get_uri() - if soco.music_source_from_uri(uri) in [ - MUSIC_SRC_RADIO, - MUSIC_SRC_LINE_IN, - ]: - soco.play_uri(uri, title=source) - else: - soco.clear_queue() - soco.add_to_queue(src.reference) - soco.play_from_queue(0) + soco.clear_queue() + soco.add_to_queue(favorite.reference) + soco.play_from_queue(0) @property # type: ignore[misc] def source_list(self) -> list[str]: @@ -501,6 +517,13 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): If ATTR_MEDIA_ENQUEUE is True, add `media_id` to the queue. """ + if media_type == "favorite_item_id": + favorite = self.speaker.favorites.lookup_by_item_id(media_id) + if favorite is None: + raise ValueError(f"Missing favorite for media_id: {media_id}") + self._play_favorite(favorite) + return + soco = self.coordinator.soco if media_id and media_id.startswith(PLEX_URI_SCHEME): media_id = media_id[len(PLEX_URI_SCHEME) :] @@ -522,7 +545,7 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): soco.play_uri(media_id) elif media_type == MEDIA_TYPE_PLAYLIST: if media_id.startswith("S:"): - item = get_media(self.media.library, media_id, media_type) # type: ignore[no-untyped-call] + item = media_browser.get_media(self.media.library, media_id, media_type) # type: ignore[no-untyped-call] soco.play_uri(item.get_uri()) return try: @@ -535,7 +558,7 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): soco.add_to_queue(playlist) soco.play_from_queue(0) elif media_type in PLAYABLE_MEDIA_TYPES: - item = get_media(self.media.library, media_id, media_type) # type: ignore[no-untyped-call] + item = media_browser.get_media(self.media.library, media_id, media_type) # type: ignore[no-untyped-call] if not item: _LOGGER.error('Could not find "%s" in the library', media_id) @@ -616,7 +639,7 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): and media_content_id ): item = await self.hass.async_add_executor_job( - get_media, + media_browser.get_media, self.media.library, media_content_id, MEDIA_TYPES_TO_SONOS[media_content_type], @@ -639,7 +662,7 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): media_image_id: str | None = None, ) -> str | None: if is_internal: - item = get_media( # type: ignore[no-untyped-call] + item = media_browser.get_media( # type: ignore[no-untyped-call] self.media.library, media_content_id, media_content_type, @@ -652,9 +675,30 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): media_image_id, ) - if media_content_type in [None, "library"]: + if media_content_type in [None, "root"]: return await self.hass.async_add_executor_job( - library_payload, self.media.library, _get_thumbnail_url + media_browser.root_payload, + self.media.library, + self.speaker.favorites, + _get_thumbnail_url, + ) + + if media_content_type == "library": + return await self.hass.async_add_executor_job( + media_browser.library_payload, self.media.library, _get_thumbnail_url + ) + + if media_content_type == "favorites": + return await self.hass.async_add_executor_job( + media_browser.favorites_payload, + self.speaker.favorites, + ) + + if media_content_type == "favorites_folder": + return await self.hass.async_add_executor_job( + media_browser.favorites_folder_payload, + self.speaker.favorites, + media_content_id, ) payload = { @@ -662,7 +706,10 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): "idstring": media_content_id, } response = await self.hass.async_add_executor_job( - build_item_response, self.media.library, payload, _get_thumbnail_url + media_browser.build_item_response, + self.media.library, + payload, + _get_thumbnail_url, ) if response is None: raise BrowseError(