From 086378c48f116bfe0c2c3ef50096b1362301477a Mon Sep 17 00:00:00 2001 From: On Freund Date: Thu, 15 Oct 2020 16:49:36 +0300 Subject: [PATCH] Add media browser capability to volumio (#40785) --- .coveragerc | 1 + .../components/volumio/browse_media.py | 167 ++++++++++++++++++ .../components/volumio/manifest.json | 2 +- .../components/volumio/media_player.py | 17 ++ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 6 files changed, 188 insertions(+), 3 deletions(-) create mode 100644 homeassistant/components/volumio/browse_media.py diff --git a/.coveragerc b/.coveragerc index 5bb54d961c1..e893f831ead 100644 --- a/.coveragerc +++ b/.coveragerc @@ -980,6 +980,7 @@ omit = homeassistant/components/vlc_telnet/media_player.py homeassistant/components/volkszaehler/sensor.py homeassistant/components/volumio/__init__.py + homeassistant/components/volumio/browse_media.py homeassistant/components/volumio/media_player.py homeassistant/components/volvooncall/* homeassistant/components/w800rf32/* diff --git a/homeassistant/components/volumio/browse_media.py b/homeassistant/components/volumio/browse_media.py new file mode 100644 index 00000000000..dfceb491372 --- /dev/null +++ b/homeassistant/components/volumio/browse_media.py @@ -0,0 +1,167 @@ +"""Support for media browsing.""" +import json + +from homeassistant.components.media_player import BrowseError, BrowseMedia +from homeassistant.components.media_player.const import ( + MEDIA_CLASS_ALBUM, + MEDIA_CLASS_ARTIST, + MEDIA_CLASS_CHANNEL, + MEDIA_CLASS_DIRECTORY, + MEDIA_CLASS_GENRE, + MEDIA_CLASS_PLAYLIST, + MEDIA_CLASS_TRACK, + MEDIA_TYPE_MUSIC, +) + +PLAYABLE_ITEM_TYPES = [ + "folder", + "song", + "mywebradio", + "webradio", + "playlist", + "cuesong", + "remdisk", + "cuefile", + "folder-with-favourites", + "internal-folder", +] + +NON_EXPANDABLE_ITEM_TYPES = [ + "song", + "webradio", + "mywebradio", + "cuesong", + "album", + "artist", + "cd", + "play-playlist", +] + +PLAYLISTS_URI_PREFIX = "playlists" +ARTISTS_URI_PREFIX = "artists://" +ALBUMS_URI_PREFIX = "albums://" +GENRES_URI_PREFIX = "genres://" +RADIO_URI_PREFIX = "radio" +LAST_100_URI_PREFIX = "Last_100" +FAVOURITES_URI = "favourites" + + +def _item_to_children_media_class(item, info=None): + if info and "album" in info and "artist" in info: + return MEDIA_CLASS_TRACK + if item["uri"].startswith(PLAYLISTS_URI_PREFIX): + return MEDIA_CLASS_PLAYLIST + if item["uri"].startswith(ARTISTS_URI_PREFIX): + if len(item["uri"]) > len(ARTISTS_URI_PREFIX): + return MEDIA_CLASS_ALBUM + return MEDIA_CLASS_ARTIST + if item["uri"].startswith(ALBUMS_URI_PREFIX): + if len(item["uri"]) > len(ALBUMS_URI_PREFIX): + return MEDIA_CLASS_TRACK + return MEDIA_CLASS_ALBUM + if item["uri"].startswith(GENRES_URI_PREFIX): + if len(item["uri"]) > len(GENRES_URI_PREFIX): + return MEDIA_CLASS_ALBUM + return MEDIA_CLASS_GENRE + if item["uri"].startswith(LAST_100_URI_PREFIX) or item["uri"] == FAVOURITES_URI: + return MEDIA_CLASS_TRACK + if item["uri"].startswith(RADIO_URI_PREFIX): + return MEDIA_CLASS_CHANNEL + return MEDIA_CLASS_DIRECTORY + + +def _item_to_media_class(item, parent_item=None): + if "type" not in item: + return MEDIA_CLASS_DIRECTORY + if item["type"] in ["webradio", "mywebradio"]: + return MEDIA_CLASS_CHANNEL + if item["type"] in ["song", "cuesong"]: + return MEDIA_CLASS_TRACK + if item.get("artist"): + return MEDIA_CLASS_ALBUM + if item["uri"].startswith(ARTISTS_URI_PREFIX) and len(item["uri"]) > len( + ARTISTS_URI_PREFIX + ): + return MEDIA_CLASS_ARTIST + if parent_item: + return _item_to_children_media_class(parent_item) + return MEDIA_CLASS_DIRECTORY + + +def _list_payload(media_library, item, children=None): + return BrowseMedia( + title=item["name"], + media_class=MEDIA_CLASS_DIRECTORY, + children_media_class=_item_to_children_media_class(item), + media_content_type=MEDIA_TYPE_MUSIC, + media_content_id=json.dumps(item), + can_play=False, + can_expand=True, + ) + + +def _raw_item_payload(media_library, item, parent_item=None, title=None, info=None): + if "type" in item: + thumbnail = item.get("albumart") + if thumbnail: + thumbnail = media_library.canonic_url(thumbnail) + else: + # don't use the built-in volumio white-on-white icons + thumbnail = None + + return { + "title": title or item.get("title"), + "media_class": _item_to_media_class(item, parent_item), + "children_media_class": _item_to_children_media_class(item, info), + "media_content_type": MEDIA_TYPE_MUSIC, + "media_content_id": json.dumps(item), + "can_play": item.get("type") in PLAYABLE_ITEM_TYPES, + "can_expand": item.get("type") not in NON_EXPANDABLE_ITEM_TYPES, + "thumbnail": thumbnail, + } + + +def _item_payload(media_library, item, parent_item): + return BrowseMedia( + **_raw_item_payload(media_library, item, parent_item=parent_item) + ) + + +async def browse_top_level(media_library): + """Browse the top-level of a Volumio media hierarchy.""" + navigation = await media_library.browse() + children = [_list_payload(media_library, item) for item in navigation["lists"]] + return BrowseMedia( + media_class=MEDIA_CLASS_DIRECTORY, + media_content_id="library", + media_content_type="library", + title="Media Library", + can_play=False, + can_expand=True, + children=children, + ) + + +async def browse_node(media_library, media_content_type, media_content_id): + """Browse a node of a Volumio media hierarchy.""" + json_item = json.loads(media_content_id) + navigation = await media_library.browse(json_item["uri"]) + if "lists" not in navigation: + raise BrowseError(f"Media not found: {media_content_type} / {media_content_id}") + + # we only use the first list since the second one could include all tracks + first_list = navigation["lists"][0] + children = [ + _item_payload(media_library, item, parent_item=json_item) + for item in first_list["items"] + ] + info = navigation.get("info") + title = first_list.get("title") + if not title: + if info: + title = f"{info.get('album')} ({info.get('artist')})" + else: + title = "Media Library" + + payload = _raw_item_payload(media_library, json_item, title=title, info=info) + return BrowseMedia(**payload, children=children) diff --git a/homeassistant/components/volumio/manifest.json b/homeassistant/components/volumio/manifest.json index 08c0167df0a..a12b96e7bca 100644 --- a/homeassistant/components/volumio/manifest.json +++ b/homeassistant/components/volumio/manifest.json @@ -5,5 +5,5 @@ "codeowners": ["@OnFreund"], "config_flow": true, "zeroconf": ["_Volumio._tcp.local."], - "requirements": ["pyvolumio==0.1.2"] + "requirements": ["pyvolumio==0.1.3"] } \ No newline at end of file diff --git a/homeassistant/components/volumio/media_player.py b/homeassistant/components/volumio/media_player.py index d471f283ef1..4edf44cc814 100644 --- a/homeassistant/components/volumio/media_player.py +++ b/homeassistant/components/volumio/media_player.py @@ -4,15 +4,18 @@ Volumio Platform. Volumio rest API: https://volumio.github.io/docs/API/REST_API.html """ from datetime import timedelta +import json import logging from homeassistant.components.media_player import MediaPlayerEntity from homeassistant.components.media_player.const import ( MEDIA_TYPE_MUSIC, + SUPPORT_BROWSE_MEDIA, SUPPORT_CLEAR_PLAYLIST, SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PLAY, + SUPPORT_PLAY_MEDIA, SUPPORT_PREVIOUS_TRACK, SUPPORT_SEEK, SUPPORT_SELECT_SOURCE, @@ -31,6 +34,7 @@ from homeassistant.const import ( ) from homeassistant.util import Throttle +from .browse_media import browse_node, browse_top_level from .const import DATA_INFO, DATA_VOLUMIO, DOMAIN _CONFIGURING = {} @@ -45,10 +49,12 @@ SUPPORT_VOLUMIO = ( | SUPPORT_SEEK | SUPPORT_STOP | SUPPORT_PLAY + | SUPPORT_PLAY_MEDIA | SUPPORT_VOLUME_STEP | SUPPORT_SELECT_SOURCE | SUPPORT_SHUFFLE_SET | SUPPORT_CLEAR_PLAYLIST + | SUPPORT_BROWSE_MEDIA ) PLAYLIST_UPDATE_INTERVAL = timedelta(seconds=15) @@ -246,3 +252,14 @@ class Volumio(MediaPlayerEntity): async def _async_update_playlists(self, **kwargs): """Update available Volumio playlists.""" self._playlists = await self._volumio.get_playlists() + + async def async_play_media(self, media_type, media_id, **kwargs): + """Send the play_media command to the media player.""" + await self._volumio.replace_and_play(json.loads(media_id)) + + 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 browse_top_level(self._volumio) + + return await browse_node(self._volumio, media_content_type, media_content_id) diff --git a/requirements_all.txt b/requirements_all.txt index 15bbae1b0fe..7dfc6d814e7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1878,7 +1878,7 @@ pyvizio==0.1.56 pyvlx==0.2.17 # homeassistant.components.volumio -pyvolumio==0.1.2 +pyvolumio==0.1.3 # homeassistant.components.html5 pywebpush==1.9.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 07c872fb240..eaa80448ffb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -895,7 +895,7 @@ pyvesync==1.2.0 pyvizio==0.1.56 # homeassistant.components.volumio -pyvolumio==0.1.2 +pyvolumio==0.1.3 # homeassistant.components.html5 pywebpush==1.9.2