This commit is contained in:
peteS-UK 2024-11-04 21:34:31 +00:00
parent 8ae52cdc4c
commit 32e6e18894
5 changed files with 509 additions and 14 deletions

View File

@ -27,6 +27,12 @@
},
"call_query": {
"service": "mdi:database"
},
"search": {
"service": "mdi:folder-search-outline"
},
"play": {
"service": "mdi:folder-play-outline"
}
}
}

View File

@ -27,7 +27,13 @@ from homeassistant.components.media_player import (
)
from homeassistant.config_entries import SOURCE_INTEGRATION_DISCOVERY
from homeassistant.const import ATTR_COMMAND, CONF_HOST, CONF_PORT, Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.core import (
HomeAssistant,
ServiceCall,
ServiceResponse,
SupportsResponse,
callback,
)
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import (
config_validation as cv,
@ -73,6 +79,8 @@ if TYPE_CHECKING:
SERVICE_CALL_METHOD = "call_method"
SERVICE_CALL_QUERY = "call_query"
SERVICE_SEARCH = "search"
SERVICE_PLAY = "play"
ATTR_QUERY_RESULT = "query_result"
@ -80,7 +88,13 @@ _LOGGER = logging.getLogger(__name__)
ATTR_PARAMETERS = "parameters"
ATTR_RETURN_ITEMS = "return_items"
ATTR_SEARCH_STRING = "search_string"
ATTR_PLAYLIST_ACTION = "playlist_action"
ATTR_SEARCH_TYPE = "search_type"
ATTR_OTHER_PLAYER = "other_player"
ATTR_TAGS = "tags"
ATTR_TO_PROPERTY = [
ATTR_QUERY_RESULT,
@ -134,6 +148,27 @@ async def async_setup_entry(
)
# Register entity services
async def async_call_query_helper(
entity: SqueezeBoxMediaPlayerEntity, serviceCall: ServiceCall
) -> ServiceResponse | None:
return await entity.async_call_query(
serviceCall.data[ATTR_COMMAND],
serviceCall.data.get(ATTR_PARAMETERS, []),
serviceCall.return_response,
)
async def async_search_service_helper(
entity: SqueezeBoxMediaPlayerEntity, serviceCall: ServiceCall
) -> ServiceResponse | None:
return await entity.async_search_service(
serviceCall.data[ATTR_COMMAND],
serviceCall.data[ATTR_RETURN_ITEMS],
serviceCall.data.get(ATTR_SEARCH_STRING),
serviceCall.data.get(ATTR_TAGS),
serviceCall.return_response,
)
platform = entity_platform.async_get_current_platform()
platform.async_register_entity_service(
SERVICE_CALL_METHOD,
@ -150,12 +185,35 @@ async def async_setup_entry(
{
vol.Required(ATTR_COMMAND): cv.string,
vol.Optional(ATTR_PARAMETERS): vol.All(
cv.ensure_list, vol.Length(min=1), [cv.string]
cv.ensure_list,
vol.Length(min=1),
[cv.string],
),
},
"async_call_query",
async_call_query_helper,
supports_response=SupportsResponse.OPTIONAL,
)
platform.async_register_entity_service(
SERVICE_SEARCH,
{
vol.Required(ATTR_COMMAND): cv.string,
vol.Required(ATTR_RETURN_ITEMS): int,
vol.Optional(ATTR_SEARCH_STRING): cv.string,
vol.Optional(ATTR_TAGS): cv.string,
},
async_search_service_helper,
supports_response=SupportsResponse.OPTIONAL,
)
platform.async_register_entity_service(
SERVICE_PLAY,
{
vol.Required(ATTR_COMMAND): cv.string,
vol.Required(ATTR_SEARCH_TYPE): cv.string,
vol.Required(ATTR_SEARCH_STRING): cv.string,
vol.Required(ATTR_PLAYLIST_ACTION): cv.string,
},
"async_play_service",
)
# Start server discovery task if not already running
entry.async_on_unload(async_at_start(hass, start_server_discovery))
@ -594,8 +652,11 @@ class SqueezeBoxMediaPlayerEntity(
await self._player.async_query(*all_params)
async def async_call_query(
self, command: str, parameters: list[str] | None = None
) -> None:
self,
command: str,
parameters: list[str] | None = None,
response: bool | None = False,
) -> ServiceResponse | None:
"""Call Squeezebox JSON/RPC method where we care about the result.
Additional parameters are added to the command to form the list of
@ -604,8 +665,127 @@ class SqueezeBoxMediaPlayerEntity(
all_params = [command]
if parameters:
all_params.extend(parameters)
self._query_result = await self._player.async_query(*all_params)
_LOGGER.debug("call_query got result %s", self._query_result)
query_result = await self._player.async_query(*all_params)
_LOGGER.debug("call_query got result %s", query_result)
if response:
return query_result
self._query_result = query_result
self.async_write_ha_state()
return None
async def async_search_service(
self,
command: str,
return_items: int,
search_string: str | None = None,
tags: str | None = None,
response: bool | None = None,
) -> None:
"""Call Squeezebox JSON/RPC method to search media library."""
match command:
case "albums":
_param = [
"0",
str(return_items),
("tags:" + tags) if tags else "tags:laay",
"search:" + search_string if search_string is not None else "",
]
case "favorites":
_param = [
"items",
"0",
str(return_items),
"search:" + search_string if search_string is not None else "",
]
case "artists":
_param = [
"0",
str(return_items),
("tags:" + tags) if tags else "",
"search:" + search_string if search_string is not None else "",
]
case "genres":
_param = [
"0",
str(return_items),
("tags:" + tags) if tags else "",
"search:" + search_string if search_string is not None else "",
]
case "tracks":
_param = [
"0",
str(return_items),
("tags:" + tags) if tags else "tags:aglQrTy",
"search:" + search_string if search_string is not None else "",
]
case "playlists":
_param = [
"0",
str(return_items),
("tags:" + tags) if tags else "",
"search:" + search_string if search_string is not None else "",
]
case "players":
_param = ["0", str(return_items)]
case _:
_LOGGER.debug("Invalid Search Service Command")
return None
return await self.async_call_query(command, _param, response)
async def async_play_service(
self, command: str, search_type: str, search_string: str, playlist_action: str
) -> None:
"""Call Squeezebox JSON/RPC method to search media library."""
if search_type == "text":
match command:
case "favorite":
_param = [
"favorites",
"items",
"0",
"1",
"search:" + search_string if search_string is not None else "",
]
result_loop = "loop_loop"
_type = "Favorites"
case _:
_param = [
command + "s",
"items0",
"1",
"search:" + search_string if search_string is not None else "",
]
result_loop = command + "s_loop"
_type = command
query_result = await self._player.async_query(*_param)
if query_result["count"] == 0:
raise ServiceValidationError("Search returned zero results")
if query_result["count"] > 1:
raise ServiceValidationError(
f"Search returned {query_result['count']} results. Each search must return only one result"
)
_id = str(query_result[result_loop][0]["id"])
else:
_id = search_string
if command == "favorite":
_type = "Favorites"
else:
_type = command
match playlist_action:
case "add":
await self.async_play_media(_type, _id, enqueue=MediaPlayerEnqueue.ADD)
case "next":
await self.async_play_media(_type, _id, enqueue=MediaPlayerEnqueue.NEXT)
case _:
await self.async_play_media(_type, _id)
self.async_write_ha_state()
async def async_join_players(self, group_members: list[str]) -> None:

View File

@ -30,3 +30,82 @@ call_query:
advanced: true
selector:
object:
search:
target:
entity:
integration: squeezebox
domain: media_player
fields:
command:
required: true
selector:
select:
translation_key: "search_command_selector"
multiple: false
options:
- albums
- artists
- tracks
- playlists
- genres
- favorites
- players
return_items:
required: true
selector:
number:
min: 1
max: 10000
mode: box
search_string:
required: false
example: Revolver
selector:
text:
tags:
required: false
selector:
text:
play:
target:
entity:
integration: squeezebox
domain: media_player
fields:
command:
required: true
selector:
select:
translation_key: "play_command_selector"
multiple: false
options:
- album
- artist
- track
- playlist
- genre
- favorite
search_type:
required: true
selector:
select:
translation_key: "search_type_selector"
multiple: false
options:
- text
- item
search_string:
required: true
example: Revolver
selector:
text:
playlist_action:
required: true
selector:
select:
translation_key: "playlist_type_selector"
multiple: false
options:
- play
- add
- next

View File

@ -49,7 +49,7 @@
},
"call_query": {
"name": "Call query",
"description": "Calls a custom Squeezebox JSONRPC API. Result will be stored in 'query_result' attribute of the Squeezebox entity.",
"description": "Calls a custom Squeezebox JSONRPC API. Result will be stored in 'query_result' attribute of the Squeezebox entity if no result variable is given.",
"fields": {
"command": {
"name": "Command",
@ -60,6 +60,50 @@
"description": "[%key:component::squeezebox::services::call_method::fields::parameters::description%]"
}
}
},
"search": {
"name": "Search music library & players",
"description": "Search for items in the music library & players.",
"fields": {
"command": {
"name": "Items",
"description": "The items to search for on LMS."
},
"return_items": {
"name": "Number of Items",
"description": "The number of items to return."
},
"search_string": {
"name": "Search String",
"description": "Limit the search to items matching the search string."
},
"tags": {
"name": "Tags",
"description": "Override default tags for search"
}
}
},
"play": {
"name": "Play music",
"description": "Play an item in the music library",
"fields": {
"command": {
"name": "Items",
"description": "The items to play on LMS."
},
"search_type": {
"name": "Search Type",
"description": "Full Text search for a string, or directly provide an item_id"
},
"search_string": {
"name": "Search String",
"description": "Search for items matching the search string or item_id."
},
"playlist_action": {
"name": "Playlist Action",
"description": "How should the item be added to the playlist"
}
}
}
},
"entity": {
@ -118,5 +162,41 @@
}
}
}
},
"selector": {
"search_command_selector": {
"options": {
"albums": "Albums",
"artists": "Artists",
"tracks": "Tracks",
"playlists": "Playlists",
"genres": "Genres",
"favorites": "Favorites",
"players": "Players"
}
},
"play_command_selector": {
"options": {
"album": "Album",
"artist": "Artist",
"track": "Track",
"playlist": "Playlist",
"genre": "Genre",
"favorite": "Favorite"
}
},
"search_type_selector": {
"options": {
"text": "Full Text",
"item": "Item ID"
}
},
"playlist_type_selector": {
"options": {
"play": "Replace the current playlist and play the item",
"add": "Add the item to the end of the current playlist",
"next": "Play the item next, after the current item on the playlist"
}
}
}
}
}

View File

@ -42,8 +42,11 @@ from homeassistant.components.squeezebox.const import (
)
from homeassistant.components.squeezebox.media_player import (
ATTR_PARAMETERS,
ATTR_RETURN_ITEMS,
ATTR_SEARCH_STRING,
SERVICE_CALL_METHOD,
SERVICE_CALL_QUERY,
SERVICE_SEARCH,
)
from homeassistant.const import (
ATTR_COMMAND,
@ -109,9 +112,12 @@ async def test_squeezebox_player_rediscovery(
# Make the player appear unavailable
configured_player.connected = False
freezer.tick(timedelta(seconds=PLAYER_UPDATE_INTERVAL))
async_fire_time_changed(hass)
await hass.async_block_till_done()
await hass.services.async_call(
MEDIA_PLAYER_DOMAIN,
SERVICE_TURN_ON,
{ATTR_ENTITY_ID: "media_player.test_player"},
blocking=True,
)
assert hass.states.get("media_player.test_player").state == STATE_UNAVAILABLE
# Make the player available again
@ -120,7 +126,7 @@ async def test_squeezebox_player_rediscovery(
async_fire_time_changed(hass)
await hass.async_block_till_done()
freezer.tick(timedelta(seconds=PLAYER_UPDATE_INTERVAL))
freezer.tick(timedelta(seconds=SENSOR_UPDATE_INTERVAL))
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert hass.states.get("media_player.test_player").state == MediaPlayerState.IDLE
@ -776,6 +782,150 @@ async def test_squeezebox_call_method(
)
async def test_squeezebox_search_albums(
hass: HomeAssistant, configured_player: MagicMock
) -> None:
"""Test query service call."""
await hass.services.async_call(
DOMAIN,
SERVICE_SEARCH,
{
ATTR_ENTITY_ID: "media_player.test_player",
ATTR_COMMAND: "albums",
ATTR_RETURN_ITEMS: 1,
ATTR_SEARCH_STRING: "searchstring",
},
blocking=True,
)
configured_player.async_query.assert_called_once_with(
"albums", "0", "1", "tags:laay", "search:searchstring"
)
async def test_squeezebox_search_favorites(
hass: HomeAssistant, configured_player: MagicMock
) -> None:
"""Test query service call."""
await hass.services.async_call(
DOMAIN,
SERVICE_SEARCH,
{
ATTR_ENTITY_ID: "media_player.test_player",
ATTR_COMMAND: "favorites",
ATTR_RETURN_ITEMS: 1,
ATTR_SEARCH_STRING: "searchstring",
},
blocking=True,
)
configured_player.async_query.assert_called_once_with(
"favorites", "items", "0", "1", "search:searchstring"
)
async def test_squeezebox_search_artists(
hass: HomeAssistant, configured_player: MagicMock
) -> None:
"""Test query service call."""
await hass.services.async_call(
DOMAIN,
SERVICE_SEARCH,
{
ATTR_ENTITY_ID: "media_player.test_player",
ATTR_COMMAND: "artists",
ATTR_RETURN_ITEMS: 1,
ATTR_SEARCH_STRING: "searchstring",
},
blocking=True,
)
configured_player.async_query.assert_called_once_with(
"artists", "0", "1", "search:searchstring"
)
async def test_squeezebox_search_genres(
hass: HomeAssistant, configured_player: MagicMock
) -> None:
"""Test query service call."""
await hass.services.async_call(
DOMAIN,
SERVICE_SEARCH,
{
ATTR_ENTITY_ID: "media_player.test_player",
ATTR_COMMAND: "genres",
ATTR_RETURN_ITEMS: 1,
ATTR_SEARCH_STRING: "searchstring",
},
blocking=True,
)
configured_player.async_query.assert_called_once_with(
"genres", "0", "1", "search:searchstring"
)
async def test_squeezebox_search_tracks(
hass: HomeAssistant, configured_player: MagicMock
) -> None:
"""Test query service call."""
await hass.services.async_call(
DOMAIN,
SERVICE_SEARCH,
{
ATTR_ENTITY_ID: "media_player.test_player",
ATTR_COMMAND: "tracks",
ATTR_RETURN_ITEMS: 1,
ATTR_SEARCH_STRING: "searchstring",
},
blocking=True,
)
configured_player.async_query.assert_called_once_with(
"tracks", "0", "1", "tags:aglQrTy", "search:searchstring"
)
async def test_squeezebox_search_playlists(
hass: HomeAssistant, configured_player: MagicMock
) -> None:
"""Test query service call."""
await hass.services.async_call(
DOMAIN,
SERVICE_SEARCH,
{
ATTR_ENTITY_ID: "media_player.test_player",
ATTR_COMMAND: "playlists",
ATTR_RETURN_ITEMS: 1,
ATTR_SEARCH_STRING: "searchstring",
},
blocking=True,
)
configured_player.async_query.assert_called_once_with(
"playlists", "0", "1", "search:searchstring"
)
async def test_squeezebox_search_players(
hass: HomeAssistant, configured_player: MagicMock
) -> None:
"""Test query service call."""
await hass.services.async_call(
DOMAIN,
SERVICE_SEARCH,
{
ATTR_ENTITY_ID: "media_player.test_player",
ATTR_COMMAND: "players",
ATTR_RETURN_ITEMS: 1,
ATTR_SEARCH_STRING: "searchstring",
},
blocking=True,
)
configured_player.async_query.assert_called_once_with("players", "0", "1")
async def test_squeezebox_invalid_state(
hass: HomeAssistant, configured_player: MagicMock, freezer: FrozenDateTimeFactory
) -> None: