From ce1b2f45c79f2d3a02c49ee2c7df79ff919d6f9e Mon Sep 17 00:00:00 2001 From: Thijs W Date: Tue, 29 Nov 2022 13:31:49 +0100 Subject: [PATCH] Add BROWSE_MEDIA support to frontier_silicon (#74950) * Add BROWSE_MEDIA support to frontier_silicon * Address review comments * Don't use mediatype to differentiate between channels and presets --- .../frontier_silicon/browse_media.py | 155 ++++++++++++++++++ .../components/frontier_silicon/const.py | 4 +- .../frontier_silicon/media_player.py | 48 +++++- 3 files changed, 204 insertions(+), 3 deletions(-) create mode 100644 homeassistant/components/frontier_silicon/browse_media.py diff --git a/homeassistant/components/frontier_silicon/browse_media.py b/homeassistant/components/frontier_silicon/browse_media.py new file mode 100644 index 00000000000..03b28a025d0 --- /dev/null +++ b/homeassistant/components/frontier_silicon/browse_media.py @@ -0,0 +1,155 @@ +"""Support for media browsing.""" +import logging + +from afsapi import AFSAPI, FSApiException, OutOfRangeException, Preset + +from homeassistant.components.media_player import ( + BrowseError, + BrowseMedia, + MediaClass, + MediaType, +) + +from .const import MEDIA_CONTENT_ID_CHANNELS, MEDIA_CONTENT_ID_PRESET + +TOP_LEVEL_DIRECTORIES = { + MEDIA_CONTENT_ID_CHANNELS: "Channels", + MEDIA_CONTENT_ID_PRESET: "Presets", +} + +FSAPI_ITEM_TYPE_TO_MEDIA_CLASS = { + 0: MediaClass.DIRECTORY, + 1: MediaClass.CHANNEL, + 2: MediaClass.CHANNEL, +} + +_LOGGER = logging.getLogger(__name__) + + +def _item_preset_payload(preset: Preset, player_mode: str) -> BrowseMedia: + """ + Create response payload for a single media item. + + Used by async_browse_media. + """ + return BrowseMedia( + title=preset.name, + media_class=MediaClass.CHANNEL, + media_content_type=MediaType.CHANNEL, + # We add 1 to the preset key to keep it in sync with the numbering shown + # on the interface of the device + media_content_id=f"{player_mode}/{MEDIA_CONTENT_ID_PRESET}/{int(preset.key)+1}", + can_play=True, + can_expand=False, + ) + + +def _item_payload( + key, item: dict[str, str], player_mode: str, parent_keys: list[str] +) -> BrowseMedia: + """ + Create response payload for a single media item. + + Used by async_browse_media. + """ + assert "label" in item or "name" in item + assert "type" in item + + title = item.get("label") or item.get("name") or "Unknown" + title = title.strip() + + media_content_id = "/".join( + [player_mode, MEDIA_CONTENT_ID_CHANNELS, *parent_keys, key] + ) + media_class = ( + FSAPI_ITEM_TYPE_TO_MEDIA_CLASS.get(int(item["type"])) or MediaClass.CHANNEL + ) + + return BrowseMedia( + title=title, + media_class=media_class, + media_content_type=MediaClass.CHANNEL, + media_content_id=media_content_id, + can_play=(media_class != MediaClass.DIRECTORY), + can_expand=(media_class == MediaClass.DIRECTORY), + ) + + +async def browse_top_level(current_mode, afsapi: AFSAPI): + """ + Create response payload to describe contents of a specific library. + + Used by async_browse_media. + """ + + children = [ + BrowseMedia( + title=name, + media_class=MediaClass.DIRECTORY, + media_content_type=MediaType.CHANNELS, + media_content_id=f"{current_mode or 'unknown'}/{top_level_media_content_id}", + can_play=False, + can_expand=True, + ) + for top_level_media_content_id, name in TOP_LEVEL_DIRECTORIES.items() + ] + + library_info = BrowseMedia( + media_class=MediaClass.DIRECTORY, + media_content_id="library", + media_content_type=MediaType.CHANNELS, + title="Media Library", + can_play=False, + can_expand=True, + children=children, + children_media_class=MediaClass.DIRECTORY, + ) + + return library_info + + +async def browse_node( + afsapi: AFSAPI, + media_content_type, + media_content_id, +): + """List the contents of a navigation directory (or preset list) on a Frontier Silicon device.""" + + player_mode, browse_type, *parent_keys = media_content_id.split("/") + + title = TOP_LEVEL_DIRECTORIES.get(browse_type, "Unknown") + + children = [] + try: + if browse_type == MEDIA_CONTENT_ID_PRESET: + # Return the presets + + children = [ + _item_preset_payload(preset, player_mode=player_mode) + for preset in await afsapi.get_presets() + ] + + else: + # Browse to correct folder + await afsapi.nav_select_folder_via_path(parent_keys) + + # Return items in this folder + children = [ + _item_payload(key, item, player_mode, parent_keys=parent_keys) + async for key, item in await afsapi.nav_list() + ] + except OutOfRangeException as err: + raise BrowseError("The requested item is out of range") from err + except FSApiException as err: + raise BrowseError(str(err)) from err + + return BrowseMedia( + title=title, + media_content_id=media_content_id, + media_content_type=MediaType.CHANNELS, + media_class=MediaClass.DIRECTORY, + can_play=False, + can_expand=True, + children=children, + children_media_class=MediaType.CHANNEL, + ) diff --git a/homeassistant/components/frontier_silicon/const.py b/homeassistant/components/frontier_silicon/const.py index 4638e63c2f2..9ee17c0320e 100644 --- a/homeassistant/components/frontier_silicon/const.py +++ b/homeassistant/components/frontier_silicon/const.py @@ -1,6 +1,8 @@ """Constants for the Frontier Silicon Media Player integration.""" - DOMAIN = "frontier_silicon" DEFAULT_PIN = "1234" DEFAULT_PORT = 80 + +MEDIA_CONTENT_ID_PRESET = "preset" +MEDIA_CONTENT_ID_CHANNELS = "channels" diff --git a/homeassistant/components/frontier_silicon/media_player.py b/homeassistant/components/frontier_silicon/media_player.py index 309074c1b26..0e3eb168484 100644 --- a/homeassistant/components/frontier_silicon/media_player.py +++ b/homeassistant/components/frontier_silicon/media_player.py @@ -2,6 +2,7 @@ from __future__ import annotations import logging +from typing import Any from afsapi import ( AFSAPI, @@ -13,6 +14,8 @@ import voluptuous as vol from homeassistant.components.media_player import ( PLATFORM_SCHEMA, + BrowseError, + BrowseMedia, MediaPlayerEntity, MediaPlayerEntityFeature, MediaPlayerState, @@ -25,7 +28,8 @@ from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from .const import DEFAULT_PIN, DEFAULT_PORT, DOMAIN +from .browse_media import browse_node, browse_top_level +from .const import DEFAULT_PIN, DEFAULT_PORT, DOMAIN, MEDIA_CONTENT_ID_PRESET _LOGGER = logging.getLogger(__name__) @@ -80,7 +84,7 @@ async def async_setup_platform( class AFSAPIDevice(MediaPlayerEntity): """Representation of a Frontier Silicon device on the network.""" - _attr_media_content_type: str = MediaType.MUSIC + _attr_media_content_type: str = MediaType.CHANNEL _attr_supported_features = ( MediaPlayerEntityFeature.PAUSE @@ -97,6 +101,7 @@ class AFSAPIDevice(MediaPlayerEntity): | MediaPlayerEntityFeature.TURN_OFF | MediaPlayerEntityFeature.SELECT_SOURCE | MediaPlayerEntityFeature.SELECT_SOUND_MODE + | MediaPlayerEntityFeature.BROWSE_MEDIA ) def __init__(self, name: str | None, afsapi: AFSAPI) -> None: @@ -298,3 +303,42 @@ class AFSAPIDevice(MediaPlayerEntity): and (mode := self.__sound_modes_by_label.get(sound_mode)) is not None ): await self.fs_device.set_eq_preset(mode) + + async def async_browse_media( + self, media_content_type: str | None = None, media_content_id: str | None = None + ) -> BrowseMedia: + """Browse media library and preset stations.""" + if not media_content_id: + return await browse_top_level(self._attr_source, self.fs_device) + + return await browse_node(self.fs_device, media_content_type, media_content_id) + + async def async_play_media( + self, media_type: MediaType | str, media_id: str, **kwargs: Any + ) -> None: + """Play selected media or channel.""" + if media_type != MediaType.CHANNEL: + _LOGGER.error( + "Got %s, but frontier_silicon only supports playing channels", + media_type, + ) + return + + player_mode, media_type, *keys = media_id.split("/") + + await self.async_select_source(player_mode) # this also powers on the device + + if media_type == MEDIA_CONTENT_ID_PRESET: + if len(keys) != 1: + raise BrowseError("Presets can only have 1 level") + + # Keys of presets are 0-based, while the list shown on the device starts from 1 + preset = int(keys[0]) - 1 + + result = await self.fs_device.select_preset(preset) + else: + result = await self.fs_device.nav_select_item_via_path(keys) + + await self.async_update() + self._attr_media_content_id = media_id + return result