Websocket media browsing for Plex (#35590)

Co-authored-by: Paulus Schoutsen <paulus@home-assistant.io>
Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
This commit is contained in:
jjlawren 2020-08-24 05:41:01 -05:00 committed by GitHub
parent 3df67ff9e1
commit 6d95ee7a00
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 323 additions and 17 deletions

View file

@ -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) - [{<item_with_keys_above>}, ...]
}
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)."""

View file

@ -63,3 +63,4 @@ SUPPORT_CLEAR_PLAYLIST = 8192
SUPPORT_PLAY = 16384
SUPPORT_SHUFFLE_SET = 32768
SUPPORT_SELECT_SOUND_MODE = 65536
SUPPORT_BROWSE_MEDIA = 131072

View file

@ -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."""

View file

@ -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

View file

@ -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,
)

View file

@ -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)

View file

@ -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"

View file

@ -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"}