mirror of
https://github.com/home-assistant/core.git
synced 2025-06-04 05:07:08 +00:00
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:
parent
3265a09083
commit
2f18058fe7
@ -43,6 +43,7 @@ SONOS_GENRE = "genres"
|
|||||||
SONOS_ALBUM_ARTIST = "album_artists"
|
SONOS_ALBUM_ARTIST = "album_artists"
|
||||||
SONOS_TRACKS = "tracks"
|
SONOS_TRACKS = "tracks"
|
||||||
SONOS_COMPOSER = "composers"
|
SONOS_COMPOSER = "composers"
|
||||||
|
SONOS_RADIO = "radio"
|
||||||
|
|
||||||
SONOS_STATE_PLAYING = "PLAYING"
|
SONOS_STATE_PLAYING = "PLAYING"
|
||||||
SONOS_STATE_TRANSITIONING = "TRANSITIONING"
|
SONOS_STATE_TRANSITIONING = "TRANSITIONING"
|
||||||
@ -76,6 +77,7 @@ SONOS_TO_MEDIA_CLASSES = {
|
|||||||
"object.container.playlistContainer.sameArtist": MEDIA_CLASS_ARTIST,
|
"object.container.playlistContainer.sameArtist": MEDIA_CLASS_ARTIST,
|
||||||
"object.container.playlistContainer": MEDIA_CLASS_PLAYLIST,
|
"object.container.playlistContainer": MEDIA_CLASS_PLAYLIST,
|
||||||
"object.item.audioItem.musicTrack": MEDIA_CLASS_TRACK,
|
"object.item.audioItem.musicTrack": MEDIA_CLASS_TRACK,
|
||||||
|
"object.item.audioItem.audioBroadcast": MEDIA_CLASS_GENRE,
|
||||||
}
|
}
|
||||||
|
|
||||||
SONOS_TO_MEDIA_TYPES = {
|
SONOS_TO_MEDIA_TYPES = {
|
||||||
@ -120,6 +122,7 @@ SONOS_TYPES_MAPPING = {
|
|||||||
"object.container.playlistContainer.sameArtist": SONOS_ARTIST,
|
"object.container.playlistContainer.sameArtist": SONOS_ARTIST,
|
||||||
"object.container.playlistContainer": SONOS_PLAYLISTS,
|
"object.container.playlistContainer": SONOS_PLAYLISTS,
|
||||||
"object.item.audioItem.musicTrack": SONOS_TRACKS,
|
"object.item.audioItem.musicTrack": SONOS_TRACKS,
|
||||||
|
"object.item.audioItem.audioBroadcast": SONOS_RADIO,
|
||||||
}
|
}
|
||||||
|
|
||||||
LIBRARY_TITLES_MAPPING = {
|
LIBRARY_TITLES_MAPPING = {
|
||||||
|
@ -33,6 +33,10 @@ class SonosFavorites(SonosHouseholdCoordinator):
|
|||||||
favorites = self._favorites.copy()
|
favorites = self._favorites.copy()
|
||||||
return iter(favorites)
|
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(
|
async def async_update_entities(
|
||||||
self, soco: SoCo, update_id: int | None = None
|
self, soco: SoCo, update_id: int | None = None
|
||||||
) -> None:
|
) -> None:
|
||||||
|
@ -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):
|
def library_payload(media_library, get_thumbnail_url=None):
|
||||||
"""
|
"""
|
||||||
Create response payload to describe contents of a specific library.
|
Create response payload to describe contents of a specific library.
|
||||||
|
|
||||||
Used by async_browse_media.
|
Used by async_browse_media.
|
||||||
"""
|
"""
|
||||||
if not media_library.browse_by_idstring(
|
|
||||||
"tracks",
|
|
||||||
"",
|
|
||||||
max_items=1,
|
|
||||||
):
|
|
||||||
raise BrowseError("Local library not found")
|
|
||||||
|
|
||||||
children = []
|
children = []
|
||||||
for item in media_library.browse():
|
for item in media_library.browse():
|
||||||
with suppress(UnknownMediaType):
|
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):
|
def get_media_type(item):
|
||||||
"""Extract media type of item."""
|
"""Extract media type of item."""
|
||||||
if item.item_class == "object.item.audioItem.musicTrack":
|
if item.item_class == "object.item.audioItem.musicTrack":
|
||||||
|
@ -13,6 +13,7 @@ from soco.core import (
|
|||||||
PLAY_MODE_BY_MEANING,
|
PLAY_MODE_BY_MEANING,
|
||||||
PLAY_MODES,
|
PLAY_MODES,
|
||||||
)
|
)
|
||||||
|
from soco.data_structures import DidlFavorite
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
from homeassistant.components.media_player import MediaPlayerEntity
|
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.entity_platform import AddEntitiesCallback
|
||||||
from homeassistant.helpers.network import is_internal_request
|
from homeassistant.helpers.network import is_internal_request
|
||||||
|
|
||||||
|
from . import media_browser
|
||||||
from .const import (
|
from .const import (
|
||||||
DATA_SONOS,
|
DATA_SONOS,
|
||||||
DOMAIN as SONOS_DOMAIN,
|
DOMAIN as SONOS_DOMAIN,
|
||||||
@ -67,7 +69,6 @@ from .const import (
|
|||||||
)
|
)
|
||||||
from .entity import SonosEntity
|
from .entity import SonosEntity
|
||||||
from .helpers import soco_error
|
from .helpers import soco_error
|
||||||
from .media_browser import build_item_response, get_media, library_payload
|
|
||||||
from .speaker import SonosMedia, SonosSpeaker
|
from .speaker import SonosMedia, SonosSpeaker
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
@ -419,22 +420,37 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity):
|
|||||||
soco = self.coordinator.soco
|
soco = self.coordinator.soco
|
||||||
if source == SOURCE_LINEIN:
|
if source == SOURCE_LINEIN:
|
||||||
soco.switch_to_line_in()
|
soco.switch_to_line_in()
|
||||||
elif source == SOURCE_TV:
|
return
|
||||||
|
|
||||||
|
if source == SOURCE_TV:
|
||||||
soco.switch_to_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:
|
else:
|
||||||
fav = [fav for fav in self.speaker.favorites if fav.title == source]
|
soco.clear_queue()
|
||||||
if len(fav) == 1:
|
soco.add_to_queue(favorite.reference)
|
||||||
src = fav.pop()
|
soco.play_from_queue(0)
|
||||||
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)
|
|
||||||
|
|
||||||
@property # type: ignore[misc]
|
@property # type: ignore[misc]
|
||||||
def source_list(self) -> list[str]:
|
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 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
|
soco = self.coordinator.soco
|
||||||
if media_id and media_id.startswith(PLEX_URI_SCHEME):
|
if media_id and media_id.startswith(PLEX_URI_SCHEME):
|
||||||
media_id = media_id[len(PLEX_URI_SCHEME) :]
|
media_id = media_id[len(PLEX_URI_SCHEME) :]
|
||||||
@ -522,7 +545,7 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity):
|
|||||||
soco.play_uri(media_id)
|
soco.play_uri(media_id)
|
||||||
elif media_type == MEDIA_TYPE_PLAYLIST:
|
elif media_type == MEDIA_TYPE_PLAYLIST:
|
||||||
if media_id.startswith("S:"):
|
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())
|
soco.play_uri(item.get_uri())
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
@ -535,7 +558,7 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity):
|
|||||||
soco.add_to_queue(playlist)
|
soco.add_to_queue(playlist)
|
||||||
soco.play_from_queue(0)
|
soco.play_from_queue(0)
|
||||||
elif media_type in PLAYABLE_MEDIA_TYPES:
|
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:
|
if not item:
|
||||||
_LOGGER.error('Could not find "%s" in the library', media_id)
|
_LOGGER.error('Could not find "%s" in the library', media_id)
|
||||||
@ -616,7 +639,7 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity):
|
|||||||
and media_content_id
|
and media_content_id
|
||||||
):
|
):
|
||||||
item = await self.hass.async_add_executor_job(
|
item = await self.hass.async_add_executor_job(
|
||||||
get_media,
|
media_browser.get_media,
|
||||||
self.media.library,
|
self.media.library,
|
||||||
media_content_id,
|
media_content_id,
|
||||||
MEDIA_TYPES_TO_SONOS[media_content_type],
|
MEDIA_TYPES_TO_SONOS[media_content_type],
|
||||||
@ -639,7 +662,7 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity):
|
|||||||
media_image_id: str | None = None,
|
media_image_id: str | None = None,
|
||||||
) -> str | None:
|
) -> str | None:
|
||||||
if is_internal:
|
if is_internal:
|
||||||
item = get_media( # type: ignore[no-untyped-call]
|
item = media_browser.get_media( # type: ignore[no-untyped-call]
|
||||||
self.media.library,
|
self.media.library,
|
||||||
media_content_id,
|
media_content_id,
|
||||||
media_content_type,
|
media_content_type,
|
||||||
@ -652,9 +675,30 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity):
|
|||||||
media_image_id,
|
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(
|
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 = {
|
payload = {
|
||||||
@ -662,7 +706,10 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity):
|
|||||||
"idstring": media_content_id,
|
"idstring": media_content_id,
|
||||||
}
|
}
|
||||||
response = await self.hass.async_add_executor_job(
|
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:
|
if response is None:
|
||||||
raise BrowseError(
|
raise BrowseError(
|
||||||
|
Loading…
x
Reference in New Issue
Block a user