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:
parent
3df67ff9e1
commit
6d95ee7a00
8 changed files with 323 additions and 17 deletions
|
@ -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)."""
|
||||
|
||||
|
|
|
@ -63,3 +63,4 @@ SUPPORT_CLEAR_PLAYLIST = 8192
|
|||
SUPPORT_PLAY = 16384
|
||||
SUPPORT_SHUFFLE_SET = 32768
|
||||
SUPPORT_SELECT_SOUND_MODE = 65536
|
||||
SUPPORT_BROWSE_MEDIA = 131072
|
||||
|
|
10
homeassistant/components/media_player/errors.py
Normal file
10
homeassistant/components/media_player/errors.py
Normal 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."""
|
125
homeassistant/components/plex/media_browser.py
Normal file
125
homeassistant/components/plex/media_browser.py
Normal 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
|
|
@ -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,
|
||||
)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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"}
|
||||
|
|
Loading…
Add table
Reference in a new issue