diff --git a/homeassistant/components/plex/helpers.py b/homeassistant/components/plex/helpers.py new file mode 100644 index 00000000000..be873614ba6 --- /dev/null +++ b/homeassistant/components/plex/helpers.py @@ -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 diff --git a/homeassistant/components/plex/media_browser.py b/homeassistant/components/plex/media_browser.py index ac3c6e8f8f8..6be7462da39 100644 --- a/homeassistant/components/plex/media_browser.py +++ b/homeassistant/components/plex/media_browser.py @@ -1,4 +1,5 @@ """Support to interface with the Plex API.""" +from itertools import islice import logging 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 .const import DOMAIN +from .helpers import pretty_title class UnknownMediaType(BrowseError): @@ -32,9 +34,10 @@ PLAYLISTS_BROWSE_PAYLOAD = { "can_play": False, "can_expand": True, } -SPECIAL_METHODS = { - "On Deck": "onDeck", - "Recently Added": "recentlyAdded", + +LIBRARY_PREFERRED_LIBTYPE = { + "show": "episode", + "artist": "album", } ITEM_TYPE_MEDIA_CLASS = { @@ -57,7 +60,7 @@ def browse_media( # noqa: C901 ): """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.""" try: 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) raise UnknownMediaType from err payload = { - "title": item.title, + "title": pretty_title(item, short_name), "media_class": media_class, "media_content_id": str(item.ratingKey), "media_content_type": item.type, @@ -129,7 +132,7 @@ def browse_media( # noqa: C901 media_info.children = [] for item in media: try: - media_info.children.append(item_payload(item)) + media_info.children.append(item_payload(item, short_name=True)) except UnknownMediaType: continue return media_info @@ -180,8 +183,22 @@ def browse_media( # noqa: C901 "children_media_class": children_media_class, } - method = SPECIAL_METHODS[special_folder] - items = getattr(library_or_section, method)() + 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)) diff --git a/tests/components/plex/test_browse_media.py b/tests/components/plex/test_browse_media.py index 4892262fc32..be4869839d2 100644 --- a/tests/components/plex/test_browse_media.py +++ b/tests/components/plex/test_browse_media.py @@ -1,15 +1,74 @@ """Tests for Plex media browser.""" +from unittest.mock import patch + 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.media_browser import SPECIAL_METHODS from homeassistant.components.websocket_api.const import ERR_UNKNOWN_ERROR, TYPE_RESULT 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( hass, hass_ws_client, @@ -58,15 +117,13 @@ 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 + Special Sections + Playlists - assert ( - len(result["children"]) - == len(mock_plex_server.library.sections()) + len(SPECIAL_METHODS) + 1 - ) + # Library Sections + On Deck + Recently Added + Playlists + assert len(result["children"]) == len(mock_plex_server.library.sections()) + 3 + 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 = list(SPECIAL_METHODS.keys()) + special_keys = ["On Deck", "Recently Added"] # Browse into a special folder (server) msg_id += 1 @@ -144,23 +201,34 @@ async def test_browse_media( result = msg["result"] assert result[ATTR_MEDIA_CONTENT_TYPE] == "library" result_id = int(result[ATTR_MEDIA_CONTENT_ID]) - assert len(result["children"]) == len( - mock_plex_server.library.sectionByID(result_id).all() - ) + len(SPECIAL_METHODS) + # All items in section + On Deck + Recently Added + assert ( + len(result["children"]) + == len(mock_plex_server.library.sectionByID(result_id).all()) + 2 + ) # Browse into a Plex TV show 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"][-1][ATTR_MEDIA_CONTENT_TYPE], - ATTR_MEDIA_CONTENT_ID: str(result["children"][-1][ATTR_MEDIA_CONTENT_ID]), - } - ) + 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( + { + "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() - msg = await websocket_client.receive_json() assert msg["id"] == msg_id assert msg["type"] == TYPE_RESULT assert msg["success"] @@ -168,6 +236,90 @@ async def test_browse_media( assert result[ATTR_MEDIA_CONTENT_TYPE] == "show" result_id = int(result[ATTR_MEDIA_CONTENT_ID]) 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 unknown_key = 99999999999999 @@ -211,3 +363,26 @@ 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"]