From 9428127021325b9f7500e03a9627929840bfa2e4 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 14 May 2025 15:45:40 -0400 Subject: [PATCH] Add media search and play intent (#144269) * Add media search intent * Add PLAY_MEDIA as required feature and remove explicit responses --------- Co-authored-by: Michael Hansen --- homeassistant/components/demo/media_player.py | 19 +++ .../components/media_player/intent.py | 136 ++++++++++++++- tests/components/media_player/test_intent.py | 158 ++++++++++++++++++ 3 files changed, 311 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/demo/media_player.py b/homeassistant/components/demo/media_player.py index 5cd83722742..ad7ddcba285 100644 --- a/homeassistant/components/demo/media_player.py +++ b/homeassistant/components/demo/media_player.py @@ -6,12 +6,16 @@ from datetime import datetime from typing import Any from homeassistant.components.media_player import ( + BrowseMedia, + MediaClass, MediaPlayerDeviceClass, MediaPlayerEntity, MediaPlayerEntityFeature, MediaPlayerState, MediaType, RepeatMode, + SearchMedia, + SearchMediaQuery, ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -407,3 +411,18 @@ class DemoSearchPlayer(AbstractDemoPlayer): """A Demo media player that supports searching.""" _attr_supported_features = SEARCH_PLAYER_SUPPORT + + async def async_search_media(self, query: SearchMediaQuery) -> SearchMedia: + """Demo implementation of search media.""" + return SearchMedia( + result=[ + BrowseMedia( + title="Search result", + media_class=MediaClass.MOVIE, + media_content_type=MediaType.MOVIE, + media_content_id="search_result_id", + can_play=True, + can_expand=False, + ) + ] + ) diff --git a/homeassistant/components/media_player/intent.py b/homeassistant/components/media_player/intent.py index 4349362b13a..85f0598695b 100644 --- a/homeassistant/components/media_player/intent.py +++ b/homeassistant/components/media_player/intent.py @@ -2,7 +2,9 @@ from collections.abc import Iterable from dataclasses import dataclass, field +import logging import time +from typing import cast import voluptuous as vol @@ -14,9 +16,17 @@ from homeassistant.const import ( SERVICE_VOLUME_SET, ) from homeassistant.core import Context, HomeAssistant, State -from homeassistant.helpers import intent +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import config_validation as cv, intent -from . import ATTR_MEDIA_VOLUME_LEVEL, DOMAIN, MediaPlayerDeviceClass +from . import ( + ATTR_MEDIA_VOLUME_LEVEL, + DOMAIN, + SERVICE_PLAY_MEDIA, + SERVICE_SEARCH_MEDIA, + MediaPlayerDeviceClass, + SearchMedia, +) from .const import MediaPlayerEntityFeature, MediaPlayerState INTENT_MEDIA_PAUSE = "HassMediaPause" @@ -24,6 +34,9 @@ INTENT_MEDIA_UNPAUSE = "HassMediaUnpause" INTENT_MEDIA_NEXT = "HassMediaNext" INTENT_MEDIA_PREVIOUS = "HassMediaPrevious" INTENT_SET_VOLUME = "HassSetVolume" +INTENT_MEDIA_SEARCH_AND_PLAY = "HassMediaSearchAndPlay" + +_LOGGER = logging.getLogger(__name__) @dataclass @@ -109,6 +122,7 @@ async def async_setup_intents(hass: HomeAssistant) -> None: device_classes={MediaPlayerDeviceClass}, ), ) + intent.async_register(hass, MediaSearchAndPlayHandler()) class MediaPauseHandler(intent.ServiceIntentHandler): @@ -207,3 +221,121 @@ class MediaUnpauseHandler(intent.ServiceIntentHandler): return await super().async_handle_states( intent_obj, match_result, match_constraints ) + + +class MediaSearchAndPlayHandler(intent.IntentHandler): + """Handle HassMediaSearchAndPlay intents.""" + + description = "Searches for media and plays the first result" + + intent_type = INTENT_MEDIA_SEARCH_AND_PLAY + slot_schema = { + vol.Required("search_query"): cv.string, + # Optional name/area/floor slots handled by intent matcher + vol.Optional("name"): cv.string, + vol.Optional("area"): cv.string, + vol.Optional("floor"): cv.string, + vol.Optional("preferred_area_id"): cv.string, + vol.Optional("preferred_floor_id"): cv.string, + } + platforms = {DOMAIN} + + async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse: + """Handle the intent.""" + hass = intent_obj.hass + slots = self.async_validate_slots(intent_obj.slots) + search_query = slots["search_query"]["value"] + + # Entity name to match + name_slot = slots.get("name", {}) + entity_name: str | None = name_slot.get("value") + + # Get area/floor info + area_slot = slots.get("area", {}) + area_id = area_slot.get("value") + + floor_slot = slots.get("floor", {}) + floor_id = floor_slot.get("value") + + # Find matching entities + match_constraints = intent.MatchTargetsConstraints( + name=entity_name, + area_name=area_id, + floor_name=floor_id, + domains={DOMAIN}, + assistant=intent_obj.assistant, + features=MediaPlayerEntityFeature.SEARCH_MEDIA + | MediaPlayerEntityFeature.PLAY_MEDIA, + single_target=True, + ) + match_result = intent.async_match_targets( + hass, + match_constraints, + intent.MatchTargetsPreferences( + area_id=slots.get("preferred_area_id", {}).get("value"), + floor_id=slots.get("preferred_floor_id", {}).get("value"), + ), + ) + + if not match_result.is_match: + raise intent.MatchFailedError( + result=match_result, constraints=match_constraints + ) + + target_entity = match_result.states[0] + target_entity_id = target_entity.entity_id + + # 1. Search Media + try: + search_response = await hass.services.async_call( + DOMAIN, + SERVICE_SEARCH_MEDIA, + { + "search_query": search_query, + }, + target={ + "entity_id": target_entity_id, + }, + blocking=True, + context=intent_obj.context, + return_response=True, + ) + except HomeAssistantError as err: + _LOGGER.error("Error calling search_media: %s", err) + raise intent.IntentHandleError(f"Error searching media: {err}") from err + + if ( + not search_response + or not ( + entity_response := cast( + SearchMedia, search_response.get(target_entity_id) + ) + ) + or not (results := entity_response.result) + ): + # No results found + return intent_obj.create_response() + + # 2. Play Media (first result) + first_result = results[0] + try: + await hass.services.async_call( + DOMAIN, + SERVICE_PLAY_MEDIA, + { + "entity_id": target_entity_id, + "media_content_id": first_result.media_content_id, + "media_content_type": first_result.media_content_type, + }, + blocking=True, + context=intent_obj.context, + ) + except HomeAssistantError as err: + _LOGGER.error("Error calling play_media: %s", err) + raise intent.IntentHandleError(f"Error playing media: {err}") from err + + # Success + response = intent_obj.create_response() + response.async_set_speech_slots({"media": first_result}) + response.response_type = intent.IntentResponseType.ACTION_DONE + return response diff --git a/tests/components/media_player/test_intent.py b/tests/components/media_player/test_intent.py index 8e7211183e7..6429d6889c0 100644 --- a/tests/components/media_player/test_intent.py +++ b/tests/components/media_player/test_intent.py @@ -8,7 +8,13 @@ from homeassistant.components.media_player import ( SERVICE_MEDIA_PAUSE, SERVICE_MEDIA_PLAY, SERVICE_MEDIA_PREVIOUS_TRACK, + SERVICE_PLAY_MEDIA, + SERVICE_SEARCH_MEDIA, SERVICE_VOLUME_SET, + BrowseMedia, + MediaClass, + MediaType, + SearchMedia, intent as media_player_intent, ) from homeassistant.components.media_player.const import MediaPlayerEntityFeature @@ -19,6 +25,7 @@ from homeassistant.const import ( STATE_PLAYING, ) from homeassistant.core import Context, HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import ( area_registry as ar, entity_registry as er, @@ -635,3 +642,154 @@ async def test_manual_pause_unpause( assert response.response_type == intent.IntentResponseType.ACTION_DONE assert len(calls) == 1 assert calls[0].data == {"entity_id": device_2.entity_id} + + +async def test_search_and_play_media_player_intent(hass: HomeAssistant) -> None: + """Test HassMediaSearchAndPlay intent for media players.""" + await media_player_intent.async_setup_intents(hass) + + entity_id = f"{DOMAIN}.test_media_player" + attributes = { + ATTR_SUPPORTED_FEATURES: MediaPlayerEntityFeature.SEARCH_MEDIA + | MediaPlayerEntityFeature.PLAY_MEDIA + } + hass.states.async_set(entity_id, STATE_IDLE, attributes=attributes) + + # Test successful search and play + search_result_item = BrowseMedia( + title="Test Track", + media_class=MediaClass.MUSIC, + media_content_type=MediaType.MUSIC, + media_content_id="library/artist/123/album/456/track/789", + can_play=True, + can_expand=False, + ) + + # Mock service calls + search_results = [search_result_item] + search_calls = async_mock_service( + hass, + DOMAIN, + SERVICE_SEARCH_MEDIA, + response={entity_id: SearchMedia(result=search_results)}, + ) + play_calls = async_mock_service(hass, DOMAIN, SERVICE_PLAY_MEDIA) + + response = await intent.async_handle( + hass, + "test", + media_player_intent.INTENT_MEDIA_SEARCH_AND_PLAY, + {"search_query": {"value": "test query"}}, + ) + await hass.async_block_till_done() + + assert response.response_type == intent.IntentResponseType.ACTION_DONE + + # Response should contain a "media" slot with the matched item. + assert not response.speech + media = response.speech_slots.get("media") + assert isinstance(media, BrowseMedia) + assert media.title == "Test Track" + + assert len(search_calls) == 1 + search_call = search_calls[0] + assert search_call.domain == DOMAIN + assert search_call.service == SERVICE_SEARCH_MEDIA + assert search_call.data == { + "entity_id": entity_id, + "search_query": "test query", + } + + assert len(play_calls) == 1 + play_call = play_calls[0] + assert play_call.domain == DOMAIN + assert play_call.service == SERVICE_PLAY_MEDIA + assert play_call.data == { + "entity_id": entity_id, + "media_content_id": search_result_item.media_content_id, + "media_content_type": search_result_item.media_content_type, + } + + # Test no search results + search_results.clear() + response = await intent.async_handle( + hass, + "test", + media_player_intent.INTENT_MEDIA_SEARCH_AND_PLAY, + {"search_query": {"value": "another query"}}, + ) + await hass.async_block_till_done() + + assert response.response_type == intent.IntentResponseType.ACTION_DONE + + # A search failure is indicated by no "media" slot in the response. + assert not response.speech + assert "media" not in response.speech_slots + assert len(search_calls) == 2 # Search was called again + assert len(play_calls) == 1 # Play was not called again + + # Test feature not supported + hass.states.async_set( + entity_id, + STATE_IDLE, + attributes={}, + ) + with pytest.raises(intent.MatchFailedError): + await intent.async_handle( + hass, + "test", + media_player_intent.INTENT_MEDIA_SEARCH_AND_PLAY, + {"search_query": {"value": "test query"}}, + ) + + # Test feature not supported (missing SEARCH_MEDIA) + hass.states.async_set( + entity_id, + STATE_IDLE, + attributes={ATTR_SUPPORTED_FEATURES: MediaPlayerEntityFeature.PLAY_MEDIA}, + ) + with pytest.raises(intent.MatchFailedError): + await intent.async_handle( + hass, + "test", + media_player_intent.INTENT_MEDIA_SEARCH_AND_PLAY, + {"search_query": {"value": "test query"}}, + ) + + # Test play media service errors + search_results.append(search_result_item) + hass.states.async_set( + entity_id, + STATE_IDLE, + attributes={ATTR_SUPPORTED_FEATURES: MediaPlayerEntityFeature.SEARCH_MEDIA}, + ) + + async_mock_service( + hass, + DOMAIN, + SERVICE_PLAY_MEDIA, + raise_exception=HomeAssistantError("Play failed"), + ) + with pytest.raises(intent.MatchFailedError): + await intent.async_handle( + hass, + "test", + media_player_intent.INTENT_MEDIA_SEARCH_AND_PLAY, + {"search_query": {"value": "play error query"}}, + ) + + # Test search service error + hass.states.async_set(entity_id, STATE_IDLE, attributes=attributes) + async_mock_service( + hass, + DOMAIN, + SERVICE_SEARCH_MEDIA, + raise_exception=HomeAssistantError("Search failed"), + ) + with pytest.raises(intent.IntentHandleError, match="Error searching media"): + await intent.async_handle( + hass, + "test", + media_player_intent.INTENT_MEDIA_SEARCH_AND_PLAY, + {"search_query": {"value": "error query"}}, + )