From 6d95ee7a00de1524da5e11632cc002f3790d602a Mon Sep 17 00:00:00 2001 From: jjlawren Date: Mon, 24 Aug 2020 05:41:01 -0500 Subject: [PATCH] Websocket media browsing for Plex (#35590) Co-authored-by: Paulus Schoutsen Co-authored-by: Paulus Schoutsen --- .../components/media_player/__init__.py | 117 ++++++++++++++-- .../components/media_player/const.py | 1 + .../components/media_player/errors.py | 10 ++ .../components/plex/media_browser.py | 125 ++++++++++++++++++ homeassistant/components/plex/media_player.py | 22 ++- homeassistant/components/plex/server.py | 7 +- .../components/websocket_api/const.py | 1 + tests/components/media_player/test_init.py | 57 ++++++++ 8 files changed, 323 insertions(+), 17 deletions(-) create mode 100644 homeassistant/components/media_player/errors.py create mode 100644 homeassistant/components/plex/media_browser.py diff --git a/homeassistant/components/media_player/__init__.py b/homeassistant/components/media_player/__init__.py index 24b1b570476..70eda4329c6 100644 --- a/homeassistant/components/media_player/__init__.py +++ b/homeassistant/components/media_player/__init__.py @@ -18,6 +18,11 @@ import voluptuous as vol from homeassistant.components import websocket_api from homeassistant.components.http import KEY_AUTHENTICATED, HomeAssistantView +from homeassistant.components.websocket_api.const import ( + ERR_NOT_FOUND, + ERR_NOT_SUPPORTED, + ERR_UNKNOWN_ERROR, +) from homeassistant.const import ( HTTP_INTERNAL_SERVER_ERROR, HTTP_NOT_FOUND, @@ -84,6 +89,7 @@ from .const import ( SERVICE_PLAY_MEDIA, SERVICE_SELECT_SOUND_MODE, SERVICE_SELECT_SOURCE, + SUPPORT_BROWSE_MEDIA, SUPPORT_CLEAR_PLAYLIST, SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, @@ -101,6 +107,7 @@ from .const import ( SUPPORT_VOLUME_SET, SUPPORT_VOLUME_STEP, ) +from .errors import BrowseError # mypy: allow-untyped-defs, no-check-untyped-defs @@ -171,12 +178,6 @@ def is_on(hass, entity_id=None): ) -WS_TYPE_MEDIA_PLAYER_THUMBNAIL = "media_player_thumbnail" -SCHEMA_WEBSOCKET_GET_THUMBNAIL = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend( - {"type": WS_TYPE_MEDIA_PLAYER_THUMBNAIL, "entity_id": cv.entity_id} -) - - def _rename_keys(**keys): """Create validator that renames keys. @@ -200,11 +201,8 @@ async def async_setup(hass, config): logging.getLogger(__name__), DOMAIN, hass, SCAN_INTERVAL ) - hass.components.websocket_api.async_register_command( - WS_TYPE_MEDIA_PLAYER_THUMBNAIL, - websocket_handle_thumbnail, - SCHEMA_WEBSOCKET_GET_THUMBNAIL, - ) + hass.components.websocket_api.async_register_command(websocket_handle_thumbnail) + hass.components.websocket_api.async_register_command(websocket_browse_media) hass.http.register_view(MediaPlayerImageView(component)) await component.async_setup(config) @@ -812,6 +810,27 @@ class MediaPlayerEntity(Entity): return state_attr + async def async_browse_media(self, media_content_type=None, media_content_id=None): + """ + Return a payload for the "media_player/browse_media" websocket command. + + Payload should follow this format: + { + "title": str - Title of the item + "media_content_type": str - see below + "media_content_id": str - see below + - Can be passed back in to browse further + - Can be used as-is with media_player.play_media service + "can_play": bool - If item is playable + "can_expand": bool - If item contains other media + "thumbnail": str (Optional) - URL to image thumbnail for item + "children": list (Optional) - [{}, ...] + } + + Note: Children should omit the children key. + """ + raise NotImplementedError() + async def _async_fetch_image(hass, url): """Fetch image. @@ -888,6 +907,12 @@ class MediaPlayerImageView(HomeAssistantView): return web.Response(body=data, content_type=content_type, headers=headers) +@websocket_api.websocket_command( + { + vol.Required("type"): "media_player_thumbnail", + vol.Required("entity_id"): cv.entity_id, + } +) @websocket_api.async_response async def websocket_handle_thumbnail(hass, connection, msg): """Handle get media player cover command. @@ -899,9 +924,7 @@ async def websocket_handle_thumbnail(hass, connection, msg): if player is None: connection.send_message( - websocket_api.error_message( - msg["id"], "entity_not_found", "Entity not found" - ) + websocket_api.error_message(msg["id"], ERR_NOT_FOUND, "Entity not found") ) return @@ -928,6 +951,72 @@ async def websocket_handle_thumbnail(hass, connection, msg): ) +@websocket_api.websocket_command( + { + vol.Required("type"): "media_player/browse_media", + vol.Required("entity_id"): cv.entity_id, + vol.Inclusive( + ATTR_MEDIA_CONTENT_TYPE, + "media_ids", + "media_content_type and media_content_id must be provided together", + ): str, + vol.Inclusive( + ATTR_MEDIA_CONTENT_ID, + "media_ids", + "media_content_type and media_content_id must be provided together", + ): str, + } +) +@websocket_api.async_response +async def websocket_browse_media(hass, connection, msg): + """ + Browse media available to the media_player entity. + + To use, media_player integrations can implement MediaPlayerEntity.async_browse_media() + """ + component = hass.data[DOMAIN] + player = component.get_entity(msg["entity_id"]) + + if player is None: + connection.send_error(msg["id"], "entity_not_found", "Entity not found") + return + + if not player.supported_features & SUPPORT_BROWSE_MEDIA: + connection.send_message( + websocket_api.error_message( + msg["id"], ERR_NOT_SUPPORTED, "Player does not support browsing media" + ) + ) + return + + media_content_type = msg.get(ATTR_MEDIA_CONTENT_TYPE) + media_content_id = msg.get(ATTR_MEDIA_CONTENT_ID) + + try: + payload = await player.async_browse_media(media_content_type, media_content_id) + except NotImplementedError: + _LOGGER.error( + "%s allows media browsing but its integration (%s) does not", + player.entity_id, + player.platform.platform_name, + ) + connection.send_message( + websocket_api.error_message( + msg["id"], + ERR_NOT_SUPPORTED, + "Integration does not support browsing media", + ) + ) + return + except BrowseError as err: + connection.send_message( + websocket_api.error_message(msg["id"], ERR_UNKNOWN_ERROR, str(err)) + ) + return + + connection.send_result(msg["id"], payload) + + class MediaPlayerDevice(MediaPlayerEntity): """ABC for media player devices (for backwards compatibility).""" diff --git a/homeassistant/components/media_player/const.py b/homeassistant/components/media_player/const.py index 372b34eae45..0e7e038cdc1 100644 --- a/homeassistant/components/media_player/const.py +++ b/homeassistant/components/media_player/const.py @@ -63,3 +63,4 @@ SUPPORT_CLEAR_PLAYLIST = 8192 SUPPORT_PLAY = 16384 SUPPORT_SHUFFLE_SET = 32768 SUPPORT_SELECT_SOUND_MODE = 65536 +SUPPORT_BROWSE_MEDIA = 131072 diff --git a/homeassistant/components/media_player/errors.py b/homeassistant/components/media_player/errors.py new file mode 100644 index 00000000000..2e8443c2794 --- /dev/null +++ b/homeassistant/components/media_player/errors.py @@ -0,0 +1,10 @@ +"""Errors for the Media Player component.""" +from homeassistant.exceptions import HomeAssistantError + + +class MediaPlayerException(HomeAssistantError): + """Base class for Media Player exceptions.""" + + +class BrowseError(MediaPlayerException): + """Error while browsing.""" diff --git a/homeassistant/components/plex/media_browser.py b/homeassistant/components/plex/media_browser.py new file mode 100644 index 00000000000..ac316edb938 --- /dev/null +++ b/homeassistant/components/plex/media_browser.py @@ -0,0 +1,125 @@ +"""Support to interface with the Plex API.""" +import logging + +from homeassistant.components.media_player.errors import BrowseError + +from .const import DOMAIN + +EXPANDABLES = ["album", "artist", "playlist", "season", "show"] +PLAYLISTS_BROWSE_PAYLOAD = { + "title": "Playlists", + "media_content_id": "all", + "media_content_type": "playlists", + "can_play": False, + "can_expand": True, +} + +_LOGGER = logging.getLogger(__name__) + + +def browse_media( + entity_id, plex_server, media_content_type=None, media_content_id=None +): + """Implement the websocket media browsing helper.""" + + def build_item_response(payload): + """Create response payload for the provided media query.""" + media = plex_server.lookup_media(**payload) + + if media is None: + return None + + media_info = item_payload(media) + if media_info.get("can_expand"): + media_info["children"] = [] + for item in media: + media_info["children"].append(item_payload(item)) + return media_info + + if ( + media_content_type == "server" + and media_content_id != plex_server.machine_identifier + ): + raise BrowseError( + f"Plex server with ID '{media_content_id}' is not associated with {entity_id}" + ) + + if media_content_type in ["server", None]: + return server_payload(plex_server) + + if media_content_type == "library": + return library_payload(plex_server, media_content_id) + + if media_content_type == "playlists": + return playlists_payload(plex_server) + + payload = { + "media_type": DOMAIN, + "plex_key": int(media_content_id), + } + response = build_item_response(payload) + if response is None: + raise BrowseError(f"Media not found: {media_content_type} / {media_content_id}") + return response + + +def item_payload(item): + """Create response payload for a single media item.""" + payload = { + "title": item.title, + "media_content_id": str(item.ratingKey), + "media_content_type": item.type, + "can_play": True, + } + if hasattr(item, "thumbUrl"): + payload["thumbnail"] = item.thumbUrl + if item.type in EXPANDABLES: + payload["can_expand"] = True + return payload + + +def library_section_payload(section): + """Create response payload for a single library section.""" + return { + "title": section.title, + "media_content_id": section.key, + "media_content_type": "library", + "can_play": False, + "can_expand": True, + } + + +def server_payload(plex_server): + """Create response payload to describe libraries of the Plex server.""" + server_info = { + "title": plex_server.friendly_name, + "media_content_id": plex_server.machine_identifier, + "media_content_type": "server", + "can_play": False, + "can_expand": True, + } + server_info["children"] = [] + for library in plex_server.library.sections(): + if library.type == "photo": + continue + server_info["children"].append(library_section_payload(library)) + server_info["children"].append(PLAYLISTS_BROWSE_PAYLOAD) + return server_info + + +def library_payload(plex_server, library_id): + """Create response payload to describe contents of a specific library.""" + library = plex_server.library.sectionByID(library_id) + library_info = library_section_payload(library) + library_info["children"] = [] + for item in library.all(): + library_info["children"].append(item_payload(item)) + return library_info + + +def playlists_payload(plex_server): + """Create response payload for all available playlists.""" + playlists_info = {**PLAYLISTS_BROWSE_PAYLOAD, "children": []} + for playlist in plex_server.playlists(): + playlists_info["children"].append(item_payload(playlist)) + return playlists_info diff --git a/homeassistant/components/plex/media_player.py b/homeassistant/components/plex/media_player.py index 1b7db505e29..f5dc98e4eb1 100644 --- a/homeassistant/components/plex/media_player.py +++ b/homeassistant/components/plex/media_player.py @@ -11,6 +11,7 @@ from homeassistant.components.media_player.const import ( MEDIA_TYPE_MUSIC, MEDIA_TYPE_TVSHOW, MEDIA_TYPE_VIDEO, + SUPPORT_BROWSE_MEDIA, SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PLAY, @@ -36,8 +37,16 @@ from .const import ( PLEX_UPDATE_MEDIA_PLAYER_SIGNAL, SERVERS, ) +from .media_browser import browse_media LIVE_TV_SECTION = "-4" +PLAYLISTS_BROWSE_PAYLOAD = { + "title": "Playlists", + "media_content_id": "all", + "media_content_type": "playlists", + "can_play": False, + "can_expand": True, +} _LOGGER = logging.getLogger(__name__) @@ -489,9 +498,10 @@ class PlexMediaPlayer(MediaPlayerEntity): | SUPPORT_PLAY | SUPPORT_PLAY_MEDIA | SUPPORT_VOLUME_MUTE + | SUPPORT_BROWSE_MEDIA ) - return SUPPORT_PLAY_MEDIA + return SUPPORT_BROWSE_MEDIA | SUPPORT_PLAY_MEDIA def set_volume_level(self, volume): """Set volume level, range 0..1.""" @@ -611,3 +621,13 @@ class PlexMediaPlayer(MediaPlayerEntity): "sw_version": self._device_version, "via_device": (PLEX_DOMAIN, self.plex_server.machine_identifier), } + + 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.entity_id, + self.plex_server, + media_content_type, + media_content_id, + ) diff --git a/homeassistant/components/plex/server.py b/homeassistant/components/plex/server.py index 146291bdbcf..a1bf56eb54f 100644 --- a/homeassistant/components/plex/server.py +++ b/homeassistant/components/plex/server.py @@ -31,7 +31,6 @@ from .const import ( CONF_USE_EPISODE_ART, DEBOUNCE_TIMEOUT, DEFAULT_VERIFY_SSL, - DOMAIN, PLAYER_SOURCE, PLEX_NEW_MP_SIGNAL, PLEX_UPDATE_MEDIA_PLAYER_SIGNAL, @@ -449,6 +448,10 @@ class PlexServer: """Return playlist from server object.""" return self._plex_server.playlist(title) + def playlists(self): + """Return available playlists from server object.""" + return self._plex_server.playlists() + def create_playqueue(self, media, **kwargs): """Create playqueue on Plex server.""" return plexapi.playqueue.PlayQueue.create(self._plex_server, media, **kwargs) @@ -461,7 +464,7 @@ class PlexServer: """Lookup a piece of media.""" media_type = media_type.lower() - if media_type == DOMAIN: + if isinstance(kwargs.get("plex_key"), int): key = kwargs["plex_key"] try: return self.fetch_item(key) diff --git a/homeassistant/components/websocket_api/const.py b/homeassistant/components/websocket_api/const.py index 121ea7496de..f01a2880b9d 100644 --- a/homeassistant/components/websocket_api/const.py +++ b/homeassistant/components/websocket_api/const.py @@ -23,6 +23,7 @@ MAX_PENDING_MSG = 2048 ERR_ID_REUSE = "id_reuse" ERR_INVALID_FORMAT = "invalid_format" ERR_NOT_FOUND = "not_found" +ERR_NOT_SUPPORTED = "not_supported" ERR_HOME_ASSISTANT_ERROR = "home_assistant_error" ERR_UNKNOWN_COMMAND = "unknown_command" ERR_UNKNOWN_ERROR = "unknown_error" diff --git a/tests/components/media_player/test_init.py b/tests/components/media_player/test_init.py index d5eac466093..02012a1f71d 100644 --- a/tests/components/media_player/test_init.py +++ b/tests/components/media_player/test_init.py @@ -100,3 +100,60 @@ def test_deprecated_base_class(caplog): CustomMediaPlayer() assert "MediaPlayerDevice is deprecated, modify CustomMediaPlayer" in caplog.text + + +async def test_media_browse(hass, hass_ws_client): + """Test browsing media.""" + await async_setup_component( + hass, "media_player", {"media_player": {"platform": "demo"}} + ) + await hass.async_block_till_done() + + client = await hass_ws_client(hass) + + with patch( + "homeassistant.components.demo.media_player.YOUTUBE_PLAYER_SUPPORT", + media_player.SUPPORT_BROWSE_MEDIA, + ), patch( + "homeassistant.components.media_player.MediaPlayerEntity." "async_browse_media", + return_value={"bla": "yo"}, + ) as mock_browse_media: + await client.send_json( + { + "id": 5, + "type": "media_player/browse_media", + "entity_id": "media_player.bedroom", + "media_content_type": "album", + "media_content_id": "abcd", + } + ) + + msg = await client.receive_json() + + assert msg["id"] == 5 + assert msg["type"] == TYPE_RESULT + assert msg["success"] + assert msg["result"] == {"bla": "yo"} + assert mock_browse_media.mock_calls[0][1] == ("album", "abcd") + + with patch( + "homeassistant.components.demo.media_player.YOUTUBE_PLAYER_SUPPORT", + media_player.SUPPORT_BROWSE_MEDIA, + ), patch( + "homeassistant.components.media_player.MediaPlayerEntity." "async_browse_media", + return_value={"bla": "yo"}, + ): + await client.send_json( + { + "id": 6, + "type": "media_player/browse_media", + "entity_id": "media_player.bedroom", + } + ) + + msg = await client.receive_json() + + assert msg["id"] == 6 + assert msg["type"] == TYPE_RESULT + assert msg["success"] + assert msg["result"] == {"bla": "yo"}