diff --git a/.coveragerc b/.coveragerc index 5bf9de194df..76dc1e933ec 100644 --- a/.coveragerc +++ b/.coveragerc @@ -730,6 +730,7 @@ omit = homeassistant/components/roomba/vacuum.py homeassistant/components/roon/__init__.py homeassistant/components/roon/const.py + homeassistant/components/roon/media_browser.py homeassistant/components/roon/media_player.py homeassistant/components/roon/server.py homeassistant/components/route53/* diff --git a/homeassistant/components/roon/config_flow.py b/homeassistant/components/roon/config_flow.py index 7fac3186c03..f8af2bac0e6 100644 --- a/homeassistant/components/roon/config_flow.py +++ b/homeassistant/components/roon/config_flow.py @@ -2,7 +2,7 @@ import asyncio import logging -from roon import RoonApi +from roonapi import RoonApi import voluptuous as vol from homeassistant import config_entries, core, exceptions diff --git a/homeassistant/components/roon/manifest.json b/homeassistant/components/roon/manifest.json index d6bf70e427b..ef69708d8a0 100644 --- a/homeassistant/components/roon/manifest.json +++ b/homeassistant/components/roon/manifest.json @@ -4,7 +4,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/roon", "requirements": [ - "roonapi==0.0.21" + "roonapi==0.0.23" ], "codeowners": [ "@pavoni" diff --git a/homeassistant/components/roon/media_browser.py b/homeassistant/components/roon/media_browser.py new file mode 100644 index 00000000000..ce002131129 --- /dev/null +++ b/homeassistant/components/roon/media_browser.py @@ -0,0 +1,163 @@ +"""Support to interface with the Roon API.""" +import logging + +from homeassistant.components.media_player import BrowseMedia +from homeassistant.components.media_player.const import ( + MEDIA_CLASS_DIRECTORY, + MEDIA_CLASS_PLAYLIST, + MEDIA_CLASS_TRACK, +) +from homeassistant.components.media_player.errors import BrowseError + + +class UnknownMediaType(BrowseError): + """Unknown media type.""" + + +EXCLUDE_ITEMS = { + "Play Album", + "Play Artist", + "Play Playlist", + "Play Composer", + "Play Now", + "Play From Here", + "Queue", + "Start Radio", + "Add Next", + "Play Radio", + "Play Work", + "Settings", + "Search", + "Search Tidal", + "Search Qobuz", +} + +# Maximum number of items to pull back from the API +ITEM_LIMIT = 3000 + +_LOGGER = logging.getLogger(__name__) + + +def browse_media(zone_id, roon_server, media_content_type=None, media_content_id=None): + """Implement the websocket media browsing helper.""" + try: + _LOGGER.debug("browse_media: %s: %s", media_content_type, media_content_id) + if media_content_type in [None, "library"]: + return library_payload(roon_server, zone_id, media_content_id) + + except UnknownMediaType as err: + raise BrowseError( + f"Media not found: {media_content_type} / {media_content_id}" + ) from err + + +def item_payload(roon_server, item, list_image_id): + """Create response payload for a single media item.""" + + title = item.get("title") + subtitle = item.get("subtitle") + if subtitle is None: + display_title = title + else: + display_title = f"{title} ({subtitle})" + + image_id = item.get("image_key") or list_image_id + + image = None + if image_id: + image = roon_server.roonapi.get_image(image_id) + + media_content_id = item.get("item_key") + media_content_type = "library" + + hint = item.get("hint") + if hint == "list": + media_class = MEDIA_CLASS_DIRECTORY + can_expand = True + elif hint == "action_list": + media_class = MEDIA_CLASS_PLAYLIST + can_expand = False + elif hint == "action": + media_content_type = "track" + media_class = MEDIA_CLASS_TRACK + can_expand = False + else: + # Roon API says to treat unknown as a list + media_class = MEDIA_CLASS_DIRECTORY + can_expand = True + _LOGGER.warning("Unknown hint %s - %s", title, hint) + + payload = { + "title": display_title, + "media_class": media_class, + "media_content_id": media_content_id, + "media_content_type": media_content_type, + "can_play": True, + "can_expand": can_expand, + "thumbnail": image, + } + + return BrowseMedia(**payload) + + +def library_payload(roon_server, zone_id, media_content_id): + """Create response payload for the library.""" + + opts = { + "hierarchy": "browse", + "zone_or_output_id": zone_id, + "count": ITEM_LIMIT, + } + + # Roon starts browsing for a zone where it left off - so start from the top unless otherwise specified + if media_content_id is None or media_content_id == "Explore": + opts["pop_all"] = True + content_id = "Explore" + else: + opts["item_key"] = media_content_id + content_id = media_content_id + + result_header = roon_server.roonapi.browse_browse(opts) + _LOGGER.debug("result_header %s", result_header) + + header = result_header["list"] + title = header.get("title") + + subtitle = header.get("subtitle") + if subtitle is None: + list_title = title + else: + list_title = f"{title} ({subtitle})" + + total_count = header["count"] + + library_image_id = header.get("image_key") + + library_info = BrowseMedia( + title=list_title, + media_content_id=content_id, + media_content_type="library", + media_class=MEDIA_CLASS_DIRECTORY, + can_play=False, + can_expand=True, + children=[], + ) + + result_detail = roon_server.roonapi.browse_load(opts) + _LOGGER.debug("result_detail %s", result_detail) + + items = result_detail.get("items") + count = len(items) + + if count < total_count: + _LOGGER.debug( + "Exceeded limit of %d, loaded %d/%d", ITEM_LIMIT, count, total_count + ) + + for item in items: + if item.get("title") in EXCLUDE_ITEMS: + continue + entry = item_payload(roon_server, item, library_image_id) + library_info.children.append(entry) + + return library_info diff --git a/homeassistant/components/roon/media_player.py b/homeassistant/components/roon/media_player.py index a4bd374dcce..1c56e858d84 100644 --- a/homeassistant/components/roon/media_player.py +++ b/homeassistant/components/roon/media_player.py @@ -3,6 +3,7 @@ import logging from homeassistant.components.media_player import MediaPlayerEntity from homeassistant.components.media_player.const import ( + SUPPORT_BROWSE_MEDIA, SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PLAY, @@ -33,9 +34,11 @@ from homeassistant.util import convert from homeassistant.util.dt import utcnow from .const import DOMAIN +from .media_browser import browse_media SUPPORT_ROON = ( - SUPPORT_PAUSE + SUPPORT_BROWSE_MEDIA + | SUPPORT_PAUSE | SUPPORT_VOLUME_SET | SUPPORT_STOP | SUPPORT_PREVIOUS_TRACK @@ -466,9 +469,21 @@ class RoonDevice(MediaPlayerEntity): self._server.roonapi.queue_playlist(self.zone_id, media_id) elif media_type == "genre": self._server.roonapi.play_genre(self.zone_id, media_id) + elif media_type in ("library", "track"): + self._server.roonapi.play_id(self.zone_id, media_id) else: _LOGGER.error( "Playback requested of unsupported type: %s --> %s", media_type, media_id, ) + + async def async_browse_media(self, media_content_type=None, media_content_id=None): + """Implement the websocket media browsing helper.""" + return await self.hass.async_add_executor_job( + browse_media, + self.zone_id, + self._server, + media_content_type, + media_content_id, + ) diff --git a/homeassistant/components/roon/server.py b/homeassistant/components/roon/server.py index df6051c287a..57adf00a9ac 100644 --- a/homeassistant/components/roon/server.py +++ b/homeassistant/components/roon/server.py @@ -2,7 +2,7 @@ import asyncio import logging -from roon import RoonApi +from roonapi import RoonApi from homeassistant.const import CONF_API_KEY, CONF_HOST from homeassistant.helpers.dispatcher import async_dispatcher_send diff --git a/requirements_all.txt b/requirements_all.txt index 937509f4219..231fb6bab2c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1948,7 +1948,7 @@ rokuecp==0.6.0 roombapy==1.6.1 # homeassistant.components.roon -roonapi==0.0.21 +roonapi==0.0.23 # homeassistant.components.rova rova==0.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ed2506749e8..31681088c27 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -932,7 +932,7 @@ rokuecp==0.6.0 roombapy==1.6.1 # homeassistant.components.roon -roonapi==0.0.21 +roonapi==0.0.23 # homeassistant.components.rpi_power rpi-bad-power==0.0.3