diff --git a/.coveragerc b/.coveragerc index 8fdbc410af2..56a52b4f4e5 100644 --- a/.coveragerc +++ b/.coveragerc @@ -446,6 +446,7 @@ omit = homeassistant/components/knx/climate.py homeassistant/components/knx/cover.py homeassistant/components/kodi/__init__.py + homeassistant/components/kodi/browse_media.py homeassistant/components/kodi/const.py homeassistant/components/kodi/media_player.py homeassistant/components/kodi/notify.py diff --git a/homeassistant/components/kodi/browse_media.py b/homeassistant/components/kodi/browse_media.py new file mode 100644 index 00000000000..d308b48101b --- /dev/null +++ b/homeassistant/components/kodi/browse_media.py @@ -0,0 +1,216 @@ +"""Support for media browsing.""" + +from homeassistant.components.media_player import BrowseMedia +from homeassistant.components.media_player.const import ( + MEDIA_TYPE_ALBUM, + MEDIA_TYPE_ARTIST, + MEDIA_TYPE_EPISODE, + MEDIA_TYPE_MOVIE, + MEDIA_TYPE_PLAYLIST, + MEDIA_TYPE_SEASON, + MEDIA_TYPE_TRACK, + MEDIA_TYPE_TVSHOW, +) + +PLAYABLE_MEDIA_TYPES = [ + MEDIA_TYPE_ALBUM, + MEDIA_TYPE_ARTIST, + MEDIA_TYPE_TRACK, +] + +EXPANDABLE_MEDIA_TYPES = [ + MEDIA_TYPE_ALBUM, + MEDIA_TYPE_ARTIST, + MEDIA_TYPE_PLAYLIST, + MEDIA_TYPE_TVSHOW, + MEDIA_TYPE_SEASON, +] + + +async def build_item_response(media_library, payload): + """Create response payload for the provided media query.""" + search_id = payload["search_id"] + search_type = payload["search_type"] + + thumbnail = None + title = None + media = None + + query = {"properties": ["thumbnail"]} + # pylint: disable=protected-access + if search_type == MEDIA_TYPE_ALBUM: + if search_id: + query.update({"filter": {"albumid": int(search_id)}}) + query["properties"].extend( + ["albumid", "artist", "duration", "album", "track"] + ) + album = await media_library._server.AudioLibrary.GetAlbumDetails( + {"albumid": int(search_id), "properties": ["thumbnail"]} + ) + thumbnail = media_library.thumbnail_url( + album["albumdetails"].get("thumbnail") + ) + title = album["albumdetails"]["label"] + media = await media_library._server.AudioLibrary.GetSongs(query) + media = media.get("songs") + else: + media = await media_library._server.AudioLibrary.GetAlbums(query) + media = media.get("albums") + title = "Albums" + elif search_type == MEDIA_TYPE_ARTIST: + if search_id: + query.update({"filter": {"artistid": int(search_id)}}) + media = await media_library._server.AudioLibrary.GetAlbums(query) + media = media.get("albums") + artist = await media_library._server.AudioLibrary.GetArtistDetails( + {"artistid": int(search_id), "properties": ["thumbnail"]} + ) + thumbnail = media_library.thumbnail_url( + artist["artistdetails"].get("thumbnail") + ) + title = artist["artistdetails"]["label"] + else: + media = await media_library._server.AudioLibrary.GetArtists(query) + media = media.get("artists") + title = "Artists" + elif search_type == "library_music": + library = {MEDIA_TYPE_ALBUM: "Albums", MEDIA_TYPE_ARTIST: "Artists"} + media = [{"label": name, "type": type_} for type_, name in library.items()] + title = "Music Library" + elif search_type == MEDIA_TYPE_MOVIE: + media = await media_library._server.VideoLibrary.GetMovies(query) + media = media.get("movies") + title = "Movies" + elif search_type == MEDIA_TYPE_TVSHOW: + if search_id: + media = await media_library._server.VideoLibrary.GetSeasons( + { + "tvshowid": int(search_id), + "properties": ["thumbnail", "season", "tvshowid"], + } + ) + media = media.get("seasons") + tvshow = await media_library._server.VideoLibrary.GetTVShowDetails( + {"tvshowid": int(search_id), "properties": ["thumbnail"]} + ) + thumbnail = media_library.thumbnail_url( + tvshow["tvshowdetails"].get("thumbnail") + ) + title = tvshow["tvshowdetails"]["label"] + else: + media = await media_library._server.VideoLibrary.GetTVShows(query) + media = media.get("tvshows") + title = "TV Shows" + elif search_type == MEDIA_TYPE_SEASON: + tv_show_id, season_id = search_id.split("/", 1) + media = await media_library._server.VideoLibrary.GetEpisodes( + { + "tvshowid": int(tv_show_id), + "season": int(season_id), + "properties": ["thumbnail", "tvshowid", "seasonid"], + } + ) + media = media.get("episodes") + if media: + season = await media_library._server.VideoLibrary.GetSeasonDetails( + {"seasonid": int(media[0]["seasonid"]), "properties": ["thumbnail"]} + ) + thumbnail = media_library.thumbnail_url( + season["seasondetails"].get("thumbnail") + ) + title = season["seasondetails"]["label"] + + if media is None: + return + + return BrowseMedia( + media_content_id=payload["search_id"], + media_content_type=search_type, + title=title, + can_play=search_type in PLAYABLE_MEDIA_TYPES and search_id, + can_expand=True, + children=[item_payload(item, media_library) for item in media], + thumbnail=thumbnail, + ) + + +def item_payload(item, media_library): + """ + Create response payload for a single media item. + + Used by async_browse_media. + """ + if "songid" in item: + media_content_type = MEDIA_TYPE_TRACK + media_content_id = f"{item['songid']}" + elif "albumid" in item: + media_content_type = MEDIA_TYPE_ALBUM + media_content_id = f"{item['albumid']}" + elif "artistid" in item: + media_content_type = MEDIA_TYPE_ARTIST + media_content_id = f"{item['artistid']}" + elif "movieid" in item: + media_content_type = MEDIA_TYPE_MOVIE + media_content_id = f"{item['movieid']}" + elif "episodeid" in item: + media_content_type = MEDIA_TYPE_EPISODE + media_content_id = f"{item['episodeid']}" + elif "seasonid" in item: + media_content_type = MEDIA_TYPE_SEASON + media_content_id = f"{item['tvshowid']}/{item['season']}" + elif "tvshowid" in item: + media_content_type = MEDIA_TYPE_TVSHOW + media_content_id = f"{item['tvshowid']}" + else: + # this case is for the top folder of each type + # possible content types: album, artist, movie, library_music, tvshow + media_content_type = item.get("type") + media_content_id = "" + + title = item["label"] + can_play = media_content_type in PLAYABLE_MEDIA_TYPES and bool(media_content_id) + can_expand = media_content_type in EXPANDABLE_MEDIA_TYPES + + thumbnail = item.get("thumbnail") + if thumbnail: + thumbnail = media_library.thumbnail_url(thumbnail) + + return BrowseMedia( + title=title, + media_content_type=media_content_type, + media_content_id=media_content_id, + can_play=can_play, + can_expand=can_expand, + thumbnail=thumbnail, + ) + + +def library_payload(media_library): + """ + Create response payload to describe contents of a specific library. + + Used by async_browse_media. + """ + library_info = BrowseMedia( + media_content_id="library", + media_content_type="library", + title="Media Library", + can_play=False, + can_expand=True, + children=[], + ) + + library = { + "library_music": "Music", + MEDIA_TYPE_MOVIE: "Movies", + MEDIA_TYPE_TVSHOW: "TV shows", + } + for item in [{"label": name, "type": type_} for type_, name in library.items()]: + library_info.children.append( + item_payload( + {"label": item["label"], "type": item["type"], "uri": item["type"]}, + media_library, + ) + ) + + return library_info diff --git a/homeassistant/components/kodi/media_player.py b/homeassistant/components/kodi/media_player.py index 96095767a01..ce05f3fc732 100644 --- a/homeassistant/components/kodi/media_player.py +++ b/homeassistant/components/kodi/media_player.py @@ -9,12 +9,18 @@ import voluptuous as vol from homeassistant.components.media_player import PLATFORM_SCHEMA, MediaPlayerEntity from homeassistant.components.media_player.const import ( + MEDIA_TYPE_ALBUM, + MEDIA_TYPE_ARTIST, MEDIA_TYPE_CHANNEL, + MEDIA_TYPE_EPISODE, MEDIA_TYPE_MOVIE, MEDIA_TYPE_MUSIC, MEDIA_TYPE_PLAYLIST, + MEDIA_TYPE_SEASON, + MEDIA_TYPE_TRACK, MEDIA_TYPE_TVSHOW, MEDIA_TYPE_VIDEO, + SUPPORT_BROWSE_MEDIA, SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PLAY, @@ -29,6 +35,7 @@ from homeassistant.components.media_player.const import ( SUPPORT_VOLUME_SET, SUPPORT_VOLUME_STEP, ) +from homeassistant.components.media_player.errors import BrowseError from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import ( ATTR_ENTITY_ID, @@ -50,6 +57,7 @@ from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.event import async_track_time_interval import homeassistant.util.dt as dt_util +from .browse_media import build_item_response, library_payload from .const import ( CONF_WS_PORT, DATA_CONNECTION, @@ -103,20 +111,28 @@ MEDIA_TYPES = { "audio": MEDIA_TYPE_MUSIC, } +MAP_KODI_MEDIA_TYPES = { + MEDIA_TYPE_MOVIE: "movieid", + MEDIA_TYPE_EPISODE: "episodeid", + MEDIA_TYPE_SEASON: "seasonid", + MEDIA_TYPE_TVSHOW: "tvshowid", +} + SUPPORT_KODI = ( - SUPPORT_PAUSE - | SUPPORT_VOLUME_SET - | SUPPORT_VOLUME_MUTE - | SUPPORT_PREVIOUS_TRACK + SUPPORT_BROWSE_MEDIA | SUPPORT_NEXT_TRACK - | SUPPORT_SEEK - | SUPPORT_PLAY_MEDIA - | SUPPORT_STOP - | SUPPORT_SHUFFLE_SET + | SUPPORT_PAUSE | SUPPORT_PLAY - | SUPPORT_VOLUME_STEP + | SUPPORT_PLAY_MEDIA + | SUPPORT_PREVIOUS_TRACK + | SUPPORT_SEEK + | SUPPORT_SHUFFLE_SET + | SUPPORT_STOP | SUPPORT_TURN_OFF | SUPPORT_TURN_ON + | SUPPORT_VOLUME_MUTE + | SUPPORT_VOLUME_SET + | SUPPORT_VOLUME_STEP ) @@ -644,6 +660,31 @@ class KodiEntity(MediaPlayerEntity): await self._kodi.play_playlist(int(media_id)) elif media_type_lower == "directory": await self._kodi.play_directory(str(media_id)) + elif media_type_lower in [ + MEDIA_TYPE_ARTIST, + MEDIA_TYPE_ALBUM, + ]: + await self.async_clear_playlist() + params = {"playlistid": 0, "item": {f"{media_type}id": int(media_id)}} + # pylint: disable=protected-access + await self._kodi._server.Playlist.Add(params) + await self._kodi.play_playlist(0) + elif media_type_lower == MEDIA_TYPE_TRACK: + await self._kodi.clear_playlist() + params = {"playlistid": 0, "item": {"songid": int(media_id)}} + # pylint: disable=protected-access + await self._kodi._server.Playlist.Add(params) + await self._kodi.play_playlist(0) + elif media_type_lower in [ + MEDIA_TYPE_MOVIE, + MEDIA_TYPE_EPISODE, + MEDIA_TYPE_SEASON, + MEDIA_TYPE_TVSHOW, + ]: + # pylint: disable=protected-access + await self._kodi._play_item( + {MAP_KODI_MEDIA_TYPES[media_type_lower]: int(media_id)} + ) else: await self._kodi.play_file(str(media_id)) @@ -794,3 +835,19 @@ class KodiEntity(MediaPlayerEntity): out[i][1] = rate return sorted(out, key=lambda out: out[1], reverse=True) + + async def async_browse_media(self, media_content_type=None, media_content_id=None): + """Implement the websocket media browsing helper.""" + if media_content_type in [None, "library"]: + return await self.hass.async_add_executor_job(library_payload, self._kodi) + + payload = { + "search_type": media_content_type, + "search_id": media_content_id, + } + response = await build_item_response(self._kodi, payload) + if response is None: + raise BrowseError( + f"Media not found: {media_content_type} / {media_content_id}" + ) + return response