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 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,
)
]
)

View File

@ -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

View File

@ -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"}},
)