From e17f1ea5770c2d36ed86295e1d4cd60bcb5f5e07 Mon Sep 17 00:00:00 2001 From: jjlawren Date: Wed, 26 Jan 2022 12:40:47 -0600 Subject: [PATCH] Support Plex in Sonos media browser (#64951) Co-authored-by: Paulus Schoutsen --- homeassistant/components/plex/__init__.py | 28 ++++++- .../components/plex/media_browser.py | 83 +++++++++---------- homeassistant/components/plex/media_player.py | 6 +- .../components/sonos/media_browser.py | 26 +++++- .../components/sonos/media_player.py | 5 +- tests/components/plex/test_browse_media.py | 49 +++++------ 6 files changed, 118 insertions(+), 79 deletions(-) diff --git a/homeassistant/components/plex/__init__.py b/homeassistant/components/plex/__init__.py index d5227322b24..41b7f0d0afd 100644 --- a/homeassistant/components/plex/__init__.py +++ b/homeassistant/components/plex/__init__.py @@ -13,7 +13,7 @@ from plexwebsocket import ( ) import requests.exceptions -from homeassistant.components.media_player import DOMAIN as MP_DOMAIN +from homeassistant.components.media_player import DOMAIN as MP_DOMAIN, BrowseError from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import CONF_URL, CONF_VERIFY_SSL, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant, callback @@ -25,6 +25,7 @@ from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, ) +from homeassistant.helpers.network import is_internal_request from homeassistant.helpers.typing import ConfigType from .const import ( @@ -39,16 +40,41 @@ from .const import ( PLEX_SERVER_CONFIG, PLEX_UPDATE_LIBRARY_SIGNAL, PLEX_UPDATE_PLATFORMS_SIGNAL, + PLEX_URI_SCHEME, SERVERS, WEBSOCKETS, ) from .errors import ShouldUpdateConfigEntry +from .media_browser import browse_media from .server import PlexServer from .services import async_setup_services _LOGGER = logging.getLogger(__package__) +def is_plex_media_id(media_content_id): + """Return whether the media_content_id is a valid Plex media_id.""" + return media_content_id and media_content_id.startswith(PLEX_URI_SCHEME) + + +async def async_browse_media(hass, media_content_type, media_content_id, platform=None): + """Browse Plex media.""" + plex_server = next(iter(hass.data[PLEX_DOMAIN][SERVERS].values()), None) + if not plex_server: + raise BrowseError("No Plex servers available") + is_internal = is_internal_request(hass) + return await hass.async_add_executor_job( + partial( + browse_media, + plex_server, + is_internal, + media_content_type, + media_content_id, + platform=platform, + ) + ) + + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Plex component.""" hass.data.setdefault( diff --git a/homeassistant/components/plex/media_browser.py b/homeassistant/components/plex/media_browser.py index 8d2baeb493d..0bff5cfb5cd 100644 --- a/homeassistant/components/plex/media_browser.py +++ b/homeassistant/components/plex/media_browser.py @@ -16,7 +16,7 @@ from homeassistant.components.media_player.const import ( ) from homeassistant.components.media_player.errors import BrowseError -from .const import DOMAIN +from .const import DOMAIN, PLEX_URI_SCHEME from .helpers import pretty_title @@ -29,7 +29,7 @@ EXPANDABLES = ["album", "artist", "playlist", "season", "show"] PLAYLISTS_BROWSE_PAYLOAD = { "title": "Playlists", "media_class": MEDIA_CLASS_DIRECTORY, - "media_content_id": "all", + "media_content_id": PLEX_URI_SCHEME + "all", "media_content_type": "playlists", "can_play": False, "can_expand": True, @@ -53,7 +53,7 @@ _LOGGER = logging.getLogger(__name__) def browse_media( # noqa: C901 - entity, is_internal, media_content_type=None, media_content_id=None + plex_server, is_internal, media_content_type, media_content_id, *, platform=None ): """Implement the websocket media browsing helper.""" @@ -67,28 +67,19 @@ def browse_media( # noqa: C901 payload = { "title": pretty_title(item, short_name), "media_class": media_class, - "media_content_id": str(item.ratingKey), + "media_content_id": PLEX_URI_SCHEME + str(item.ratingKey), "media_content_type": item.type, "can_play": True, "can_expand": item.type in EXPANDABLES, } if hasattr(item, "thumbUrl"): - entity.plex_server.thumbnail_cache.setdefault( - str(item.ratingKey), item.thumbUrl - ) - - if is_internal: - thumbnail = item.thumbUrl - else: - thumbnail = entity.get_browse_image_url(item.type, item.ratingKey) - - payload["thumbnail"] = thumbnail + payload["thumbnail"] = item.thumbUrl return BrowseMedia(**payload) def library_payload(library_id): """Create response payload to describe contents of a specific library.""" - library = entity.plex_server.library.sectionByID(library_id) + library = plex_server.library.sectionByID(library_id) library_info = library_section_payload(library) library_info.children = [special_library_payload(library_info, "Recommended")] for item in library.all(): @@ -98,10 +89,12 @@ def browse_media( # noqa: C901 continue return library_info - def playlists_payload(): + def playlists_payload(platform): """Create response payload for all available playlists.""" playlists_info = {**PLAYLISTS_BROWSE_PAYLOAD, "children": []} - for playlist in entity.plex_server.playlists(): + for playlist in plex_server.playlists(): + if playlist.playlistType != "audio" and platform == "sonos": + continue try: playlists_info["children"].append(item_payload(playlist)) except UnknownMediaType: @@ -112,7 +105,7 @@ def browse_media( # noqa: C901 def build_item_response(payload): """Create response payload for the provided media query.""" - media = entity.plex_server.lookup_media(**payload) + media = plex_server.lookup_media(**payload) if media is None: return None @@ -123,7 +116,7 @@ def browse_media( # noqa: C901 return None if media_info.can_expand: media_info.children = [] - if media.TYPE == "artist": + if media.TYPE == "artist" and platform != "sonos": if (station := media.station()) is not None: media_info.children.append(station_payload(station)) for item in media: @@ -133,18 +126,22 @@ def browse_media( # noqa: C901 continue return media_info + if media_content_id: + assert media_content_id.startswith(PLEX_URI_SCHEME) + media_content_id = media_content_id[len(PLEX_URI_SCHEME) :] + if media_content_id and media_content_id.startswith(HUB_PREFIX): media_content_id = media_content_id[len(HUB_PREFIX) :] location, hub_identifier = media_content_id.split(":") if location == "server": hub = next( x - for x in entity.plex_server.library.hubs() + for x in plex_server.library.hubs() if x.hubIdentifier == hub_identifier ) media_content_id = f"{HUB_PREFIX}server:{hub.hubIdentifier}" else: - library_section = entity.plex_server.library.sectionByID(int(location)) + library_section = plex_server.library.sectionByID(int(location)) hub = next( x for x in library_section.hubs() if x.hubIdentifier == hub_identifier ) @@ -156,7 +153,7 @@ def browse_media( # noqa: C901 payload = { "title": hub.title, "media_class": MEDIA_CLASS_DIRECTORY, - "media_content_id": media_content_id, + "media_content_id": PLEX_URI_SCHEME + media_content_id, "media_content_type": hub.type, "can_play": False, "can_expand": True, @@ -165,6 +162,8 @@ def browse_media( # noqa: C901 } for item in hub.items: if hub.type == "station": + if platform == "sonos": + continue payload["children"].append(station_payload(item)) else: payload["children"].append(item_payload(item)) @@ -175,24 +174,13 @@ def browse_media( # noqa: C901 else: special_folder = None - if ( - media_content_type - and media_content_type == "server" - and media_content_id != entity.plex_server.machine_identifier - ): - raise BrowseError( - f"Plex server with ID '{media_content_id}' is not associated with {entity.entity_id}" - ) - if special_folder: if media_content_type == "server": - library_or_section = entity.plex_server.library + library_or_section = plex_server.library children_media_class = MEDIA_CLASS_DIRECTORY - title = entity.plex_server.friendly_name + title = plex_server.friendly_name elif media_content_type == "library": - library_or_section = entity.plex_server.library.sectionByID( - int(media_content_id) - ) + library_or_section = plex_server.library.sectionByID(int(media_content_id)) title = library_or_section.title try: children_media_class = ITEM_TYPE_MEDIA_CLASS[library_or_section.TYPE] @@ -208,7 +196,8 @@ def browse_media( # noqa: C901 payload = { "title": title, "media_class": MEDIA_CLASS_DIRECTORY, - "media_content_id": f"{media_content_id}:{special_folder}", + "media_content_id": PLEX_URI_SCHEME + + f"{media_content_id}:{special_folder}", "media_content_type": media_content_type, "can_play": False, "can_expand": True, @@ -226,7 +215,7 @@ def browse_media( # noqa: C901 try: if media_content_type in ("server", None): - return server_payload(entity.plex_server) + return server_payload(plex_server, platform) if media_content_type == "library": return library_payload(int(media_content_id)) @@ -237,7 +226,7 @@ def browse_media( # noqa: C901 ) from err if media_content_type == "playlists": - return playlists_payload() + return playlists_payload(platform) payload = { "media_type": DOMAIN, @@ -259,7 +248,7 @@ def library_section_payload(section): return BrowseMedia( title=section.title, media_class=MEDIA_CLASS_DIRECTORY, - media_content_id=str(section.key), + media_content_id=PLEX_URI_SCHEME + str(section.key), media_content_type="library", can_play=False, can_expand=True, @@ -281,21 +270,25 @@ def special_library_payload(parent_payload, special_type): ) -def server_payload(plex_server): +def server_payload(plex_server, platform): """Create response payload to describe libraries of the Plex server.""" server_info = BrowseMedia( title=plex_server.friendly_name, media_class=MEDIA_CLASS_DIRECTORY, - media_content_id=plex_server.machine_identifier, + media_content_id=PLEX_URI_SCHEME + plex_server.machine_identifier, media_content_type="server", can_play=False, can_expand=True, + children=[], children_media_class=MEDIA_CLASS_DIRECTORY, ) - server_info.children = [special_library_payload(server_info, "Recommended")] + if platform != "sonos": + server_info.children.append(special_library_payload(server_info, "Recommended")) for library in plex_server.library.sections(): if library.type == "photo": continue + if library.type != "artist" and platform == "sonos": + continue server_info.children.append(library_section_payload(library)) server_info.children.append(BrowseMedia(**PLAYLISTS_BROWSE_PAYLOAD)) return server_info @@ -310,7 +303,7 @@ def hub_payload(hub): payload = { "title": hub.title, "media_class": MEDIA_CLASS_DIRECTORY, - "media_content_id": media_content_id, + "media_content_id": PLEX_URI_SCHEME + media_content_id, "media_content_type": hub.type, "can_play": False, "can_expand": True, @@ -323,7 +316,7 @@ def station_payload(station): return BrowseMedia( title=station.title, media_class=ITEM_TYPE_MEDIA_CLASS[station.type], - media_content_id=station.key, + media_content_id=PLEX_URI_SCHEME + station.key, media_content_type="station", can_play=True, can_expand=False, diff --git a/homeassistant/components/plex/media_player.py b/homeassistant/components/plex/media_player.py index 6ee09bfcfb4..1ff58ed468d 100644 --- a/homeassistant/components/plex/media_player.py +++ b/homeassistant/components/plex/media_player.py @@ -46,6 +46,7 @@ from .const import ( PLEX_UPDATE_MEDIA_PLAYER_SESSION_SIGNAL, PLEX_UPDATE_MEDIA_PLAYER_SIGNAL, PLEX_UPDATE_SENSOR_SIGNAL, + PLEX_URI_SCHEME, SERVERS, TRANSIENT_DEVICE_MODELS, ) @@ -486,6 +487,9 @@ class PlexMediaPlayer(MediaPlayerEntity): "Plex integration configured without a token, playback may fail" ) + if media_id.startswith(PLEX_URI_SCHEME): + media_id = media_id[len(PLEX_URI_SCHEME) :] + if media_type == "station": playqueue = self.plex_server.create_station_playqueue(media_id) try: @@ -576,7 +580,7 @@ class PlexMediaPlayer(MediaPlayerEntity): is_internal = is_internal_request(self.hass) return await self.hass.async_add_executor_job( browse_media, - self, + self.plex_server, is_internal, media_content_type, media_content_id, diff --git a/homeassistant/components/sonos/media_browser.py b/homeassistant/components/sonos/media_browser.py index 2887884c274..93b3ddd034d 100644 --- a/homeassistant/components/sonos/media_browser.py +++ b/homeassistant/components/sonos/media_browser.py @@ -7,7 +7,7 @@ from functools import partial import logging from urllib.parse import quote_plus, unquote -from homeassistant.components import media_source, spotify +from homeassistant.components import media_source, plex, spotify from homeassistant.components.media_player import BrowseMedia from homeassistant.components.media_player.const import ( MEDIA_CLASS_DIRECTORY, @@ -18,6 +18,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.network import is_internal_request from .const import ( + DOMAIN, EXPANDABLE_MEDIA_TYPES, LIBRARY_TITLES_MAPPING, MEDIA_TYPES_TO_SONOS, @@ -90,6 +91,14 @@ async def async_browse_media( hass, media_content_id, content_filter=media_source_filter ) + if plex.is_plex_media_id(media_content_id): + return await plex.async_browse_media( + hass, media_content_type, media_content_id, platform=DOMAIN + ) + + if media_content_type == "plex": + return await plex.async_browse_media(hass, None, None, platform=DOMAIN) + if spotify.is_spotify_media_type(media_content_type): return await spotify.async_browse_media( hass, media_content_type, media_content_id, can_play_artist=False @@ -256,6 +265,19 @@ async def root_payload( ) ) + if "plex" in hass.config.components: + children.append( + BrowseMedia( + title="Plex", + media_class=MEDIA_CLASS_DIRECTORY, + media_content_id="", + media_content_type="plex", + thumbnail="https://brands.home-assistant.io/_/plex/logo.png", + can_play=False, + can_expand=True, + ) + ) + if "spotify" in hass.config.components: children.append( BrowseMedia( @@ -263,7 +285,7 @@ async def root_payload( media_class=MEDIA_CLASS_DIRECTORY, media_content_id="", media_content_type="spotify", - thumbnail="https://brands.home-assistant.io/spotify/logo.png", + thumbnail="https://brands.home-assistant.io/_/spotify/logo.png", can_play=False, can_expand=True, ) diff --git a/homeassistant/components/sonos/media_player.py b/homeassistant/components/sonos/media_player.py index 34574c95b8f..d490120faf8 100644 --- a/homeassistant/components/sonos/media_player.py +++ b/homeassistant/components/sonos/media_player.py @@ -546,7 +546,10 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): plex_plugin = self.speaker.plex_plugin media_id = media_id[len(PLEX_URI_SCHEME) :] payload = json.loads(media_id) - shuffle = payload.pop("shuffle", None) + if isinstance(payload, dict): + shuffle = payload.pop("shuffle", False) + else: + shuffle = False media = lookup_plex_media(self.hass, media_type, json.dumps(payload)) if not kwargs.get(ATTR_MEDIA_ENQUEUE): soco.clear_queue() diff --git a/tests/components/plex/test_browse_media.py b/tests/components/plex/test_browse_media.py index d14689f9c0f..502084c2090 100644 --- a/tests/components/plex/test_browse_media.py +++ b/tests/components/plex/test_browse_media.py @@ -6,7 +6,7 @@ from homeassistant.components.media_player.const import ( ATTR_MEDIA_CONTENT_ID, ATTR_MEDIA_CONTENT_TYPE, ) -from homeassistant.components.plex.const import CONF_SERVER_IDENTIFIER +from homeassistant.components.plex.const import CONF_SERVER_IDENTIFIER, PLEX_URI_SCHEME from homeassistant.components.websocket_api.const import ERR_UNKNOWN_ERROR, TYPE_RESULT from .const import DEFAULT_DATA @@ -120,23 +120,6 @@ async def test_browse_media( media_players = hass.states.async_entity_ids("media_player") msg_id = 1 - # Browse base of non-existent Plex server - await websocket_client.send_json( - { - "id": msg_id, - "type": "media_player/browse_media", - "entity_id": media_players[0], - ATTR_MEDIA_CONTENT_TYPE: "server", - ATTR_MEDIA_CONTENT_ID: "this server does not exist", - } - ) - - msg = await websocket_client.receive_json() - assert msg["id"] == msg_id - assert msg["type"] == TYPE_RESULT - assert not msg["success"] - assert msg["error"]["code"] == ERR_UNKNOWN_ERROR - # Browse base of Plex server msg_id += 1 await websocket_client.send_json( @@ -153,7 +136,10 @@ async def test_browse_media( assert msg["success"] result = msg["result"] assert result[ATTR_MEDIA_CONTENT_TYPE] == "server" - assert result[ATTR_MEDIA_CONTENT_ID] == DEFAULT_DATA[CONF_SERVER_IDENTIFIER] + assert ( + result[ATTR_MEDIA_CONTENT_ID] + == PLEX_URI_SCHEME + DEFAULT_DATA[CONF_SERVER_IDENTIFIER] + ) # Library Sections + Recommended + Playlists assert len(result["children"]) == len(mock_plex_server.library.sections()) + 2 @@ -175,7 +161,8 @@ async def test_browse_media( "type": "media_player/browse_media", "entity_id": media_players[0], ATTR_MEDIA_CONTENT_TYPE: "server", - ATTR_MEDIA_CONTENT_ID: f"{DEFAULT_DATA[CONF_SERVER_IDENTIFIER]}:{special_keys[0]}", + ATTR_MEDIA_CONTENT_ID: PLEX_URI_SCHEME + + f"{DEFAULT_DATA[CONF_SERVER_IDENTIFIER]}:{special_keys[0]}", } ) @@ -187,7 +174,7 @@ async def test_browse_media( assert result[ATTR_MEDIA_CONTENT_TYPE] == "server" assert ( result[ATTR_MEDIA_CONTENT_ID] - == f"{DEFAULT_DATA[CONF_SERVER_IDENTIFIER]}:{special_keys[0]}" + == PLEX_URI_SCHEME + f"{DEFAULT_DATA[CONF_SERVER_IDENTIFIER]}:{special_keys[0]}" ) assert len(result["children"]) == 4 # Hardcoded in fixture assert result["children"][0]["media_content_type"] == "mixed" @@ -228,7 +215,8 @@ async def test_browse_media( "type": "media_player/browse_media", "entity_id": media_players[0], ATTR_MEDIA_CONTENT_TYPE: "library", - ATTR_MEDIA_CONTENT_ID: f"{library_section_id}:{special_keys[0]}", + ATTR_MEDIA_CONTENT_ID: PLEX_URI_SCHEME + + f"{library_section_id}:{special_keys[0]}", } ) @@ -238,7 +226,10 @@ async def test_browse_media( assert msg["success"] result = msg["result"] assert result[ATTR_MEDIA_CONTENT_TYPE] == "library" - assert result[ATTR_MEDIA_CONTENT_ID] == f"{library_section_id}:{special_keys[0]}" + assert ( + result[ATTR_MEDIA_CONTENT_ID] + == PLEX_URI_SCHEME + f"{library_section_id}:{special_keys[0]}" + ) assert len(result["children"]) == 1 # Browse into a library radio station hub @@ -280,7 +271,7 @@ async def test_browse_media( assert msg["success"] result = msg["result"] assert result[ATTR_MEDIA_CONTENT_TYPE] == "library" - result_id = int(result[ATTR_MEDIA_CONTENT_ID]) + result_id = int(result[ATTR_MEDIA_CONTENT_ID][len(PLEX_URI_SCHEME) :]) # All items in section + Hubs assert ( len(result["children"]) @@ -314,7 +305,7 @@ async def test_browse_media( assert msg["success"] result = msg["result"] assert result[ATTR_MEDIA_CONTENT_TYPE] == "show" - result_id = int(result[ATTR_MEDIA_CONTENT_ID]) + result_id = int(result[ATTR_MEDIA_CONTENT_ID][len(PLEX_URI_SCHEME) :]) assert result["title"] == mock_plex_server.fetch_item(result_id).title assert result["children"][0]["title"] == f"{mock_season.title} ({mock_season.year})" @@ -344,7 +335,7 @@ async def test_browse_media( assert msg["success"] result = msg["result"] assert result[ATTR_MEDIA_CONTENT_TYPE] == "season" - result_id = int(result[ATTR_MEDIA_CONTENT_ID]) + result_id = int(result[ATTR_MEDIA_CONTENT_ID][len(PLEX_URI_SCHEME) :]) assert ( result["title"] == f"{mock_season.parentTitle} - {mock_season.title} ({mock_season.year})" @@ -369,7 +360,7 @@ async def test_browse_media( assert msg["success"] result = msg["result"] - result_id = int(result[ATTR_MEDIA_CONTENT_ID]) + result_id = int(result[ATTR_MEDIA_CONTENT_ID][len(PLEX_URI_SCHEME) :]) assert result[ATTR_MEDIA_CONTENT_TYPE] == "library" assert result["title"] == "Music" @@ -399,7 +390,7 @@ async def test_browse_media( assert mock_fetch.called assert msg["success"] result = msg["result"] - result_id = int(result[ATTR_MEDIA_CONTENT_ID]) + result_id = int(result[ATTR_MEDIA_CONTENT_ID][len(PLEX_URI_SCHEME) :]) assert result[ATTR_MEDIA_CONTENT_TYPE] == "artist" assert result["title"] == mock_artist.title assert result["children"][0]["title"] == "Radio Station" @@ -420,7 +411,7 @@ async def test_browse_media( assert msg["success"] result = msg["result"] - result_id = int(result[ATTR_MEDIA_CONTENT_ID]) + result_id = int(result[ATTR_MEDIA_CONTENT_ID][len(PLEX_URI_SCHEME) :]) assert result[ATTR_MEDIA_CONTENT_TYPE] == "album" assert ( result["title"]