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": { "call_query": {
"service": "mdi:database" "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.config_entries import SOURCE_INTEGRATION_DISCOVERY
from homeassistant.const import ATTR_COMMAND, CONF_HOST, CONF_PORT, Platform 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.exceptions import ServiceValidationError
from homeassistant.helpers import ( from homeassistant.helpers import (
config_validation as cv, config_validation as cv,
@ -73,6 +79,8 @@ if TYPE_CHECKING:
SERVICE_CALL_METHOD = "call_method" SERVICE_CALL_METHOD = "call_method"
SERVICE_CALL_QUERY = "call_query" SERVICE_CALL_QUERY = "call_query"
SERVICE_SEARCH = "search"
SERVICE_PLAY = "play"
ATTR_QUERY_RESULT = "query_result" ATTR_QUERY_RESULT = "query_result"
@ -80,7 +88,13 @@ _LOGGER = logging.getLogger(__name__)
ATTR_PARAMETERS = "parameters" 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_OTHER_PLAYER = "other_player"
ATTR_TAGS = "tags"
ATTR_TO_PROPERTY = [ ATTR_TO_PROPERTY = [
ATTR_QUERY_RESULT, ATTR_QUERY_RESULT,
@ -134,6 +148,27 @@ async def async_setup_entry(
) )
# Register entity services # 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 = entity_platform.async_get_current_platform()
platform.async_register_entity_service( platform.async_register_entity_service(
SERVICE_CALL_METHOD, SERVICE_CALL_METHOD,
@ -150,12 +185,35 @@ async def async_setup_entry(
{ {
vol.Required(ATTR_COMMAND): cv.string, vol.Required(ATTR_COMMAND): cv.string,
vol.Optional(ATTR_PARAMETERS): vol.All( 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 # Start server discovery task if not already running
entry.async_on_unload(async_at_start(hass, start_server_discovery)) entry.async_on_unload(async_at_start(hass, start_server_discovery))
@ -594,8 +652,11 @@ class SqueezeBoxMediaPlayerEntity(
await self._player.async_query(*all_params) await self._player.async_query(*all_params)
async def async_call_query( async def async_call_query(
self, command: str, parameters: list[str] | None = None self,
) -> None: command: str,
parameters: list[str] | None = None,
response: bool | None = False,
) -> ServiceResponse | None:
"""Call Squeezebox JSON/RPC method where we care about the result. """Call Squeezebox JSON/RPC method where we care about the result.
Additional parameters are added to the command to form the list of Additional parameters are added to the command to form the list of
@ -604,8 +665,127 @@ class SqueezeBoxMediaPlayerEntity(
all_params = [command] all_params = [command]
if parameters: if parameters:
all_params.extend(parameters) all_params.extend(parameters)
self._query_result = await self._player.async_query(*all_params) query_result = await self._player.async_query(*all_params)
_LOGGER.debug("call_query got result %s", self._query_result) _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() self.async_write_ha_state()
async def async_join_players(self, group_members: list[str]) -> None: async def async_join_players(self, group_members: list[str]) -> None:

View File

@ -30,3 +30,82 @@ call_query:
advanced: true advanced: true
selector: selector:
object: 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": { "call_query": {
"name": "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": { "fields": {
"command": { "command": {
"name": "Command", "name": "Command",
@ -60,6 +60,50 @@
"description": "[%key:component::squeezebox::services::call_method::fields::parameters::description%]" "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": { "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 ( from homeassistant.components.squeezebox.media_player import (
ATTR_PARAMETERS, ATTR_PARAMETERS,
ATTR_RETURN_ITEMS,
ATTR_SEARCH_STRING,
SERVICE_CALL_METHOD, SERVICE_CALL_METHOD,
SERVICE_CALL_QUERY, SERVICE_CALL_QUERY,
SERVICE_SEARCH,
) )
from homeassistant.const import ( from homeassistant.const import (
ATTR_COMMAND, ATTR_COMMAND,
@ -109,9 +112,12 @@ async def test_squeezebox_player_rediscovery(
# Make the player appear unavailable # Make the player appear unavailable
configured_player.connected = False configured_player.connected = False
freezer.tick(timedelta(seconds=PLAYER_UPDATE_INTERVAL)) await hass.services.async_call(
async_fire_time_changed(hass) MEDIA_PLAYER_DOMAIN,
await hass.async_block_till_done() SERVICE_TURN_ON,
{ATTR_ENTITY_ID: "media_player.test_player"},
blocking=True,
)
assert hass.states.get("media_player.test_player").state == STATE_UNAVAILABLE assert hass.states.get("media_player.test_player").state == STATE_UNAVAILABLE
# Make the player available again # Make the player available again
@ -120,7 +126,7 @@ async def test_squeezebox_player_rediscovery(
async_fire_time_changed(hass) async_fire_time_changed(hass)
await hass.async_block_till_done() 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) async_fire_time_changed(hass)
await hass.async_block_till_done() await hass.async_block_till_done()
assert hass.states.get("media_player.test_player").state == MediaPlayerState.IDLE 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( async def test_squeezebox_invalid_state(
hass: HomeAssistant, configured_player: MagicMock, freezer: FrozenDateTimeFactory hass: HomeAssistant, configured_player: MagicMock, freezer: FrozenDateTimeFactory
) -> None: ) -> None: