Add username parameter to search action in Music Assistant (#176347)

Co-authored-by: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com>
This commit is contained in:
Fabian Munkes
2026-07-30 22:56:59 +02:00
committed by GitHub
parent 2bf7401566
commit ac03df0a9c
6 changed files with 113 additions and 25 deletions
@@ -4,12 +4,15 @@ from collections.abc import Callable, Coroutine
import functools
from typing import TYPE_CHECKING, Any
from music_assistant_models.auth import UserRole
from music_assistant_models.errors import MusicAssistantError
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from .const import DOMAIN
if TYPE_CHECKING:
from music_assistant_client import MusicAssistantClient
@@ -46,10 +49,19 @@ def get_music_assistant_client(
return entry.runtime_data.mass
async def _async_get_available_mass_usernames(mass: MusicAssistantClient) -> list[str]:
"""Get available Music Assistant usernames which can be used in Home Assistant."""
users = await mass.auth.list_users()
return [
user.username for user in users if user.enabled and user.role != UserRole.GUEST
]
async def async_resolve_mass_username(
hass: HomeAssistant, user_id: str, available_usernames: list[str]
hass: HomeAssistant, mass: MusicAssistantClient, user_id: str
) -> str | None:
"""Resolve the Music Assistant username for the Home Assistant user."""
available_usernames = await _async_get_available_mass_usernames(mass)
if (user := await hass.auth.async_get_user(user_id)) is None:
return None
for cred in user.credentials:
@@ -62,3 +74,19 @@ async def async_resolve_mass_username(
if username in available_usernames:
return username
return None
async def async_verify_mass_username_availability(
mass: MusicAssistantClient, username: str
) -> None:
"""Verify Music Assistant username availability for service calls."""
available_usernames = await _async_get_available_mass_usernames(mass)
if username not in available_usernames:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="invalid_username",
translation_placeholders={
"username": username,
"available_usernames": ", ".join(available_usernames),
},
)
@@ -6,7 +6,6 @@ from contextlib import suppress
import os
from typing import TYPE_CHECKING, Any, override
from music_assistant_models.auth import UserRole
from music_assistant_models.constants import PLAYER_CONTROL_NONE
from music_assistant_models.enums import (
EventType,
@@ -61,7 +60,11 @@ from .const import (
DOMAIN,
)
from .entity import MusicAssistantEntity
from .helpers import async_resolve_mass_username, catch_musicassistant_error
from .helpers import (
async_resolve_mass_username,
async_verify_mass_username_availability,
catch_musicassistant_error,
)
from .media_browser import async_browse_media, async_search_media
from .schemas import QUEUE_DETAILS_SCHEMA, queue_item_dict_from_mass_item
@@ -463,26 +466,12 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity):
# An explicit username is validated strictly; when omitted we default to
# the Home Assistant user that made the call (best-effort, never raises).
user_id = self._context.user_id if self._context is not None else None
if username is not None or user_id is not None:
available_usernames = [
user.username
for user in await self.mass.auth.list_users()
if user.enabled and user.role != UserRole.GUEST
]
if username is not None:
if username not in available_usernames:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="invalid_username",
translation_placeholders={
"username": username,
"available_usernames": ", ".join(available_usernames),
},
)
elif user_id is not None:
username = await async_resolve_mass_username(
self.hass, user_id, available_usernames
)
if username is not None:
await async_verify_mass_username_availability(
mass=self.mass, username=username
)
elif user_id is not None:
username = await async_resolve_mass_username(self.hass, self.mass, user_id)
media_uris: list[str] = []
item: MediaItemType | ItemMapping | None = None
@@ -54,7 +54,7 @@ from .const import (
ATTR_USERNAME,
DOMAIN,
)
from .helpers import get_music_assistant_client
from .helpers import async_verify_mass_username_availability, get_music_assistant_client
from .schemas import (
LIBRARY_RESULTS_SCHEMA,
SEARCH_RESULT_SCHEMA,
@@ -102,6 +102,7 @@ def register_actions(hass: HomeAssistant) -> None:
vol.Optional(ATTR_SEARCH_ALBUM): cv.string,
vol.Optional(ATTR_LIMIT, default=5): vol.Coerce(int),
vol.Optional(ATTR_LIBRARY_ONLY, default=False): cv.boolean,
vol.Optional(ATTR_USERNAME): cv.string,
}
),
supports_response=SupportsResponse.ONLY,
@@ -184,6 +185,11 @@ async def handle_search(call: ServiceCall) -> ServiceResponse:
search_name = call.data[ATTR_SEARCH_NAME]
search_artist = call.data.get(ATTR_SEARCH_ARTIST)
search_album = call.data.get(ATTR_SEARCH_ALBUM)
search_username = call.data.get(ATTR_USERNAME)
if search_username is not None:
await async_verify_mass_username_availability(
mass=mass, username=search_username
)
if search_album and search_artist:
search_name = f"{search_artist} - {search_album} - {search_name}"
elif search_album:
@@ -195,6 +201,7 @@ async def handle_search(call: ServiceCall) -> ServiceResponse:
media_types=call.data.get(ATTR_MEDIA_TYPE, MediaType.ALL),
limit=call.data[ATTR_LIMIT],
library_only=call.data[ATTR_LIBRARY_ONLY],
user=search_username,
)
response: ServiceResponse = SEARCH_RESULT_SCHEMA(
{
@@ -156,6 +156,10 @@ search:
default: false
selector:
boolean:
username:
example: "john"
selector:
text:
get_library:
fields:
@@ -462,6 +462,10 @@
"name": {
"description": "The name/title to search for.",
"name": "Search name"
},
"username": {
"description": "Music Assistant username used for searching. Searches respect the user's configured provider filters.",
"name": "Username"
}
},
"name": "Search Music Assistant",
@@ -1,7 +1,8 @@
"""Test Music Assistant actions."""
from unittest.mock import AsyncMock, MagicMock
from unittest.mock import AsyncMock, MagicMock, call
from music_assistant_models.enums import MediaType
from music_assistant_models.media_items import SearchResults
import pytest
from syrupy.assertion import SnapshotAssertion
@@ -10,6 +11,7 @@ from homeassistant.components.music_assistant.const import (
ATTR_FAVORITE,
ATTR_MEDIA_TYPE,
ATTR_SEARCH_NAME,
ATTR_USERNAME,
DOMAIN,
)
from homeassistant.components.music_assistant.services import (
@@ -18,6 +20,7 @@ from homeassistant.components.music_assistant.services import (
)
from homeassistant.const import ATTR_CONFIG_ENTRY_ID
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ServiceValidationError
from .common import create_library_albums_from_fixture, setup_integration_from_fixtures
@@ -48,6 +51,59 @@ async def test_search_action(
assert response == snapshot
async def test_search_action_with_username(
hass: HomeAssistant,
music_assistant_client: MagicMock,
) -> None:
"""Test music assistant search action."""
entry = await setup_integration_from_fixtures(hass, music_assistant_client)
# tests for servers supporting the username
music_assistant_client.server_info.schema_version = 35
music_assistant_client.music.client.send_command = AsyncMock(
return_value={"albums": []}
)
# valid user ok and forwarded
await hass.services.async_call(
DOMAIN,
SERVICE_SEARCH,
{
ATTR_CONFIG_ENTRY_ID: entry.entry_id,
ATTR_SEARCH_NAME: "test",
ATTR_USERNAME: "user_user",
},
blocking=True,
return_response=True,
)
assert music_assistant_client.send_command.call_count == 1
assert music_assistant_client.send_command.call_args == call(
"music/search",
search_query="test",
media_types=MediaType.ALL,
limit=5,
library_only=False,
user="user_user",
require_schema=35,
)
# not valid because of name, disabled or guest
for username in ("non_existing_user", "party_guest", "user_disabled"):
with pytest.raises(ServiceValidationError) as exc:
await hass.services.async_call(
DOMAIN,
SERVICE_SEARCH,
{
ATTR_CONFIG_ENTRY_ID: entry.entry_id,
ATTR_SEARCH_NAME: "test",
ATTR_USERNAME: username,
},
blocking=True,
return_response=True,
)
assert exc.value.translation_key == "invalid_username"
@pytest.mark.parametrize(
"media_type",
[