mirror of
https://github.com/home-assistant/core.git
synced 2025-07-21 04:07:08 +00:00
Plex media browser improvements (#56312)
This commit is contained in:
parent
ef13e473cf
commit
8f4ba564d4
24
homeassistant/components/plex/helpers.py
Normal file
24
homeassistant/components/plex/helpers.py
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
"""Helper methods for common Plex integration operations."""
|
||||||
|
|
||||||
|
|
||||||
|
def pretty_title(media, short_name=False):
|
||||||
|
"""Return a formatted title for the given media item."""
|
||||||
|
year = None
|
||||||
|
if media.type == "album":
|
||||||
|
title = f"{media.parentTitle} - {media.title}"
|
||||||
|
elif media.type == "episode":
|
||||||
|
title = f"{media.seasonEpisode.upper()} - {media.title}"
|
||||||
|
if not short_name:
|
||||||
|
title = f"{media.grandparentTitle} - {title}"
|
||||||
|
elif media.type == "track":
|
||||||
|
title = f"{media.index}. {media.title}"
|
||||||
|
else:
|
||||||
|
title = media.title
|
||||||
|
|
||||||
|
if media.type in ["album", "movie", "season"]:
|
||||||
|
year = media.year
|
||||||
|
|
||||||
|
if year:
|
||||||
|
title += f" ({year!s})"
|
||||||
|
|
||||||
|
return title
|
@ -1,4 +1,5 @@
|
|||||||
"""Support to interface with the Plex API."""
|
"""Support to interface with the Plex API."""
|
||||||
|
from itertools import islice
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from homeassistant.components.media_player import BrowseMedia
|
from homeassistant.components.media_player import BrowseMedia
|
||||||
@ -17,6 +18,7 @@ from homeassistant.components.media_player.const import (
|
|||||||
from homeassistant.components.media_player.errors import BrowseError
|
from homeassistant.components.media_player.errors import BrowseError
|
||||||
|
|
||||||
from .const import DOMAIN
|
from .const import DOMAIN
|
||||||
|
from .helpers import pretty_title
|
||||||
|
|
||||||
|
|
||||||
class UnknownMediaType(BrowseError):
|
class UnknownMediaType(BrowseError):
|
||||||
@ -32,9 +34,10 @@ PLAYLISTS_BROWSE_PAYLOAD = {
|
|||||||
"can_play": False,
|
"can_play": False,
|
||||||
"can_expand": True,
|
"can_expand": True,
|
||||||
}
|
}
|
||||||
SPECIAL_METHODS = {
|
|
||||||
"On Deck": "onDeck",
|
LIBRARY_PREFERRED_LIBTYPE = {
|
||||||
"Recently Added": "recentlyAdded",
|
"show": "episode",
|
||||||
|
"artist": "album",
|
||||||
}
|
}
|
||||||
|
|
||||||
ITEM_TYPE_MEDIA_CLASS = {
|
ITEM_TYPE_MEDIA_CLASS = {
|
||||||
@ -57,7 +60,7 @@ def browse_media( # noqa: C901
|
|||||||
):
|
):
|
||||||
"""Implement the websocket media browsing helper."""
|
"""Implement the websocket media browsing helper."""
|
||||||
|
|
||||||
def item_payload(item):
|
def item_payload(item, short_name=False):
|
||||||
"""Create response payload for a single media item."""
|
"""Create response payload for a single media item."""
|
||||||
try:
|
try:
|
||||||
media_class = ITEM_TYPE_MEDIA_CLASS[item.type]
|
media_class = ITEM_TYPE_MEDIA_CLASS[item.type]
|
||||||
@ -65,7 +68,7 @@ def browse_media( # noqa: C901
|
|||||||
_LOGGER.debug("Unknown type received: %s", item.type)
|
_LOGGER.debug("Unknown type received: %s", item.type)
|
||||||
raise UnknownMediaType from err
|
raise UnknownMediaType from err
|
||||||
payload = {
|
payload = {
|
||||||
"title": item.title,
|
"title": pretty_title(item, short_name),
|
||||||
"media_class": media_class,
|
"media_class": media_class,
|
||||||
"media_content_id": str(item.ratingKey),
|
"media_content_id": str(item.ratingKey),
|
||||||
"media_content_type": item.type,
|
"media_content_type": item.type,
|
||||||
@ -129,7 +132,7 @@ def browse_media( # noqa: C901
|
|||||||
media_info.children = []
|
media_info.children = []
|
||||||
for item in media:
|
for item in media:
|
||||||
try:
|
try:
|
||||||
media_info.children.append(item_payload(item))
|
media_info.children.append(item_payload(item, short_name=True))
|
||||||
except UnknownMediaType:
|
except UnknownMediaType:
|
||||||
continue
|
continue
|
||||||
return media_info
|
return media_info
|
||||||
@ -180,8 +183,22 @@ def browse_media( # noqa: C901
|
|||||||
"children_media_class": children_media_class,
|
"children_media_class": children_media_class,
|
||||||
}
|
}
|
||||||
|
|
||||||
method = SPECIAL_METHODS[special_folder]
|
if special_folder == "On Deck":
|
||||||
items = getattr(library_or_section, method)()
|
items = library_or_section.onDeck()
|
||||||
|
elif special_folder == "Recently Added":
|
||||||
|
if library_or_section.TYPE:
|
||||||
|
libtype = LIBRARY_PREFERRED_LIBTYPE.get(
|
||||||
|
library_or_section.TYPE, library_or_section.TYPE
|
||||||
|
)
|
||||||
|
items = library_or_section.recentlyAdded(libtype=libtype)
|
||||||
|
else:
|
||||||
|
recent_iter = (
|
||||||
|
x
|
||||||
|
for x in library_or_section.search(sort="addedAt:desc", limit=100)
|
||||||
|
if x.type in ["album", "episode", "movie"]
|
||||||
|
)
|
||||||
|
items = list(islice(recent_iter, 30))
|
||||||
|
|
||||||
for item in items:
|
for item in items:
|
||||||
try:
|
try:
|
||||||
payload["children"].append(item_payload(item))
|
payload["children"].append(item_payload(item))
|
||||||
|
@ -1,15 +1,74 @@
|
|||||||
"""Tests for Plex media browser."""
|
"""Tests for Plex media browser."""
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
from homeassistant.components.media_player.const import (
|
from homeassistant.components.media_player.const import (
|
||||||
ATTR_MEDIA_CONTENT_ID,
|
ATTR_MEDIA_CONTENT_ID,
|
||||||
ATTR_MEDIA_CONTENT_TYPE,
|
ATTR_MEDIA_CONTENT_TYPE,
|
||||||
)
|
)
|
||||||
from homeassistant.components.plex.const import CONF_SERVER_IDENTIFIER
|
from homeassistant.components.plex.const import CONF_SERVER_IDENTIFIER
|
||||||
from homeassistant.components.plex.media_browser import SPECIAL_METHODS
|
|
||||||
from homeassistant.components.websocket_api.const import ERR_UNKNOWN_ERROR, TYPE_RESULT
|
from homeassistant.components.websocket_api.const import ERR_UNKNOWN_ERROR, TYPE_RESULT
|
||||||
|
|
||||||
from .const import DEFAULT_DATA
|
from .const import DEFAULT_DATA
|
||||||
|
|
||||||
|
|
||||||
|
class MockPlexShow:
|
||||||
|
"""Mock a plexapi Season instance."""
|
||||||
|
|
||||||
|
ratingKey = 30
|
||||||
|
title = "TV Show"
|
||||||
|
type = "show"
|
||||||
|
|
||||||
|
def __iter__(self):
|
||||||
|
"""Iterate over episodes."""
|
||||||
|
yield MockPlexSeason()
|
||||||
|
|
||||||
|
|
||||||
|
class MockPlexSeason:
|
||||||
|
"""Mock a plexapi Season instance."""
|
||||||
|
|
||||||
|
ratingKey = 20
|
||||||
|
title = "Season 1"
|
||||||
|
type = "season"
|
||||||
|
year = 2021
|
||||||
|
|
||||||
|
def __iter__(self):
|
||||||
|
"""Iterate over episodes."""
|
||||||
|
yield MockPlexEpisode()
|
||||||
|
|
||||||
|
|
||||||
|
class MockPlexEpisode:
|
||||||
|
"""Mock a plexapi Episode instance."""
|
||||||
|
|
||||||
|
ratingKey = 10
|
||||||
|
title = "Episode 1"
|
||||||
|
grandparentTitle = "TV Show"
|
||||||
|
seasonEpisode = "s01e01"
|
||||||
|
type = "episode"
|
||||||
|
|
||||||
|
|
||||||
|
class MockPlexAlbum:
|
||||||
|
"""Mock a plexapi Album instance."""
|
||||||
|
|
||||||
|
ratingKey = 200
|
||||||
|
parentTitle = "Artist"
|
||||||
|
title = "Album"
|
||||||
|
type = "album"
|
||||||
|
year = 2001
|
||||||
|
|
||||||
|
def __iter__(self):
|
||||||
|
"""Iterate over tracks."""
|
||||||
|
yield MockPlexTrack()
|
||||||
|
|
||||||
|
|
||||||
|
class MockPlexTrack:
|
||||||
|
"""Mock a plexapi Track instance."""
|
||||||
|
|
||||||
|
index = 1
|
||||||
|
ratingKey = 100
|
||||||
|
title = "Track 1"
|
||||||
|
type = "track"
|
||||||
|
|
||||||
|
|
||||||
async def test_browse_media(
|
async def test_browse_media(
|
||||||
hass,
|
hass,
|
||||||
hass_ws_client,
|
hass_ws_client,
|
||||||
@ -58,15 +117,13 @@ async def test_browse_media(
|
|||||||
result = msg["result"]
|
result = msg["result"]
|
||||||
assert result[ATTR_MEDIA_CONTENT_TYPE] == "server"
|
assert result[ATTR_MEDIA_CONTENT_TYPE] == "server"
|
||||||
assert result[ATTR_MEDIA_CONTENT_ID] == DEFAULT_DATA[CONF_SERVER_IDENTIFIER]
|
assert result[ATTR_MEDIA_CONTENT_ID] == DEFAULT_DATA[CONF_SERVER_IDENTIFIER]
|
||||||
# Library Sections + Special Sections + Playlists
|
# Library Sections + On Deck + Recently Added + Playlists
|
||||||
assert (
|
assert len(result["children"]) == len(mock_plex_server.library.sections()) + 3
|
||||||
len(result["children"])
|
|
||||||
== len(mock_plex_server.library.sections()) + len(SPECIAL_METHODS) + 1
|
|
||||||
)
|
|
||||||
|
|
||||||
|
music = next(iter(x for x in result["children"] if x["title"] == "Music"))
|
||||||
tvshows = next(iter(x for x in result["children"] if x["title"] == "TV Shows"))
|
tvshows = next(iter(x for x in result["children"] if x["title"] == "TV Shows"))
|
||||||
playlists = next(iter(x for x in result["children"] if x["title"] == "Playlists"))
|
playlists = next(iter(x for x in result["children"] if x["title"] == "Playlists"))
|
||||||
special_keys = list(SPECIAL_METHODS.keys())
|
special_keys = ["On Deck", "Recently Added"]
|
||||||
|
|
||||||
# Browse into a special folder (server)
|
# Browse into a special folder (server)
|
||||||
msg_id += 1
|
msg_id += 1
|
||||||
@ -144,23 +201,34 @@ async def test_browse_media(
|
|||||||
result = msg["result"]
|
result = msg["result"]
|
||||||
assert result[ATTR_MEDIA_CONTENT_TYPE] == "library"
|
assert result[ATTR_MEDIA_CONTENT_TYPE] == "library"
|
||||||
result_id = int(result[ATTR_MEDIA_CONTENT_ID])
|
result_id = int(result[ATTR_MEDIA_CONTENT_ID])
|
||||||
assert len(result["children"]) == len(
|
# All items in section + On Deck + Recently Added
|
||||||
mock_plex_server.library.sectionByID(result_id).all()
|
assert (
|
||||||
) + len(SPECIAL_METHODS)
|
len(result["children"])
|
||||||
|
== len(mock_plex_server.library.sectionByID(result_id).all()) + 2
|
||||||
|
)
|
||||||
|
|
||||||
# Browse into a Plex TV show
|
# Browse into a Plex TV show
|
||||||
msg_id += 1
|
msg_id += 1
|
||||||
|
mock_show = MockPlexShow()
|
||||||
|
mock_season = next(iter(mock_show))
|
||||||
|
with patch.object(
|
||||||
|
mock_plex_server, "fetch_item", return_value=mock_show
|
||||||
|
) as mock_fetch:
|
||||||
await websocket_client.send_json(
|
await websocket_client.send_json(
|
||||||
{
|
{
|
||||||
"id": msg_id,
|
"id": msg_id,
|
||||||
"type": "media_player/browse_media",
|
"type": "media_player/browse_media",
|
||||||
"entity_id": media_players[0],
|
"entity_id": media_players[0],
|
||||||
ATTR_MEDIA_CONTENT_TYPE: result["children"][-1][ATTR_MEDIA_CONTENT_TYPE],
|
ATTR_MEDIA_CONTENT_TYPE: result["children"][-1][
|
||||||
ATTR_MEDIA_CONTENT_ID: str(result["children"][-1][ATTR_MEDIA_CONTENT_ID]),
|
ATTR_MEDIA_CONTENT_TYPE
|
||||||
|
],
|
||||||
|
ATTR_MEDIA_CONTENT_ID: str(
|
||||||
|
result["children"][-1][ATTR_MEDIA_CONTENT_ID]
|
||||||
|
),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
msg = await websocket_client.receive_json()
|
msg = await websocket_client.receive_json()
|
||||||
|
|
||||||
assert msg["id"] == msg_id
|
assert msg["id"] == msg_id
|
||||||
assert msg["type"] == TYPE_RESULT
|
assert msg["type"] == TYPE_RESULT
|
||||||
assert msg["success"]
|
assert msg["success"]
|
||||||
@ -168,6 +236,90 @@ async def test_browse_media(
|
|||||||
assert result[ATTR_MEDIA_CONTENT_TYPE] == "show"
|
assert result[ATTR_MEDIA_CONTENT_TYPE] == "show"
|
||||||
result_id = int(result[ATTR_MEDIA_CONTENT_ID])
|
result_id = int(result[ATTR_MEDIA_CONTENT_ID])
|
||||||
assert result["title"] == mock_plex_server.fetch_item(result_id).title
|
assert result["title"] == mock_plex_server.fetch_item(result_id).title
|
||||||
|
assert result["children"][0]["title"] == f"{mock_season.title} ({mock_season.year})"
|
||||||
|
|
||||||
|
# Browse into a Plex TV show season
|
||||||
|
msg_id += 1
|
||||||
|
mock_episode = next(iter(mock_season))
|
||||||
|
with patch.object(
|
||||||
|
mock_plex_server, "fetch_item", return_value=mock_season
|
||||||
|
) as mock_fetch:
|
||||||
|
await websocket_client.send_json(
|
||||||
|
{
|
||||||
|
"id": msg_id,
|
||||||
|
"type": "media_player/browse_media",
|
||||||
|
"entity_id": media_players[0],
|
||||||
|
ATTR_MEDIA_CONTENT_TYPE: result["children"][0][ATTR_MEDIA_CONTENT_TYPE],
|
||||||
|
ATTR_MEDIA_CONTENT_ID: str(
|
||||||
|
result["children"][0][ATTR_MEDIA_CONTENT_ID]
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
msg = await websocket_client.receive_json()
|
||||||
|
|
||||||
|
assert mock_fetch.called
|
||||||
|
assert msg["id"] == msg_id
|
||||||
|
assert msg["type"] == TYPE_RESULT
|
||||||
|
assert msg["success"]
|
||||||
|
result = msg["result"]
|
||||||
|
assert result[ATTR_MEDIA_CONTENT_TYPE] == "season"
|
||||||
|
result_id = int(result[ATTR_MEDIA_CONTENT_ID])
|
||||||
|
assert result["title"] == f"{mock_season.title} ({mock_season.year})"
|
||||||
|
assert (
|
||||||
|
result["children"][0]["title"]
|
||||||
|
== f"{mock_episode.seasonEpisode.upper()} - {mock_episode.title}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Browse into a Plex music library
|
||||||
|
msg_id += 1
|
||||||
|
await websocket_client.send_json(
|
||||||
|
{
|
||||||
|
"id": msg_id,
|
||||||
|
"type": "media_player/browse_media",
|
||||||
|
"entity_id": media_players[0],
|
||||||
|
ATTR_MEDIA_CONTENT_TYPE: music[ATTR_MEDIA_CONTENT_TYPE],
|
||||||
|
ATTR_MEDIA_CONTENT_ID: str(music[ATTR_MEDIA_CONTENT_ID]),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
msg = await websocket_client.receive_json()
|
||||||
|
|
||||||
|
assert msg["success"]
|
||||||
|
result = msg["result"]
|
||||||
|
result_id = int(result[ATTR_MEDIA_CONTENT_ID])
|
||||||
|
assert result[ATTR_MEDIA_CONTENT_TYPE] == "library"
|
||||||
|
assert result["title"] == "Music"
|
||||||
|
|
||||||
|
# Browse into a Plex album
|
||||||
|
msg_id += 1
|
||||||
|
mock_album = MockPlexAlbum()
|
||||||
|
with patch.object(
|
||||||
|
mock_plex_server, "fetch_item", return_value=mock_album
|
||||||
|
) as mock_fetch:
|
||||||
|
await websocket_client.send_json(
|
||||||
|
{
|
||||||
|
"id": msg_id,
|
||||||
|
"type": "media_player/browse_media",
|
||||||
|
"entity_id": media_players[0],
|
||||||
|
ATTR_MEDIA_CONTENT_TYPE: result["children"][-1][
|
||||||
|
ATTR_MEDIA_CONTENT_TYPE
|
||||||
|
],
|
||||||
|
ATTR_MEDIA_CONTENT_ID: str(
|
||||||
|
result["children"][-1][ATTR_MEDIA_CONTENT_ID]
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
msg = await websocket_client.receive_json()
|
||||||
|
|
||||||
|
assert mock_fetch.called
|
||||||
|
assert msg["success"]
|
||||||
|
result = msg["result"]
|
||||||
|
result_id = int(result[ATTR_MEDIA_CONTENT_ID])
|
||||||
|
assert result[ATTR_MEDIA_CONTENT_TYPE] == "album"
|
||||||
|
assert (
|
||||||
|
result["title"]
|
||||||
|
== f"{mock_album.parentTitle} - {mock_album.title} ({mock_album.year})"
|
||||||
|
)
|
||||||
|
|
||||||
# Browse into a non-existent TV season
|
# Browse into a non-existent TV season
|
||||||
unknown_key = 99999999999999
|
unknown_key = 99999999999999
|
||||||
@ -211,3 +363,26 @@ async def test_browse_media(
|
|||||||
result = msg["result"]
|
result = msg["result"]
|
||||||
assert result[ATTR_MEDIA_CONTENT_TYPE] == "playlists"
|
assert result[ATTR_MEDIA_CONTENT_TYPE] == "playlists"
|
||||||
result_id = result[ATTR_MEDIA_CONTENT_ID]
|
result_id = result[ATTR_MEDIA_CONTENT_ID]
|
||||||
|
|
||||||
|
# Browse recently added items
|
||||||
|
msg_id += 1
|
||||||
|
mock_items = [MockPlexAlbum(), MockPlexEpisode(), MockPlexSeason(), MockPlexTrack()]
|
||||||
|
with patch("plexapi.library.Library.search", return_value=mock_items) as mock_fetch:
|
||||||
|
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: f"{DEFAULT_DATA[CONF_SERVER_IDENTIFIER]}:{special_keys[1]}",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
msg = await websocket_client.receive_json()
|
||||||
|
|
||||||
|
assert msg["success"]
|
||||||
|
result = msg["result"]
|
||||||
|
assert result[ATTR_MEDIA_CONTENT_TYPE] == "server"
|
||||||
|
result_id = result[ATTR_MEDIA_CONTENT_ID]
|
||||||
|
for child in result["children"]:
|
||||||
|
assert child["media_content_type"] in ["album", "episode"]
|
||||||
|
assert child["media_content_type"] not in ["season", "track"]
|
||||||
|
Loading…
x
Reference in New Issue
Block a user