Fix Music Assistant media player entity features (#139428)

* Fix Music Assistant supported media player features

* Update supported features when player config changes

* Add tests
This commit is contained in:
Marcel van der Veldt 2025-02-27 14:30:29 +01:00 committed by GitHub
parent 59eb323f8d
commit f111a2c34a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 182 additions and 19 deletions

View File

@ -9,6 +9,7 @@ import functools
import os
from typing import TYPE_CHECKING, Any, Concatenate
from music_assistant_models.constants import PLAYER_CONTROL_NONE
from music_assistant_models.enums import (
EventType,
MediaType,
@ -80,19 +81,14 @@ if TYPE_CHECKING:
from music_assistant_client import MusicAssistantClient
from music_assistant_models.player import Player
SUPPORTED_FEATURES = (
MediaPlayerEntityFeature.PAUSE
| MediaPlayerEntityFeature.VOLUME_SET
| MediaPlayerEntityFeature.STOP
SUPPORTED_FEATURES_BASE = (
MediaPlayerEntityFeature.STOP
| MediaPlayerEntityFeature.PREVIOUS_TRACK
| MediaPlayerEntityFeature.NEXT_TRACK
| MediaPlayerEntityFeature.SHUFFLE_SET
| MediaPlayerEntityFeature.REPEAT_SET
| MediaPlayerEntityFeature.TURN_ON
| MediaPlayerEntityFeature.TURN_OFF
| MediaPlayerEntityFeature.PLAY
| MediaPlayerEntityFeature.PLAY_MEDIA
| MediaPlayerEntityFeature.VOLUME_STEP
| MediaPlayerEntityFeature.CLEAR_PLAYLIST
| MediaPlayerEntityFeature.BROWSE_MEDIA
| MediaPlayerEntityFeature.MEDIA_ENQUEUE
@ -212,11 +208,7 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity):
"""Initialize MediaPlayer entity."""
super().__init__(mass, player_id)
self._attr_icon = self.player.icon.replace("mdi-", "mdi:")
self._attr_supported_features = SUPPORTED_FEATURES
if PlayerFeature.SET_MEMBERS in self.player.supported_features:
self._attr_supported_features |= MediaPlayerEntityFeature.GROUPING
if PlayerFeature.VOLUME_MUTE in self.player.supported_features:
self._attr_supported_features |= MediaPlayerEntityFeature.VOLUME_MUTE
self._set_supported_features()
self._attr_device_class = MediaPlayerDeviceClass.SPEAKER
self._prev_time: float = 0
@ -241,6 +233,19 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity):
)
)
# we subscribe to the player config changed event to update
# the supported features of the player
async def player_config_changed(event: MassEvent) -> None:
self._set_supported_features()
await self.async_on_update()
self.async_write_ha_state()
self.async_on_remove(
self.mass.subscribe(
player_config_changed, EventType.PLAYER_CONFIG_UPDATED, self.player_id
)
)
@property
def active_queue(self) -> PlayerQueue | None:
"""Return the active queue for this player (if any)."""
@ -682,3 +687,20 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity):
if isinstance(queue_option, MediaPlayerEnqueue):
queue_option = QUEUE_OPTION_MAP.get(queue_option)
return queue_option
def _set_supported_features(self) -> None:
"""Set supported features based on player capabilities."""
supported_features = SUPPORTED_FEATURES_BASE
if PlayerFeature.SET_MEMBERS in self.player.supported_features:
supported_features |= MediaPlayerEntityFeature.GROUPING
if PlayerFeature.PAUSE in self.player.supported_features:
supported_features |= MediaPlayerEntityFeature.PAUSE
if self.player.mute_control != PLAYER_CONTROL_NONE:
supported_features |= MediaPlayerEntityFeature.VOLUME_MUTE
if self.player.volume_control != PLAYER_CONTROL_NONE:
supported_features |= MediaPlayerEntityFeature.VOLUME_STEP
supported_features |= MediaPlayerEntityFeature.VOLUME_SET
if self.player.power_control != PLAYER_CONTROL_NONE:
supported_features |= MediaPlayerEntityFeature.TURN_ON
supported_features |= MediaPlayerEntityFeature.TURN_OFF
self._attr_supported_features = supported_features

View File

@ -2,9 +2,11 @@
from __future__ import annotations
import asyncio
from typing import Any
from unittest.mock import AsyncMock, MagicMock
from music_assistant_models.api import MassEvent
from music_assistant_models.enums import EventType
from music_assistant_models.media_items import Album, Artist, Playlist, Radio, Track
from music_assistant_models.player import Player
@ -134,15 +136,42 @@ async def trigger_subscription_callback(
hass: HomeAssistant,
client: MagicMock,
event: EventType = EventType.PLAYER_UPDATED,
object_id: str | None = None,
data: Any = None,
) -> None:
"""Trigger a subscription callback."""
# trigger callback on all subscribers
for sub in client.subscribe_events.call_args_list:
callback = sub.kwargs["callback"]
event_filter = sub.kwargs.get("event_filter")
if event_filter in (None, event):
callback(event, data)
for sub in client.subscribe.call_args_list:
cb_func = sub.kwargs.get("cb_func", sub.args[0])
event_filter = sub.kwargs.get(
"event_filter", sub.args[1] if len(sub.args) > 1 else None
)
id_filter = sub.kwargs.get(
"id_filter", sub.args[2] if len(sub.args) > 2 else None
)
if not (
event_filter is None
or event == event_filter
or (isinstance(event_filter, list) and event in event_filter)
):
continue
if not (
id_filter is None
or object_id == id_filter
or (isinstance(id_filter, list) and object_id in id_filter)
):
continue
event = MassEvent(
event=event,
object_id=object_id,
data=data,
)
if asyncio.iscoroutinefunction(cb_func):
await cb_func(event)
else:
cb_func(event)
await hass.async_block_till_done()

View File

@ -2,7 +2,13 @@
from unittest.mock import MagicMock, call
from music_assistant_models.enums import MediaType, QueueOption
from music_assistant_models.constants import PLAYER_CONTROL_NONE
from music_assistant_models.enums import (
EventType,
MediaType,
PlayerFeature,
QueueOption,
)
from music_assistant_models.media_items import Track
import pytest
from syrupy import SnapshotAssertion
@ -20,6 +26,7 @@ from homeassistant.components.media_player import (
SERVICE_CLEAR_PLAYLIST,
SERVICE_JOIN,
SERVICE_UNJOIN,
MediaPlayerEntityFeature,
)
from homeassistant.components.music_assistant.const import DOMAIN as MASS_DOMAIN
from homeassistant.components.music_assistant.media_player import (
@ -59,7 +66,11 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from .common import setup_integration_from_fixtures, snapshot_music_assistant_entities
from .common import (
setup_integration_from_fixtures,
snapshot_music_assistant_entities,
trigger_subscription_callback,
)
from tests.common import AsyncMock
@ -607,3 +618,104 @@ async def test_media_player_get_queue_action(
# no call is made, this info comes from the cached queue data
assert music_assistant_client.send_command.call_count == 0
assert response == snapshot(exclude=paths(f"{entity_id}.elapsed_time"))
async def test_media_player_supported_features(
hass: HomeAssistant,
music_assistant_client: MagicMock,
) -> None:
"""Test if media_player entity supported features are cortrectly (re)mapped."""
await setup_integration_from_fixtures(hass, music_assistant_client)
entity_id = "media_player.test_player_1"
mass_player_id = "00:00:00:00:00:01"
state = hass.states.get(entity_id)
assert state
expected_features = (
MediaPlayerEntityFeature.STOP
| MediaPlayerEntityFeature.PREVIOUS_TRACK
| MediaPlayerEntityFeature.NEXT_TRACK
| MediaPlayerEntityFeature.SHUFFLE_SET
| MediaPlayerEntityFeature.REPEAT_SET
| MediaPlayerEntityFeature.PLAY
| MediaPlayerEntityFeature.PLAY_MEDIA
| MediaPlayerEntityFeature.VOLUME_STEP
| MediaPlayerEntityFeature.CLEAR_PLAYLIST
| MediaPlayerEntityFeature.BROWSE_MEDIA
| MediaPlayerEntityFeature.MEDIA_ENQUEUE
| MediaPlayerEntityFeature.MEDIA_ANNOUNCE
| MediaPlayerEntityFeature.SEEK
| MediaPlayerEntityFeature.PAUSE
| MediaPlayerEntityFeature.GROUPING
| MediaPlayerEntityFeature.VOLUME_SET
| MediaPlayerEntityFeature.VOLUME_STEP
| MediaPlayerEntityFeature.VOLUME_MUTE
| MediaPlayerEntityFeature.TURN_ON
| MediaPlayerEntityFeature.TURN_OFF
)
assert state.attributes["supported_features"] == expected_features
# remove power control capability from player, trigger subscription callback
# and check if the supported features got updated
music_assistant_client.players._players[
mass_player_id
].power_control = PLAYER_CONTROL_NONE
await trigger_subscription_callback(
hass, music_assistant_client, EventType.PLAYER_CONFIG_UPDATED, mass_player_id
)
expected_features &= ~MediaPlayerEntityFeature.TURN_ON
expected_features &= ~MediaPlayerEntityFeature.TURN_OFF
state = hass.states.get(entity_id)
assert state
assert state.attributes["supported_features"] == expected_features
# remove volume control capability from player, trigger subscription callback
# and check if the supported features got updated
music_assistant_client.players._players[
mass_player_id
].volume_control = PLAYER_CONTROL_NONE
await trigger_subscription_callback(
hass, music_assistant_client, EventType.PLAYER_CONFIG_UPDATED, mass_player_id
)
expected_features &= ~MediaPlayerEntityFeature.VOLUME_SET
expected_features &= ~MediaPlayerEntityFeature.VOLUME_STEP
state = hass.states.get(entity_id)
assert state
assert state.attributes["supported_features"] == expected_features
# remove mute control capability from player, trigger subscription callback
# and check if the supported features got updated
music_assistant_client.players._players[
mass_player_id
].mute_control = PLAYER_CONTROL_NONE
await trigger_subscription_callback(
hass, music_assistant_client, EventType.PLAYER_CONFIG_UPDATED, mass_player_id
)
expected_features &= ~MediaPlayerEntityFeature.VOLUME_MUTE
state = hass.states.get(entity_id)
assert state
assert state.attributes["supported_features"] == expected_features
# remove pause capability from player, trigger subscription callback
# and check if the supported features got updated
music_assistant_client.players._players[mass_player_id].supported_features.remove(
PlayerFeature.PAUSE
)
await trigger_subscription_callback(
hass, music_assistant_client, EventType.PLAYER_CONFIG_UPDATED, mass_player_id
)
expected_features &= ~MediaPlayerEntityFeature.PAUSE
state = hass.states.get(entity_id)
assert state
assert state.attributes["supported_features"] == expected_features
# remove grouping capability from player, trigger subscription callback
# and check if the supported features got updated
music_assistant_client.players._players[mass_player_id].supported_features.remove(
PlayerFeature.SET_MEMBERS
)
await trigger_subscription_callback(
hass, music_assistant_client, EventType.PLAYER_CONFIG_UPDATED, mass_player_id
)
expected_features &= ~MediaPlayerEntityFeature.GROUPING
state = hass.states.get(entity_id)
assert state
assert state.attributes["supported_features"] == expected_features