diff --git a/homeassistant/components/plex/helpers.py b/homeassistant/components/plex/helpers.py
index c534eca1f27..6a0f0780e00 100644
--- a/homeassistant/components/plex/helpers.py
+++ b/homeassistant/components/plex/helpers.py
@@ -13,6 +13,10 @@ def pretty_title(media, short_name=False):
title = f"{media.seasonEpisode.upper()} - {media.title}"
if not short_name:
title = f"{media.grandparentTitle} - {title}"
+ elif media.type == "season":
+ title = media.title
+ if not short_name:
+ title = f"{media.parentTitle} - {title}"
elif media.type == "track":
title = f"{media.index}. {media.title}"
else:
diff --git a/homeassistant/components/plex/media_browser.py b/homeassistant/components/plex/media_browser.py
index 6be7462da39..8d2baeb493d 100644
--- a/homeassistant/components/plex/media_browser.py
+++ b/homeassistant/components/plex/media_browser.py
@@ -1,5 +1,4 @@
"""Support to interface with the Plex API."""
-from itertools import islice
import logging
from homeassistant.components.media_player import BrowseMedia
@@ -25,6 +24,7 @@ class UnknownMediaType(BrowseError):
"""Unknown media type."""
+HUB_PREFIX = "hub:"
EXPANDABLES = ["album", "artist", "playlist", "season", "show"]
PLAYLISTS_BROWSE_PAYLOAD = {
"title": "Playlists",
@@ -34,20 +34,17 @@ PLAYLISTS_BROWSE_PAYLOAD = {
"can_play": False,
"can_expand": True,
}
-
-LIBRARY_PREFERRED_LIBTYPE = {
- "show": "episode",
- "artist": "album",
-}
-
ITEM_TYPE_MEDIA_CLASS = {
"album": MEDIA_CLASS_ALBUM,
"artist": MEDIA_CLASS_ARTIST,
+ "clip": MEDIA_CLASS_VIDEO,
"episode": MEDIA_CLASS_EPISODE,
+ "mixed": MEDIA_CLASS_DIRECTORY,
"movie": MEDIA_CLASS_MOVIE,
"playlist": MEDIA_CLASS_PLAYLIST,
"season": MEDIA_CLASS_SEASON,
"show": MEDIA_CLASS_TV_SHOW,
+ "station": MEDIA_CLASS_ARTIST,
"track": MEDIA_CLASS_TRACK,
"video": MEDIA_CLASS_VIDEO,
}
@@ -93,11 +90,7 @@ def browse_media( # noqa: C901
"""Create response payload to describe contents of a specific library."""
library = entity.plex_server.library.sectionByID(library_id)
library_info = library_section_payload(library)
- library_info.children = []
- library_info.children.append(special_library_payload(library_info, "On Deck"))
- library_info.children.append(
- special_library_payload(library_info, "Recently Added")
- )
+ library_info.children = [special_library_payload(library_info, "Recommended")]
for item in library.all():
try:
library_info.children.append(item_payload(item))
@@ -130,6 +123,9 @@ def browse_media( # noqa: C901
return None
if media_info.can_expand:
media_info.children = []
+ if media.TYPE == "artist":
+ if (station := media.station()) is not None:
+ media_info.children.append(station_payload(station))
for item in media:
try:
media_info.children.append(item_payload(item, short_name=True))
@@ -137,6 +133,43 @@ def browse_media( # noqa: C901
continue
return media_info
+ 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()
+ if x.hubIdentifier == hub_identifier
+ )
+ media_content_id = f"{HUB_PREFIX}server:{hub.hubIdentifier}"
+ else:
+ library_section = entity.plex_server.library.sectionByID(int(location))
+ hub = next(
+ x for x in library_section.hubs() if x.hubIdentifier == hub_identifier
+ )
+ media_content_id = f"{HUB_PREFIX}{hub.librarySectionID}:{hub.hubIdentifier}"
+ try:
+ children_media_class = ITEM_TYPE_MEDIA_CLASS[hub.type]
+ except KeyError as err:
+ raise BrowseError(f"Unknown type received: {hub.type}") from err
+ payload = {
+ "title": hub.title,
+ "media_class": MEDIA_CLASS_DIRECTORY,
+ "media_content_id": media_content_id,
+ "media_content_type": hub.type,
+ "can_play": False,
+ "can_expand": True,
+ "children": [],
+ "children_media_class": children_media_class,
+ }
+ for item in hub.items:
+ if hub.type == "station":
+ payload["children"].append(station_payload(item))
+ else:
+ payload["children"].append(item_payload(item))
+ return BrowseMedia(**payload)
+
if media_content_id and ":" in media_content_id:
media_content_id, special_folder = media_content_id.split(":")
else:
@@ -183,27 +216,11 @@ def browse_media( # noqa: C901
"children_media_class": children_media_class,
}
- if special_folder == "On Deck":
- 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:
- try:
- payload["children"].append(item_payload(item))
- except UnknownMediaType:
- continue
+ if special_folder == "Recommended":
+ for item in library_or_section.hubs():
+ if item.type == "photo":
+ continue
+ payload["children"].append(hub_payload(item))
return BrowseMedia(**payload)
@@ -275,12 +292,39 @@ def server_payload(plex_server):
can_expand=True,
children_media_class=MEDIA_CLASS_DIRECTORY,
)
- server_info.children = []
- server_info.children.append(special_library_payload(server_info, "On Deck"))
- server_info.children.append(special_library_payload(server_info, "Recently Added"))
+ server_info.children = [special_library_payload(server_info, "Recommended")]
for library in plex_server.library.sections():
if library.type == "photo":
continue
server_info.children.append(library_section_payload(library))
server_info.children.append(BrowseMedia(**PLAYLISTS_BROWSE_PAYLOAD))
return server_info
+
+
+def hub_payload(hub):
+ """Create response payload for a hub."""
+ if hasattr(hub, "librarySectionID"):
+ media_content_id = f"{HUB_PREFIX}{hub.librarySectionID}:{hub.hubIdentifier}"
+ else:
+ media_content_id = f"{HUB_PREFIX}server:{hub.hubIdentifier}"
+ payload = {
+ "title": hub.title,
+ "media_class": MEDIA_CLASS_DIRECTORY,
+ "media_content_id": media_content_id,
+ "media_content_type": hub.type,
+ "can_play": False,
+ "can_expand": True,
+ }
+ return BrowseMedia(**payload)
+
+
+def station_payload(station):
+ """Create response payload for a music station."""
+ return BrowseMedia(
+ title=station.title,
+ media_class=ITEM_TYPE_MEDIA_CLASS[station.type],
+ media_content_id=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 af1415e65bc..6ee09bfcfb4 100644
--- a/homeassistant/components/plex/media_player.py
+++ b/homeassistant/components/plex/media_player.py
@@ -486,6 +486,16 @@ class PlexMediaPlayer(MediaPlayerEntity):
"Plex integration configured without a token, playback may fail"
)
+ if media_type == "station":
+ playqueue = self.plex_server.create_station_playqueue(media_id)
+ try:
+ self.device.playMedia(playqueue)
+ except requests.exceptions.ConnectTimeout as exc:
+ raise HomeAssistantError(
+ f"Request failed when playing on {self.name}"
+ ) from exc
+ return
+
src = json.loads(media_id)
if isinstance(src, int):
src = {"plex_key": src}
diff --git a/homeassistant/components/plex/server.py b/homeassistant/components/plex/server.py
index c50ad2025b7..f1ac620f379 100644
--- a/homeassistant/components/plex/server.py
+++ b/homeassistant/components/plex/server.py
@@ -599,6 +599,10 @@ class PlexServer:
"""Create playqueue on Plex server."""
return plexapi.playqueue.PlayQueue.create(self._plex_server, media, **kwargs)
+ def create_station_playqueue(self, key):
+ """Create playqueue on Plex server using a radio station key."""
+ return plexapi.playqueue.PlayQueue.fromStationKey(self._plex_server, key)
+
def get_playqueue(self, playqueue_id):
"""Retrieve existing playqueue from Plex server."""
return plexapi.playqueue.PlayQueue.get(self._plex_server, playqueue_id)
diff --git a/tests/components/plex/conftest.py b/tests/components/plex/conftest.py
index 9d3e7a13536..47e7d96d2fe 100644
--- a/tests/components/plex/conftest.py
+++ b/tests/components/plex/conftest.py
@@ -368,6 +368,18 @@ def sonos_resources_fixture():
return load_fixture("plex/sonos_resources.xml")
+@pytest.fixture(name="hubs", scope="session")
+def hubs_fixture():
+ """Load hubs resource payload and return it."""
+ return load_fixture("plex/hubs.xml")
+
+
+@pytest.fixture(name="hubs_music_library", scope="session")
+def hubs_music_library_fixture():
+ """Load music library hubs resource payload and return it."""
+ return load_fixture("plex/hubs_library_section.xml")
+
+
@pytest.fixture(name="entry")
def mock_config_entry():
"""Return the default mocked config entry."""
diff --git a/tests/components/plex/fixtures/hubs.xml b/tests/components/plex/fixtures/hubs.xml
new file mode 100644
index 00000000000..6ed54c12f34
--- /dev/null
+++ b/tests/components/plex/fixtures/hubs.xml
@@ -0,0 +1,59 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/components/plex/fixtures/hubs_library_section.xml b/tests/components/plex/fixtures/hubs_library_section.xml
new file mode 100644
index 00000000000..27e141c325f
--- /dev/null
+++ b/tests/components/plex/fixtures/hubs_library_section.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/components/plex/test_browse_media.py b/tests/components/plex/test_browse_media.py
index 5bbd29f35c0..d14689f9c0f 100644
--- a/tests/components/plex/test_browse_media.py
+++ b/tests/components/plex/test_browse_media.py
@@ -15,6 +15,8 @@ from .const import DEFAULT_DATA
class MockPlexShow:
"""Mock a plexapi Season instance."""
+ TAG = "Directory"
+ TYPE = "show"
ratingKey = 30
title = "TV Show"
type = "show"
@@ -27,8 +29,11 @@ class MockPlexShow:
class MockPlexSeason:
"""Mock a plexapi Season instance."""
+ TAG = "Directory"
+ TYPE = "season"
ratingKey = 20
title = "Season 1"
+ parentTitle = "TV Show"
type = "season"
year = 2021
@@ -40,6 +45,7 @@ class MockPlexSeason:
class MockPlexEpisode:
"""Mock a plexapi Episode instance."""
+ TAG = "Video"
ratingKey = 10
title = "Episode 1"
grandparentTitle = "TV Show"
@@ -50,6 +56,8 @@ class MockPlexEpisode:
class MockPlexArtist:
"""Mock a plexapi Artist instance."""
+ TAG = "Directory"
+ TYPE = "artist"
ratingKey = 300
title = "Artist"
type = "artist"
@@ -58,10 +66,15 @@ class MockPlexArtist:
"""Iterate over albums."""
yield MockPlexAlbum()
+ def station(self):
+ """Mock the station artist method."""
+ return MockPlexStation()
+
class MockPlexAlbum:
"""Mock a plexapi Album instance."""
+ TAG = "Directory"
ratingKey = 200
parentTitle = "Artist"
title = "Album"
@@ -76,19 +89,30 @@ class MockPlexAlbum:
class MockPlexTrack:
"""Mock a plexapi Track instance."""
+ TAG = "Track"
index = 1
ratingKey = 100
title = "Track 1"
type = "track"
+class MockPlexStation:
+ """Mock a plexapi radio station instance."""
+
+ TAG = "Playlist"
+ key = "/library/sections/3/stations/1"
+ title = "Radio Station"
+ radio = True
+ type = "playlist"
+
+
async def test_browse_media(
hass,
hass_ws_client,
mock_plex_server,
requests_mock,
- library_movies_filtertypes,
- empty_payload,
+ hubs,
+ hubs_music_library,
):
"""Test getting Plex clients from plex.tv."""
websocket_client = await hass_ws_client(hass)
@@ -130,13 +154,18 @@ async def test_browse_media(
result = msg["result"]
assert result[ATTR_MEDIA_CONTENT_TYPE] == "server"
assert result[ATTR_MEDIA_CONTENT_ID] == DEFAULT_DATA[CONF_SERVER_IDENTIFIER]
- # Library Sections + On Deck + Recently Added + Playlists
- assert len(result["children"]) == len(mock_plex_server.library.sections()) + 3
+ # Library Sections + Recommended + Playlists
+ assert len(result["children"]) == len(mock_plex_server.library.sections()) + 2
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"))
playlists = next(iter(x for x in result["children"] if x["title"] == "Playlists"))
- special_keys = ["On Deck", "Recently Added"]
+ special_keys = ["Recommended"]
+
+ requests_mock.get(
+ f"{mock_plex_server.url_in_use}/hubs",
+ text=hubs,
+ )
# Browse into a special folder (server)
msg_id += 1
@@ -160,27 +189,46 @@ async def test_browse_media(
result[ATTR_MEDIA_CONTENT_ID]
== f"{DEFAULT_DATA[CONF_SERVER_IDENTIFIER]}:{special_keys[0]}"
)
- assert len(result["children"]) == len(mock_plex_server.library.onDeck())
+ assert len(result["children"]) == 4 # Hardcoded in fixture
+ assert result["children"][0]["media_content_type"] == "mixed"
+ assert result["children"][1]["media_content_type"] == "album"
+ assert result["children"][2]["media_content_type"] == "clip"
+ assert result["children"][3]["media_content_type"] == "playlist"
+
+ # Browse into a special folder (server): Continue Watching
+ 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: result["children"][0][ATTR_MEDIA_CONTENT_TYPE],
+ ATTR_MEDIA_CONTENT_ID: result["children"][0][ATTR_MEDIA_CONTENT_ID],
+ }
+ )
+
+ msg = await websocket_client.receive_json()
+ assert msg["id"] == msg_id
+ assert msg["type"] == TYPE_RESULT
+ assert msg["success"]
+ result = msg["result"]
+ assert result[ATTR_MEDIA_CONTENT_TYPE] == "mixed"
+
+ requests_mock.get(
+ f"{mock_plex_server.url_in_use}/hubs/sections/3?includeStations=1",
+ text=hubs_music_library,
+ )
# Browse into a special folder (library)
- requests_mock.get(
- f"{mock_plex_server.url_in_use}/library/sections/1/all?includeMeta=1",
- text=library_movies_filtertypes,
- )
- requests_mock.get(
- f"{mock_plex_server.url_in_use}/library/sections/1/collections?includeMeta=1",
- text=empty_payload,
- )
-
msg_id += 1
- library_section_id = next(iter(mock_plex_server.library.sections())).key
+ library_section_id = 3
await websocket_client.send_json(
{
"id": msg_id,
"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[1]}",
+ ATTR_MEDIA_CONTENT_ID: f"{library_section_id}:{special_keys[0]}",
}
)
@@ -190,11 +238,30 @@ 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[1]}"
- assert len(result["children"]) == len(
- mock_plex_server.library.sectionByID(library_section_id).recentlyAdded()
+ assert result[ATTR_MEDIA_CONTENT_ID] == f"{library_section_id}:{special_keys[0]}"
+ assert len(result["children"]) == 1
+
+ # Browse into a library radio station hub
+ 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: result["children"][0][ATTR_MEDIA_CONTENT_TYPE],
+ ATTR_MEDIA_CONTENT_ID: result["children"][0][ATTR_MEDIA_CONTENT_ID],
+ }
)
+ msg = await websocket_client.receive_json()
+ assert msg["id"] == msg_id
+ assert msg["type"] == TYPE_RESULT
+ assert msg["success"]
+ result = msg["result"]
+ assert result[ATTR_MEDIA_CONTENT_TYPE] == "station"
+ assert len(result["children"]) == 3
+ assert result["children"][0]["title"] == "Library Radio"
+
# Browse into a Plex TV show library
msg_id += 1
await websocket_client.send_json(
@@ -214,10 +281,10 @@ async def test_browse_media(
result = msg["result"]
assert result[ATTR_MEDIA_CONTENT_TYPE] == "library"
result_id = int(result[ATTR_MEDIA_CONTENT_ID])
- # All items in section + On Deck + Recently Added
+ # All items in section + Hubs
assert (
len(result["children"])
- == len(mock_plex_server.library.sectionByID(result_id).all()) + 2
+ == len(mock_plex_server.library.sectionByID(result_id).all()) + 1
)
# Browse into a Plex TV show
@@ -278,7 +345,10 @@ async def test_browse_media(
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["title"]
+ == f"{mock_season.parentTitle} - {mock_season.title} ({mock_season.year})"
+ )
assert (
result["children"][0]["title"]
== f"{mock_episode.seasonEpisode.upper()} - {mock_episode.title}"
@@ -332,7 +402,8 @@ async def test_browse_media(
result_id = int(result[ATTR_MEDIA_CONTENT_ID])
assert result[ATTR_MEDIA_CONTENT_TYPE] == "artist"
assert result["title"] == mock_artist.title
- assert result["children"][0]["title"] == f"{mock_album.title} ({mock_album.year})"
+ assert result["children"][0]["title"] == "Radio Station"
+ assert result["children"][1]["title"] == f"{mock_album.title} ({mock_album.year})"
# Browse into a Plex album
msg_id += 1
@@ -400,26 +471,3 @@ async def test_browse_media(
result = msg["result"]
assert result[ATTR_MEDIA_CONTENT_TYPE] == "playlists"
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"]
diff --git a/tests/components/plex/test_sensor.py b/tests/components/plex/test_sensor.py
index d99ce483b04..83046b85be3 100644
--- a/tests/components/plex/test_sensor.py
+++ b/tests/components/plex/test_sensor.py
@@ -33,6 +33,7 @@ class MockPlexMedia:
class MockPlexClip(MockPlexMedia):
"""Minimal mock of plexapi clip object."""
+ TAG = "Video"
type = "clip"
title = "Clip 1"
@@ -40,6 +41,7 @@ class MockPlexClip(MockPlexMedia):
class MockPlexMovie(MockPlexMedia):
"""Minimal mock of plexapi movie object."""
+ TAG = "Video"
type = "movie"
title = "Movie 1"
@@ -47,6 +49,7 @@ class MockPlexMovie(MockPlexMedia):
class MockPlexMusic(MockPlexMedia):
"""Minimal mock of plexapi album object."""
+ TAG = "Directory"
listType = "audio"
type = "album"
title = "Album"
@@ -56,6 +59,7 @@ class MockPlexMusic(MockPlexMedia):
class MockPlexTVEpisode(MockPlexMedia):
"""Minimal mock of plexapi episode object."""
+ TAG = "Video"
type = "episode"
title = "Episode 5"
grandparentTitle = "TV Show"