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 <marhje52@gmail.com>

* Keep favorite groups ordering consistent

* Skip root folder if only one child available

Co-authored-by: Jason Lawrence <jjlawren@users.noreply.github.com>
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
This commit is contained in:
Paulus Schoutsen 2022-01-14 09:28:25 -08:00 committed by GitHub
parent 3265a09083
commit 2f18058fe7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 191 additions and 29 deletions

View File

@ -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 = {

View File

@ -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:

View File

@ -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":

View File

@ -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(