mirror of
https://github.com/home-assistant/core.git
synced 2025-07-25 14:17:45 +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 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,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
@ -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
|
||||||
|
@ -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"}},
|
||||||
|
)
|
||||||
|
Loading…
x
Reference in New Issue
Block a user