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 <mike@rhasspy.org>
This commit is contained in:
Paulus Schoutsen 2025-05-14 15:45:40 -04:00 committed by GitHub
parent 1e8843947c
commit 9428127021
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 311 additions and 2 deletions

View File

@ -6,12 +6,16 @@ from datetime import datetime
from typing import Any from typing import Any
from homeassistant.components.media_player import ( from homeassistant.components.media_player import (
BrowseMedia,
MediaClass,
MediaPlayerDeviceClass, MediaPlayerDeviceClass,
MediaPlayerEntity, MediaPlayerEntity,
MediaPlayerEntityFeature, MediaPlayerEntityFeature,
MediaPlayerState, MediaPlayerState,
MediaType, MediaType,
RepeatMode, RepeatMode,
SearchMedia,
SearchMediaQuery,
) )
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
@ -407,3 +411,18 @@ class DemoSearchPlayer(AbstractDemoPlayer):
"""A Demo media player that supports searching.""" """A Demo media player that supports searching."""
_attr_supported_features = SEARCH_PLAYER_SUPPORT _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,
)
]
)

View File

@ -2,7 +2,9 @@
from collections.abc import Iterable from collections.abc import Iterable
from dataclasses import dataclass, field from dataclasses import dataclass, field
import logging
import time import time
from typing import cast
import voluptuous as vol import voluptuous as vol
@ -14,9 +16,17 @@ from homeassistant.const import (
SERVICE_VOLUME_SET, SERVICE_VOLUME_SET,
) )
from homeassistant.core import Context, HomeAssistant, State 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 from .const import MediaPlayerEntityFeature, MediaPlayerState
INTENT_MEDIA_PAUSE = "HassMediaPause" INTENT_MEDIA_PAUSE = "HassMediaPause"
@ -24,6 +34,9 @@ INTENT_MEDIA_UNPAUSE = "HassMediaUnpause"
INTENT_MEDIA_NEXT = "HassMediaNext" INTENT_MEDIA_NEXT = "HassMediaNext"
INTENT_MEDIA_PREVIOUS = "HassMediaPrevious" INTENT_MEDIA_PREVIOUS = "HassMediaPrevious"
INTENT_SET_VOLUME = "HassSetVolume" INTENT_SET_VOLUME = "HassSetVolume"
INTENT_MEDIA_SEARCH_AND_PLAY = "HassMediaSearchAndPlay"
_LOGGER = logging.getLogger(__name__)
@dataclass @dataclass
@ -109,6 +122,7 @@ async def async_setup_intents(hass: HomeAssistant) -> None:
device_classes={MediaPlayerDeviceClass}, device_classes={MediaPlayerDeviceClass},
), ),
) )
intent.async_register(hass, MediaSearchAndPlayHandler())
class MediaPauseHandler(intent.ServiceIntentHandler): class MediaPauseHandler(intent.ServiceIntentHandler):
@ -207,3 +221,121 @@ class MediaUnpauseHandler(intent.ServiceIntentHandler):
return await super().async_handle_states( return await super().async_handle_states(
intent_obj, match_result, match_constraints 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

View File

@ -8,7 +8,13 @@ from homeassistant.components.media_player import (
SERVICE_MEDIA_PAUSE, SERVICE_MEDIA_PAUSE,
SERVICE_MEDIA_PLAY, SERVICE_MEDIA_PLAY,
SERVICE_MEDIA_PREVIOUS_TRACK, SERVICE_MEDIA_PREVIOUS_TRACK,
SERVICE_PLAY_MEDIA,
SERVICE_SEARCH_MEDIA,
SERVICE_VOLUME_SET, SERVICE_VOLUME_SET,
BrowseMedia,
MediaClass,
MediaType,
SearchMedia,
intent as media_player_intent, intent as media_player_intent,
) )
from homeassistant.components.media_player.const import MediaPlayerEntityFeature from homeassistant.components.media_player.const import MediaPlayerEntityFeature
@ -19,6 +25,7 @@ from homeassistant.const import (
STATE_PLAYING, STATE_PLAYING,
) )
from homeassistant.core import Context, HomeAssistant from homeassistant.core import Context, HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import ( from homeassistant.helpers import (
area_registry as ar, area_registry as ar,
entity_registry as er, entity_registry as er,
@ -635,3 +642,154 @@ async def test_manual_pause_unpause(
assert response.response_type == intent.IntentResponseType.ACTION_DONE assert response.response_type == intent.IntentResponseType.ACTION_DONE
assert len(calls) == 1 assert len(calls) == 1
assert calls[0].data == {"entity_id": device_2.entity_id} 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"}},
)