mirror of
https://github.com/home-assistant/core.git
synced 2026-08-03 20:24:55 +02:00
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:
@@ -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",
|
||||
[
|
||||
|
||||
Reference in New Issue
Block a user