mirror of
https://github.com/home-assistant/core.git
synced 2025-07-24 05:37:44 +00:00
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:
parent
1e8843947c
commit
9428127021
@ -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,
|
||||
)
|
||||
]
|
||||
)
|
||||
|
@ -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
|
||||
|
@ -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"}},
|
||||
)
|
||||
|
Loading…
x
Reference in New Issue
Block a user