Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We鈥檒l occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow browsing favorites in Sonos media browser #64082

Merged
merged 6 commits into from Jan 14, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
3 changes: 3 additions & 0 deletions homeassistant/components/sonos/const.py
Expand Up @@ -43,6 +43,7 @@
SONOS_ALBUM_ARTIST = "album_artists"
SONOS_TRACKS = "tracks"
SONOS_COMPOSER = "composers"
SONOS_RADIO = "radio"

SONOS_STATE_PLAYING = "PLAYING"
SONOS_STATE_TRANSITIONING = "TRANSITIONING"
Expand Down Expand Up @@ -76,6 +77,7 @@
"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 = {
Expand Down Expand Up @@ -120,6 +122,7 @@
"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 = {
Expand Down
4 changes: 4 additions & 0 deletions homeassistant/components/sonos/favorites.py
Expand Up @@ -33,6 +33,10 @@ def __iter__(self) -> Iterator:
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:
Expand Down
122 changes: 115 additions & 7 deletions homeassistant/components/sonos/media_browser.py
Expand Up @@ -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):
Expand All @@ -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":
Expand Down
91 changes: 69 additions & 22 deletions homeassistant/components/sonos/media_player.py
Expand Up @@ -13,6 +13,7 @@
PLAY_MODE_BY_MEANING,
PLAY_MODES,
)
from soco.data_structures import DidlFavorite
import voluptuous as vol

from homeassistant.components.media_player import MediaPlayerEntity
Expand Down Expand Up @@ -54,6 +55,7 @@
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,
Expand All @@ -67,7 +69,6 @@
)
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__)
Expand Down Expand Up @@ -419,22 +420,37 @@ def select_source(self, source: str) -> None:
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]:
Expand Down Expand Up @@ -501,6 +517,13 @@ def play_media(self, media_type: str, media_id: str, **kwargs: Any) -> None:

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)
jjlawren marked this conversation as resolved.
Show resolved Hide resolved
return

soco = self.coordinator.soco
if media_id and media_id.startswith(PLEX_URI_SCHEME):
media_id = media_id[len(PLEX_URI_SCHEME) :]
Expand All @@ -522,7 +545,7 @@ def play_media(self, media_type: str, media_id: str, **kwargs: Any) -> None:
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:
Expand All @@ -535,7 +558,7 @@ def play_media(self, media_type: str, media_id: str, **kwargs: Any) -> None:
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)
Expand Down Expand Up @@ -616,7 +639,7 @@ async def async_get_browse_image(
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],
Expand All @@ -639,7 +662,7 @@ def _get_thumbnail_url(
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,
Expand All @@ -652,17 +675,41 @@ def _get_thumbnail_url(
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 = {
"search_type": media_content_type,
"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(
Expand Down