diff --git a/homeassistant/components/universal/media_player.py b/homeassistant/components/universal/media_player.py index c7cc0dd098d..2cbe7aa6fb1 100644 --- a/homeassistant/components/universal/media_player.py +++ b/homeassistant/components/universal/media_player.py @@ -45,6 +45,7 @@ from homeassistant.components.media_player import ( MediaPlayerEntityFeature, MediaPlayerState, ) +from homeassistant.components.media_player.browse_media import BrowseMedia from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_ENTITY_PICTURE, @@ -78,6 +79,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import TemplateError from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import ( TrackTemplate, @@ -93,6 +95,7 @@ ATTR_ACTIVE_CHILD = "active_child" CONF_ATTRS = "attributes" CONF_CHILDREN = "children" CONF_COMMANDS = "commands" +CONF_BROWSE_MEDIA_ENTITY = "browse_media_entity" STATES_ORDER = [ STATE_UNKNOWN, @@ -119,6 +122,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( vol.Optional(CONF_ATTRS, default={}): vol.Or( cv.ensure_list(ATTRS_SCHEMA), ATTRS_SCHEMA ), + vol.Optional(CONF_BROWSE_MEDIA_ENTITY): cv.string, vol.Optional(CONF_UNIQUE_ID): cv.string, vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, vol.Optional(CONF_STATE_TEMPLATE): cv.template, @@ -136,17 +140,7 @@ async def async_setup_platform( """Set up the universal media players.""" await async_setup_reload_service(hass, "universal", ["media_player"]) - player = UniversalMediaPlayer( - hass, - config.get(CONF_NAME), - config.get(CONF_CHILDREN), - config.get(CONF_COMMANDS), - config.get(CONF_ATTRS), - config.get(CONF_UNIQUE_ID), - config.get(CONF_DEVICE_CLASS), - config.get(CONF_STATE_TEMPLATE), - ) - + player = UniversalMediaPlayer(hass, config) async_add_entities([player]) @@ -158,30 +152,25 @@ class UniversalMediaPlayer(MediaPlayerEntity): def __init__( self, hass, - name, - children, - commands, - attributes, - unique_id=None, - device_class=None, - state_template=None, + config, ): """Initialize the Universal media device.""" self.hass = hass - self._name = name - self._children = children - self._cmds = commands + self._name = config.get(CONF_NAME) + self._children = config.get(CONF_CHILDREN) + self._cmds = config.get(CONF_COMMANDS) self._attrs = {} - for key, val in attributes.items(): + for key, val in config.get(CONF_ATTRS).items(): attr = list(map(str.strip, val.split("|", 1))) if len(attr) == 1: attr.append(None) self._attrs[key] = attr self._child_state = None self._state_template_result = None - self._state_template = state_template - self._device_class = device_class - self._attr_unique_id = unique_id + self._state_template = config.get(CONF_STATE_TEMPLATE) + self._device_class = config.get(CONF_DEVICE_CLASS) + self._attr_unique_id = config.get(CONF_UNIQUE_ID) + self._browse_media_entity = config.get(CONF_BROWSE_MEDIA_ENTITY) async def async_added_to_hass(self) -> None: """Subscribe to children and template state changes.""" @@ -497,6 +486,9 @@ class UniversalMediaPlayer(MediaPlayerEntity): if SERVICE_PLAY_MEDIA in self._cmds: flags |= MediaPlayerEntityFeature.PLAY_MEDIA + if self._browse_media_entity: + flags |= MediaPlayerEntityFeature.BROWSE_MEDIA + if SERVICE_CLEAR_PLAYLIST in self._cmds: flags |= MediaPlayerEntityFeature.CLEAR_PLAYLIST @@ -628,6 +620,20 @@ class UniversalMediaPlayer(MediaPlayerEntity): # Delegate to turn_on or turn_off by default await super().async_toggle() + async def async_browse_media( + self, + media_content_type: str | None = None, + media_content_id: str | None = None, + ) -> BrowseMedia: + """Return a BrowseMedia instance.""" + entity_id = self._browse_media_entity + if not entity_id and self._child_state: + entity_id = self._child_state.entity_id + component: EntityComponent[MediaPlayerEntity] = self.hass.data[DOMAIN] + if entity_id and (entity := component.get_entity(entity_id)): + return await entity.async_browse_media(media_content_type, media_content_id) + raise NotImplementedError() + async def async_update(self) -> None: """Update state in HA.""" self._child_state = None diff --git a/tests/components/universal/test_media_player.py b/tests/components/universal/test_media_player.py index 78fd0722180..81204fe21c2 100644 --- a/tests/components/universal/test_media_player.py +++ b/tests/components/universal/test_media_player.py @@ -9,7 +9,8 @@ from homeassistant import config as hass_config import homeassistant.components.input_number as input_number import homeassistant.components.input_select as input_select import homeassistant.components.media_player as media_player -from homeassistant.components.media_player import MediaPlayerEntityFeature +from homeassistant.components.media_player import MediaClass, MediaPlayerEntityFeature +from homeassistant.components.media_player.browse_media import BrowseMedia import homeassistant.components.switch as switch import homeassistant.components.universal.media_player as universal from homeassistant.const import ( @@ -36,6 +37,15 @@ CONFIG_CHILDREN_ONLY = { ], } +MOCK_BROWSE_MEDIA = BrowseMedia( + media_class=MediaClass.APP, + media_content_id="mock-id", + media_content_type="mock-type", + title="Mock Title", + can_play=False, + can_expand=True, +) + def validate_config(config): """Use the platform schema to validate configuration.""" @@ -376,7 +386,7 @@ async def test_master_state(hass: HomeAssistant) -> None: """Test master state property.""" config = validate_config(CONFIG_CHILDREN_ONLY) - ump = universal.UniversalMediaPlayer(hass, **config) + ump = universal.UniversalMediaPlayer(hass, config) assert ump.master_state is None @@ -387,7 +397,7 @@ async def test_master_state_with_attrs( """Test master state property.""" config = validate_config(config_children_and_attr) - ump = universal.UniversalMediaPlayer(hass, **config) + ump = universal.UniversalMediaPlayer(hass, config) assert ump.master_state == STATE_OFF hass.states.async_set(mock_states.mock_state_switch_id, STATE_ON) @@ -402,7 +412,7 @@ async def test_master_state_with_bad_attrs( config["attributes"]["state"] = "bad.entity_id" config = validate_config(config) - ump = universal.UniversalMediaPlayer(hass, **config) + ump = universal.UniversalMediaPlayer(hass, config) assert ump.master_state == STATE_OFF @@ -411,7 +421,7 @@ async def test_active_child_state(hass: HomeAssistant, mock_states) -> None: """Test active child state property.""" config = validate_config(CONFIG_CHILDREN_ONLY) - ump = universal.UniversalMediaPlayer(hass, **config) + ump = universal.UniversalMediaPlayer(hass, config) ump.entity_id = media_player.ENTITY_ID_FORMAT.format(config["name"]) await ump.async_update() @@ -452,7 +462,7 @@ async def test_name(hass: HomeAssistant) -> None: """Test name property.""" config = validate_config(CONFIG_CHILDREN_ONLY) - ump = universal.UniversalMediaPlayer(hass, **config) + ump = universal.UniversalMediaPlayer(hass, config) assert config["name"] == ump.name @@ -461,7 +471,7 @@ async def test_polling(hass: HomeAssistant) -> None: """Test should_poll property.""" config = validate_config(CONFIG_CHILDREN_ONLY) - ump = universal.UniversalMediaPlayer(hass, **config) + ump = universal.UniversalMediaPlayer(hass, config) assert ump.should_poll is False @@ -470,7 +480,7 @@ async def test_state_children_only(hass: HomeAssistant, mock_states) -> None: """Test media player state with only children.""" config = validate_config(CONFIG_CHILDREN_ONLY) - ump = universal.UniversalMediaPlayer(hass, **config) + ump = universal.UniversalMediaPlayer(hass, config) ump.entity_id = media_player.ENTITY_ID_FORMAT.format(config["name"]) await ump.async_update() @@ -489,7 +499,7 @@ async def test_state_with_children_and_attrs( """Test media player with children and master state.""" config = validate_config(config_children_and_attr) - ump = universal.UniversalMediaPlayer(hass, **config) + ump = universal.UniversalMediaPlayer(hass, config) ump.entity_id = media_player.ENTITY_ID_FORMAT.format(config["name"]) await ump.async_update() @@ -514,7 +524,7 @@ async def test_volume_level(hass: HomeAssistant, mock_states) -> None: """Test volume level property.""" config = validate_config(CONFIG_CHILDREN_ONLY) - ump = universal.UniversalMediaPlayer(hass, **config) + ump = universal.UniversalMediaPlayer(hass, config) ump.entity_id = media_player.ENTITY_ID_FORMAT.format(config["name"]) await ump.async_update() @@ -538,7 +548,7 @@ async def test_media_image_url(hass: HomeAssistant, mock_states) -> None: test_url = "test_url" config = validate_config(CONFIG_CHILDREN_ONLY) - ump = universal.UniversalMediaPlayer(hass, **config) + ump = universal.UniversalMediaPlayer(hass, config) ump.entity_id = media_player.ENTITY_ID_FORMAT.format(config["name"]) await ump.async_update() @@ -558,7 +568,7 @@ async def test_is_volume_muted_children_only(hass: HomeAssistant, mock_states) - """Test is volume muted property w/ children only.""" config = validate_config(CONFIG_CHILDREN_ONLY) - ump = universal.UniversalMediaPlayer(hass, **config) + ump = universal.UniversalMediaPlayer(hass, config) ump.entity_id = media_player.ENTITY_ID_FORMAT.format(config["name"]) await ump.async_update() @@ -583,7 +593,7 @@ async def test_sound_mode_list_children_and_attr( """Test sound mode list property w/ children and attrs.""" config = validate_config(config_children_and_attr) - ump = universal.UniversalMediaPlayer(hass, **config) + ump = universal.UniversalMediaPlayer(hass, config) assert ump.sound_mode_list == "['music', 'movie']" @@ -599,7 +609,7 @@ async def test_source_list_children_and_attr( """Test source list property w/ children and attrs.""" config = validate_config(config_children_and_attr) - ump = universal.UniversalMediaPlayer(hass, **config) + ump = universal.UniversalMediaPlayer(hass, config) assert ump.source_list == "['dvd', 'htpc']" @@ -613,7 +623,7 @@ async def test_sound_mode_children_and_attr( """Test sound modeproperty w/ children and attrs.""" config = validate_config(config_children_and_attr) - ump = universal.UniversalMediaPlayer(hass, **config) + ump = universal.UniversalMediaPlayer(hass, config) assert ump.sound_mode == "music" @@ -627,7 +637,7 @@ async def test_source_children_and_attr( """Test source property w/ children and attrs.""" config = validate_config(config_children_and_attr) - ump = universal.UniversalMediaPlayer(hass, **config) + ump = universal.UniversalMediaPlayer(hass, config) assert ump.source == "dvd" @@ -641,7 +651,7 @@ async def test_volume_level_children_and_attr( """Test volume level property w/ children and attrs.""" config = validate_config(config_children_and_attr) - ump = universal.UniversalMediaPlayer(hass, **config) + ump = universal.UniversalMediaPlayer(hass, config) assert ump.volume_level == 0 @@ -655,7 +665,7 @@ async def test_is_volume_muted_children_and_attr( """Test is volume muted property w/ children and attrs.""" config = validate_config(config_children_and_attr) - ump = universal.UniversalMediaPlayer(hass, **config) + ump = universal.UniversalMediaPlayer(hass, config) assert not ump.is_volume_muted @@ -669,7 +679,7 @@ async def test_supported_features_children_only( """Test supported media commands with only children.""" config = validate_config(CONFIG_CHILDREN_ONLY) - ump = universal.UniversalMediaPlayer(hass, **config) + ump = universal.UniversalMediaPlayer(hass, config) ump.entity_id = media_player.ENTITY_ID_FORMAT.format(config["name"]) await ump.async_update() @@ -709,9 +719,10 @@ async def test_supported_features_children_and_cmds( "play_media": excmd, "clear_playlist": excmd, } + config["browse_media_entity"] = "media_player.test" config = validate_config(config) - ump = universal.UniversalMediaPlayer(hass, **config) + ump = universal.UniversalMediaPlayer(hass, config) ump.entity_id = media_player.ENTITY_ID_FORMAT.format(config["name"]) await ump.async_update() @@ -737,6 +748,7 @@ async def test_supported_features_children_and_cmds( | MediaPlayerEntityFeature.PREVIOUS_TRACK | MediaPlayerEntityFeature.PLAY_MEDIA | MediaPlayerEntityFeature.CLEAR_PLAYLIST + | MediaPlayerEntityFeature.BROWSE_MEDIA ) assert check_flags == ump.supported_features @@ -926,7 +938,7 @@ async def test_supported_features_play_pause( config["commands"] = {"media_play_pause": excmd} config = validate_config(config) - ump = universal.UniversalMediaPlayer(hass, **config) + ump = universal.UniversalMediaPlayer(hass, config) ump.entity_id = media_player.ENTITY_ID_FORMAT.format(config["name"]) await ump.async_update() @@ -946,7 +958,7 @@ async def test_service_call_no_active_child( """Test a service call to children with no active child.""" config = validate_config(config_children_and_attr) - ump = universal.UniversalMediaPlayer(hass, **config) + ump = universal.UniversalMediaPlayer(hass, config) ump.entity_id = media_player.ENTITY_ID_FORMAT.format(config["name"]) await ump.async_update() @@ -966,7 +978,7 @@ async def test_service_call_to_child(hass: HomeAssistant, mock_states) -> None: """Test service calls that should be routed to a child.""" config = validate_config(CONFIG_CHILDREN_ONLY) - ump = universal.UniversalMediaPlayer(hass, **config) + ump = universal.UniversalMediaPlayer(hass, config) ump.entity_id = media_player.ENTITY_ID_FORMAT.format(config["name"]) await ump.async_update() @@ -1045,7 +1057,7 @@ async def test_service_call_to_command(hass: HomeAssistant, mock_states) -> None service = async_mock_service(hass, "test", "turn_off") - ump = universal.UniversalMediaPlayer(hass, **config) + ump = universal.UniversalMediaPlayer(hass, config) ump.entity_id = media_player.ENTITY_ID_FORMAT.format(config["name"]) await ump.async_update() @@ -1084,6 +1096,67 @@ async def test_state_template(hass: HomeAssistant) -> None: assert hass.states.get("media_player.tv").state == STATE_OFF +async def test_browse_media(hass: HomeAssistant): + """Test browse media.""" + await async_setup_component( + hass, "media_player", {"media_player": {"platform": "demo"}} + ) + await hass.async_block_till_done() + + config = { + "name": "test", + "platform": "universal", + "children": [ + "media_player.bedroom", + ], + } + config = validate_config(config) + ump = universal.UniversalMediaPlayer(hass, config) + ump.entity_id = media_player.ENTITY_ID_FORMAT.format(config["name"]) + await ump.async_update() + + with patch( + "homeassistant.components.demo.media_player.MediaPlayerEntity.supported_features", + MediaPlayerEntityFeature.BROWSE_MEDIA, + ), patch( + "homeassistant.components.demo.media_player.MediaPlayerEntity.async_browse_media", + return_value=MOCK_BROWSE_MEDIA, + ): + result = await ump.async_browse_media() + assert result == MOCK_BROWSE_MEDIA + + +async def test_browse_media_override(hass: HomeAssistant): + """Test browse media override.""" + await async_setup_component( + hass, "media_player", {"media_player": {"platform": "demo"}} + ) + await hass.async_block_till_done() + + config = { + "name": "test", + "platform": "universal", + "children": [ + "media_player.mock1", + ], + "browse_media_entity": "media_player.bedroom", + } + config = validate_config(config) + ump = universal.UniversalMediaPlayer(hass, config) + ump.entity_id = media_player.ENTITY_ID_FORMAT.format(config["name"]) + await ump.async_update() + + with patch( + "homeassistant.components.demo.media_player.MediaPlayerEntity.supported_features", + MediaPlayerEntityFeature.BROWSE_MEDIA, + ), patch( + "homeassistant.components.demo.media_player.MediaPlayerEntity.async_browse_media", + return_value=MOCK_BROWSE_MEDIA, + ): + result = await ump.async_browse_media() + assert result == MOCK_BROWSE_MEDIA + + async def test_device_class(hass: HomeAssistant) -> None: """Test device_class property.""" hass.states.async_set("sensor.test_sensor", "on")