diff --git a/homeassistant/components/kodi/browse_media.py b/homeassistant/components/kodi/browse_media.py index 3fc3b40cd38..c7df170b5c9 100644 --- a/homeassistant/components/kodi/browse_media.py +++ b/homeassistant/components/kodi/browse_media.py @@ -29,8 +29,15 @@ PLAYABLE_MEDIA_TYPES = [ MEDIA_TYPE_TRACK, ] -CONTENT_TYPE_MEDIA_CLASS = { - "library_music": MEDIA_CLASS_MUSIC, +CONTAINER_TYPES_SPECIFIC_MEDIA_CLASS = { + MEDIA_TYPE_ALBUM: MEDIA_CLASS_ALBUM, + MEDIA_TYPE_ARTIST: MEDIA_CLASS_ARTIST, + MEDIA_TYPE_PLAYLIST: MEDIA_CLASS_PLAYLIST, + MEDIA_TYPE_SEASON: MEDIA_CLASS_SEASON, + MEDIA_TYPE_TVSHOW: MEDIA_CLASS_TV_SHOW, +} + +CHILD_TYPE_MEDIA_CLASS = { MEDIA_TYPE_SEASON: MEDIA_CLASS_SEASON, MEDIA_TYPE_ALBUM: MEDIA_CLASS_ALBUM, MEDIA_TYPE_ARTIST: MEDIA_CLASS_ARTIST, @@ -151,8 +158,10 @@ async def build_item_response(media_library, payload): except UnknownMediaType: pass - return BrowseMedia( - media_class=CONTENT_TYPE_MEDIA_CLASS[search_type], + response = BrowseMedia( + media_class=CONTAINER_TYPES_SPECIFIC_MEDIA_CLASS.get( + search_type, MEDIA_CLASS_DIRECTORY + ), media_content_id=search_id, media_content_type=search_type, title=title, @@ -162,6 +171,13 @@ async def build_item_response(media_library, payload): thumbnail=thumbnail, ) + if search_type == "library_music": + response.children_media_class = MEDIA_CLASS_MUSIC + else: + response.calculate_children_class() + + return response + def item_payload(item, media_library): """ @@ -170,11 +186,12 @@ def item_payload(item, media_library): Used by async_browse_media. """ title = item["label"] - thumbnail = item.get("thumbnail") if thumbnail: thumbnail = media_library.thumbnail_url(thumbnail) + media_class = None + if "songid" in item: media_content_type = MEDIA_TYPE_TRACK media_content_id = f"{item['songid']}" @@ -213,16 +230,18 @@ def item_payload(item, media_library): else: # this case is for the top folder of each type # possible content types: album, artist, movie, library_music, tvshow + media_class = MEDIA_CLASS_DIRECTORY media_content_type = item["type"] media_content_id = "" can_play = False can_expand = True - try: - media_class = CONTENT_TYPE_MEDIA_CLASS[media_content_type] - except KeyError as err: - _LOGGER.debug("Unknown media type received: %s", media_content_type) - raise UnknownMediaType from err + if media_class is None: + try: + media_class = CHILD_TYPE_MEDIA_CLASS[media_content_type] + except KeyError as err: + _LOGGER.debug("Unknown media type received: %s", media_content_type) + raise UnknownMediaType from err return BrowseMedia( title=title, diff --git a/homeassistant/components/media_player/__init__.py b/homeassistant/components/media_player/__init__.py index 718011b4a76..348bc521a5a 100644 --- a/homeassistant/components/media_player/__init__.py +++ b/homeassistant/components/media_player/__init__.py @@ -85,6 +85,7 @@ from .const import ( ATTR_SOUND_MODE, ATTR_SOUND_MODE_LIST, DOMAIN, + MEDIA_CLASS_DIRECTORY, SERVICE_CLEAR_PLAYLIST, SERVICE_PLAY_MEDIA, SERVICE_SELECT_SOUND_MODE, @@ -816,24 +817,10 @@ class MediaPlayerEntity(Entity): media_content_type: Optional[str] = None, media_content_id: Optional[str] = None, ) -> "BrowseMedia": - """ - Return a payload for the "media_player/browse_media" websocket command. + """Return a BrowseMedia instance. - Payload should follow this format: - { - "title": str - Title of the item - "media_class": str - Media class - "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) - [{}, ...] - } - - Note: Children should omit the children key. + The BrowseMedia instance will be used by the + "media_player/browse_media" websocket command. """ raise NotImplementedError() @@ -1054,6 +1041,7 @@ class BrowseMedia: can_play: bool, can_expand: bool, children: Optional[List["BrowseMedia"]] = None, + children_media_class: Optional[str] = None, thumbnail: Optional[str] = None, ): """Initialize browse media item.""" @@ -1064,10 +1052,14 @@ class BrowseMedia: self.can_play = can_play self.can_expand = can_expand self.children = children + self.children_media_class = children_media_class self.thumbnail = thumbnail def as_dict(self, *, parent: bool = True) -> dict: """Convert Media class to browse media dictionary.""" + if self.children_media_class is None: + self.calculate_children_class() + response = { "title": self.title, "media_class": self.media_class, @@ -1075,6 +1067,7 @@ class BrowseMedia: "media_content_id": self.media_content_id, "can_play": self.can_play, "can_expand": self.can_expand, + "children_media_class": self.children_media_class, "thumbnail": self.thumbnail, } @@ -1089,3 +1082,14 @@ class BrowseMedia: response["children"] = [] return response + + def calculate_children_class(self) -> None: + """Count the children media classes and calculate the correct class.""" + if self.children is None or len(self.children) == 0: + return + + self.children_media_class = MEDIA_CLASS_DIRECTORY + + proposed_class = self.children[0].media_class + if all(child.media_class == proposed_class for child in self.children): + self.children_media_class = proposed_class diff --git a/homeassistant/components/media_source/models.py b/homeassistant/components/media_source/models.py index 3b2044d7e0f..e16ecbe578e 100644 --- a/homeassistant/components/media_source/models.py +++ b/homeassistant/components/media_source/models.py @@ -6,6 +6,7 @@ from typing import List, Optional, Tuple from homeassistant.components.media_player import BrowseMedia from homeassistant.components.media_player.const import ( MEDIA_CLASS_CHANNEL, + MEDIA_CLASS_DIRECTORY, MEDIA_TYPE_CHANNEL, MEDIA_TYPE_CHANNELS, ) @@ -53,11 +54,12 @@ class MediaSourceItem: base = BrowseMediaSource( domain=None, identifier=None, - media_class=MEDIA_CLASS_CHANNEL, + media_class=MEDIA_CLASS_DIRECTORY, media_content_type=MEDIA_TYPE_CHANNELS, title="Media Sources", can_play=False, can_expand=True, + children_media_class=MEDIA_CLASS_CHANNEL, ) base.children = [ BrowseMediaSource( diff --git a/homeassistant/components/netatmo/media_source.py b/homeassistant/components/netatmo/media_source.py index 02ffd608472..76527677224 100644 --- a/homeassistant/components/netatmo/media_source.py +++ b/homeassistant/components/netatmo/media_source.py @@ -5,6 +5,7 @@ import re from typing import Optional, Tuple from homeassistant.components.media_player.const import ( + MEDIA_CLASS_DIRECTORY, MEDIA_CLASS_VIDEO, MEDIA_TYPE_VIDEO, ) @@ -91,10 +92,12 @@ class NetatmoSource(MediaSource): else: path = f"{source}/{camera_id}" + media_class = MEDIA_CLASS_DIRECTORY if event_id is None else MEDIA_CLASS_VIDEO + media = BrowseMediaSource( domain=DOMAIN, identifier=path, - media_class=MEDIA_CLASS_VIDEO, + media_class=media_class, media_content_type=MEDIA_TYPE_VIDEO, title=title, can_play=bool( diff --git a/homeassistant/components/philips_js/media_player.py b/homeassistant/components/philips_js/media_player.py index 7da485fca74..7e2e126437c 100644 --- a/homeassistant/components/philips_js/media_player.py +++ b/homeassistant/components/philips_js/media_player.py @@ -12,6 +12,7 @@ from homeassistant.components.media_player import ( ) from homeassistant.components.media_player.const import ( MEDIA_CLASS_CHANNEL, + MEDIA_CLASS_DIRECTORY, MEDIA_TYPE_CHANNEL, MEDIA_TYPE_CHANNELS, SUPPORT_BROWSE_MEDIA, @@ -289,7 +290,7 @@ class PhilipsTVMediaPlayer(MediaPlayerEntity): return BrowseMedia( title="Channels", - media_class=MEDIA_CLASS_CHANNEL, + media_class=MEDIA_CLASS_DIRECTORY, media_content_id="", media_content_type=MEDIA_TYPE_CHANNELS, can_play=False, diff --git a/homeassistant/components/plex/media_browser.py b/homeassistant/components/plex/media_browser.py index 9d5572a4faa..28444c4a351 100644 --- a/homeassistant/components/plex/media_browser.py +++ b/homeassistant/components/plex/media_browser.py @@ -26,7 +26,7 @@ class UnknownMediaType(BrowseError): EXPANDABLES = ["album", "artist", "playlist", "season", "show"] PLAYLISTS_BROWSE_PAYLOAD = { "title": "Playlists", - "media_class": MEDIA_CLASS_PLAYLIST, + "media_class": MEDIA_CLASS_DIRECTORY, "media_content_id": "all", "media_content_type": "playlists", "can_play": False, @@ -94,10 +94,21 @@ def browse_media( if special_folder: if media_content_type == "server": library_or_section = plex_server.library + children_media_class = MEDIA_CLASS_DIRECTORY title = plex_server.friendly_name elif media_content_type == "library": library_or_section = plex_server.library.sectionByID(media_content_id) title = library_or_section.title + try: + children_media_class = ITEM_TYPE_MEDIA_CLASS[library_or_section.TYPE] + except KeyError as err: + raise BrowseError( + f"Media not found: {media_content_type} / {media_content_id}" + ) from err + else: + raise BrowseError( + f"Media not found: {media_content_type} / {media_content_id}" + ) payload = { "title": title, @@ -107,6 +118,7 @@ def browse_media( "can_play": False, "can_expand": True, "children": [], + "children_media_class": children_media_class, } method = SPECIAL_METHODS[special_folder] @@ -116,13 +128,20 @@ def browse_media( payload["children"].append(item_payload(item)) except UnknownMediaType: continue + return BrowseMedia(**payload) - if media_content_type in ["server", None]: - return server_payload(plex_server) + try: + 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 == "library": + return library_payload(plex_server, media_content_id) + + except UnknownMediaType as err: + raise BrowseError( + f"Media not found: {media_content_type} / {media_content_id}" + ) from err if media_content_type == "playlists": return playlists_payload(plex_server) @@ -160,6 +179,11 @@ def item_payload(item): def library_section_payload(section): """Create response payload for a single library section.""" + try: + children_media_class = ITEM_TYPE_MEDIA_CLASS[section.TYPE] + except KeyError as err: + _LOGGER.debug("Unknown type received: %s", section.TYPE) + raise UnknownMediaType from err return BrowseMedia( title=section.title, media_class=MEDIA_CLASS_DIRECTORY, @@ -167,6 +191,7 @@ def library_section_payload(section): media_content_type="library", can_play=False, can_expand=True, + children_media_class=children_media_class, ) @@ -194,6 +219,7 @@ def server_payload(plex_server): can_expand=True, ) server_info.children = [] + server_info.children_media_class = MEDIA_CLASS_DIRECTORY server_info.children.append(special_library_payload(server_info, "On Deck")) server_info.children.append(special_library_payload(server_info, "Recently Added")) for library in plex_server.library.sections(): @@ -229,4 +255,6 @@ def playlists_payload(plex_server): playlists_info["children"].append(item_payload(playlist)) except UnknownMediaType: continue - return BrowseMedia(**playlists_info) + response = BrowseMedia(**playlists_info) + response.children_media_class = MEDIA_CLASS_PLAYLIST + return response diff --git a/homeassistant/components/roku/browse_media.py b/homeassistant/components/roku/browse_media.py index 809c6ac3578..f6f8c8976f1 100644 --- a/homeassistant/components/roku/browse_media.py +++ b/homeassistant/components/roku/browse_media.py @@ -13,9 +13,9 @@ from homeassistant.components.media_player.const import ( CONTENT_TYPE_MEDIA_CLASS = { MEDIA_TYPE_APP: MEDIA_CLASS_APP, - MEDIA_TYPE_APPS: MEDIA_CLASS_APP, + MEDIA_TYPE_APPS: MEDIA_CLASS_DIRECTORY, MEDIA_TYPE_CHANNEL: MEDIA_CLASS_CHANNEL, - MEDIA_TYPE_CHANNELS: MEDIA_CLASS_CHANNEL, + MEDIA_TYPE_CHANNELS: MEDIA_CLASS_DIRECTORY, } PLAYABLE_MEDIA_TYPES = [ @@ -59,7 +59,7 @@ def build_item_response(coordinator, payload): return None return BrowseMedia( - media_class=CONTENT_TYPE_MEDIA_CLASS[search_type], + media_class=MEDIA_CLASS_DIRECTORY, media_content_id=search_id, media_content_type=search_type, title=title, @@ -139,4 +139,16 @@ def library_payload(coordinator): ) ) + if all( + child.media_content_type == MEDIA_TYPE_APPS for child in library_info.children + ): + library_info.children_media_class = MEDIA_CLASS_APP + elif all( + child.media_content_type == MEDIA_TYPE_CHANNELS + for child in library_info.children + ): + library_info.children_media_class = MEDIA_CLASS_CHANNEL + else: + library_info.children_media_class = MEDIA_CLASS_DIRECTORY + return library_info diff --git a/homeassistant/components/sonos/media_player.py b/homeassistant/components/sonos/media_player.py index 51287f9f288..2b50f2864dc 100644 --- a/homeassistant/components/sonos/media_player.py +++ b/homeassistant/components/sonos/media_player.py @@ -222,6 +222,10 @@ ATTR_STATUS_LIGHT = "status_light" UNAVAILABLE_VALUES = {"", "NOT_IMPLEMENTED", None} +class UnknownMediaType(BrowseError): + """Unknown media type.""" + + class SonosData: """Storage class for platform global data.""" @@ -1487,7 +1491,20 @@ def build_item_response(media_library, payload): except IndexError: title = LIBRARY_TITLES_MAPPING[payload["idstring"]] - media_class = SONOS_TO_MEDIA_CLASSES[MEDIA_TYPES_TO_SONOS[payload["search_type"]]] + try: + media_class = SONOS_TO_MEDIA_CLASSES[ + MEDIA_TYPES_TO_SONOS[payload["search_type"]] + ] + except KeyError: + _LOGGER.debug("Unknown media type received %s", payload["search_type"]) + return None + + children = [] + for item in media: + try: + children.append(item_payload(item)) + except UnknownMediaType: + pass return BrowseMedia( title=title, @@ -1495,7 +1512,7 @@ def build_item_response(media_library, payload): media_class=media_class, media_content_id=payload["idstring"], media_content_type=payload["search_type"], - children=[item_payload(item) for item in media], + children=children, can_play=can_play(payload["search_type"]), can_expand=can_expand(payload["search_type"]), ) @@ -1507,12 +1524,18 @@ def item_payload(item): Used by async_browse_media. """ + media_type = get_media_type(item) + try: + media_class = SONOS_TO_MEDIA_CLASSES[media_type] + except KeyError as err: + _LOGGER.debug("Unknown media type received %s", media_type) + raise UnknownMediaType from err return BrowseMedia( title=item.title, thumbnail=getattr(item, "album_art_uri", None), - media_class=SONOS_TO_MEDIA_CLASSES[get_media_type(item)], + media_class=media_class, media_content_id=get_content_id(item), - media_content_type=SONOS_TO_MEDIA_TYPES[get_media_type(item)], + media_content_type=SONOS_TO_MEDIA_TYPES[media_type], can_play=can_play(item.item_class), can_expand=can_expand(item), ) @@ -1524,6 +1547,13 @@ def library_payload(media_library): Used by async_browse_media. """ + children = [] + for item in media_library.browse(): + try: + children.append(item_payload(item)) + except UnknownMediaType: + pass + return BrowseMedia( title="Music Library", media_class=MEDIA_CLASS_DIRECTORY, @@ -1531,7 +1561,7 @@ def library_payload(media_library): media_content_type="library", can_play=False, can_expand=True, - children=[item_payload(item) for item in media_library.browse()], + children=children, ) diff --git a/homeassistant/components/spotify/media_player.py b/homeassistant/components/spotify/media_player.py index e2850027229..7beea59a7bd 100644 --- a/homeassistant/components/spotify/media_player.py +++ b/homeassistant/components/spotify/media_player.py @@ -105,18 +105,18 @@ LIBRARY_MAP = { } CONTENT_TYPE_MEDIA_CLASS = { - "current_user_playlists": MEDIA_CLASS_PLAYLIST, - "current_user_followed_artists": MEDIA_CLASS_ARTIST, - "current_user_saved_albums": MEDIA_CLASS_ALBUM, - "current_user_saved_tracks": MEDIA_CLASS_TRACK, - "current_user_saved_shows": MEDIA_CLASS_PODCAST, - "current_user_recently_played": MEDIA_CLASS_TRACK, - "current_user_top_artists": MEDIA_CLASS_ARTIST, - "current_user_top_tracks": MEDIA_CLASS_TRACK, - "featured_playlists": MEDIA_CLASS_PLAYLIST, - "categories": MEDIA_CLASS_GENRE, - "category_playlists": MEDIA_CLASS_PLAYLIST, - "new_releases": MEDIA_CLASS_ALBUM, + "current_user_playlists": MEDIA_CLASS_DIRECTORY, + "current_user_followed_artists": MEDIA_CLASS_DIRECTORY, + "current_user_saved_albums": MEDIA_CLASS_DIRECTORY, + "current_user_saved_tracks": MEDIA_CLASS_DIRECTORY, + "current_user_saved_shows": MEDIA_CLASS_DIRECTORY, + "current_user_recently_played": MEDIA_CLASS_DIRECTORY, + "current_user_top_artists": MEDIA_CLASS_DIRECTORY, + "current_user_top_tracks": MEDIA_CLASS_DIRECTORY, + "featured_playlists": MEDIA_CLASS_DIRECTORY, + "categories": MEDIA_CLASS_DIRECTORY, + "category_playlists": MEDIA_CLASS_DIRECTORY, + "new_releases": MEDIA_CLASS_DIRECTORY, MEDIA_TYPE_PLAYLIST: MEDIA_CLASS_PLAYLIST, MEDIA_TYPE_ALBUM: MEDIA_CLASS_ALBUM, MEDIA_TYPE_ARTIST: MEDIA_CLASS_ARTIST, @@ -567,6 +567,7 @@ def build_item_response(spotify, user, payload): can_expand=True, ) ) + media_item.children_media_class = MEDIA_CLASS_GENRE return media_item if title is None: @@ -575,7 +576,7 @@ def build_item_response(spotify, user, payload): else: title = LIBRARY_MAP.get(payload["media_content_id"]) - response = { + params = { "title": title, "media_class": media_class, "media_content_id": media_content_id, @@ -586,16 +587,16 @@ def build_item_response(spotify, user, payload): } for item in items: try: - response["children"].append(item_payload(item)) + params["children"].append(item_payload(item)) except (MissingMediaInformation, UnknownMediaType): continue if "images" in media: - response["thumbnail"] = fetch_image_url(media) + params["thumbnail"] = fetch_image_url(media) elif image: - response["thumbnail"] = image + params["thumbnail"] = image - return BrowseMedia(**response) + return BrowseMedia(**params) def item_payload(item): @@ -624,17 +625,13 @@ def item_payload(item): payload = { "title": item.get("name"), + "media_class": media_class, "media_content_id": media_id, "media_content_type": media_type, "can_play": media_type in PLAYABLE_MEDIA_TYPES, "can_expand": can_expand, } - payload = { - **payload, - "media_class": media_class, - } - if "images" in item: payload["thumbnail"] = fetch_image_url(item) elif MEDIA_TYPE_ALBUM in item: @@ -665,7 +662,9 @@ def library_payload(): {"name": item["name"], "type": item["type"], "uri": item["type"]} ) ) - return BrowseMedia(**library_info) + response = BrowseMedia(**library_info) + response.children_media_class = MEDIA_CLASS_DIRECTORY + return response def fetch_image_url(item, key="images"): diff --git a/tests/components/media_source/test_models.py b/tests/components/media_source/test_models.py index 3d19edd722d..8372382bb7a 100644 --- a/tests/components/media_source/test_models.py +++ b/tests/components/media_source/test_models.py @@ -17,6 +17,7 @@ async def test_browse_media_as_dict(): title="media/", can_play=False, can_expand=True, + children_media_class=MEDIA_CLASS_MUSIC, ) base.children = [ models.BrowseMediaSource( @@ -37,6 +38,7 @@ async def test_browse_media_as_dict(): assert item["media_content_id"] == f"{const.URI_SCHEME}{const.DOMAIN}/media" assert not item["can_play"] assert item["can_expand"] + assert item["children_media_class"] == MEDIA_CLASS_MUSIC assert len(item["children"]) == 1 assert item["children"][0]["title"] == "test.mp3" assert item["children"][0]["media_class"] == MEDIA_CLASS_MUSIC @@ -62,6 +64,7 @@ async def test_browse_media_parent_no_children(): assert not item["can_play"] assert item["can_expand"] assert len(item["children"]) == 0 + assert item["children_media_class"] is None async def test_media_source_default_name(): diff --git a/tests/components/plex/mock_classes.py b/tests/components/plex/mock_classes.py index 7cdac1b669a..e16d5cdc13b 100644 --- a/tests/components/plex/mock_classes.py +++ b/tests/components/plex/mock_classes.py @@ -409,6 +409,11 @@ class MockPlexLibrarySection: if self.title == "Photos": return "photo" + @property + def TYPE(self): + """Return the library type.""" + return self.type + @property def key(self): """Mock the key identifier property.""" diff --git a/tests/components/roku/test_media_player.py b/tests/components/roku/test_media_player.py index 312770a873a..e9d5091d664 100644 --- a/tests/components/roku/test_media_player.py +++ b/tests/components/roku/test_media_player.py @@ -16,6 +16,9 @@ from homeassistant.components.media_player.const import ( ATTR_MEDIA_TITLE, ATTR_MEDIA_VOLUME_MUTED, DOMAIN as MP_DOMAIN, + MEDIA_CLASS_APP, + MEDIA_CLASS_CHANNEL, + MEDIA_CLASS_DIRECTORY, MEDIA_TYPE_APP, MEDIA_TYPE_APPS, MEDIA_TYPE_CHANNEL, @@ -499,6 +502,7 @@ async def test_media_browse(hass, aioclient_mock, hass_ws_client): assert msg["result"] assert msg["result"]["title"] == "Media Library" + assert msg["result"]["media_class"] == MEDIA_CLASS_DIRECTORY assert msg["result"]["media_content_type"] == "library" assert msg["result"]["can_expand"] assert not msg["result"]["can_play"] @@ -523,10 +527,12 @@ async def test_media_browse(hass, aioclient_mock, hass_ws_client): assert msg["result"] assert msg["result"]["title"] == "Apps" + assert msg["result"]["media_class"] == MEDIA_CLASS_DIRECTORY assert msg["result"]["media_content_type"] == MEDIA_TYPE_APPS assert msg["result"]["can_expand"] assert not msg["result"]["can_play"] assert len(msg["result"]["children"]) == 11 + assert msg["result"]["children_media_class"] == MEDIA_CLASS_APP assert msg["result"]["children"][0]["title"] == "Satellite TV" assert msg["result"]["children"][0]["media_content_type"] == MEDIA_TYPE_APP @@ -565,10 +571,12 @@ async def test_media_browse(hass, aioclient_mock, hass_ws_client): assert msg["result"] assert msg["result"]["title"] == "Channels" + assert msg["result"]["media_class"] == MEDIA_CLASS_DIRECTORY assert msg["result"]["media_content_type"] == MEDIA_TYPE_CHANNELS assert msg["result"]["can_expand"] assert not msg["result"]["can_play"] assert len(msg["result"]["children"]) == 2 + assert msg["result"]["children_media_class"] == MEDIA_CLASS_CHANNEL assert msg["result"]["children"][0]["title"] == "WhatsOn" assert msg["result"]["children"][0]["media_content_type"] == MEDIA_TYPE_CHANNEL