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"