From dec2c9114070f18859d6edd7f911890d1c3636fd Mon Sep 17 00:00:00 2001 From: Jacob Yundt Date: Mon, 25 May 2026 09:36:06 -0400 Subject: [PATCH 1/2] Avoid syncing native parent volume to AirPlay --- music_assistant/providers/airplay/player.py | 10 +++-- tests/providers/airplay/test_player.py | 43 ++++++++++++++++++++- 2 files changed, 49 insertions(+), 4 deletions(-) diff --git a/music_assistant/providers/airplay/player.py b/music_assistant/providers/airplay/player.py index 2332705bf1..108b8383e0 100644 --- a/music_assistant/providers/airplay/player.py +++ b/music_assistant/providers/airplay/player.py @@ -16,7 +16,7 @@ PlayerType, ) -from music_assistant.constants import CONF_ENTRY_SYNC_ADJUST +from music_assistant.constants import CONF_ENTRY_SYNC_ADJUST, PLAYER_CONTROL_NATIVE from music_assistant.helpers.util import get_primary_ip_address_from_zeroconf, is_valid_mac_address from music_assistant.models.player import DeviceInfo, Player, PlayerMedia @@ -923,14 +923,18 @@ def sync_volume_level(self) -> None: AirPlay players only report their volume level when we are actually streaming to them and we remember the last used/reported volume level in the player config by default - but if we have a parent player, that may know better about the current volume level, - so we try to sync from that parent player if possible + but if we have a parent player without native volume control, that parent may know better + about the current volume level, so we try to sync from that parent player if possible. """ if ( self.protocol_parent_id and (parent_player := self.mass.players.get_player(self.protocol_parent_id)) and parent_player.state.volume_level is not None ): + # Native parent volume is the device/amplifier volume, while AirPlay volume is + # stream/protocol volume. Some receivers map those scales differently. + if parent_player.volume_control == PLAYER_CONTROL_NATIVE: + return if self._attr_volume_level == parent_player.state.volume_level: return self._attr_volume_level = parent_player.state.volume_level diff --git a/tests/providers/airplay/test_player.py b/tests/providers/airplay/test_player.py index 5acdfe9371..5f819a4a54 100644 --- a/tests/providers/airplay/test_player.py +++ b/tests/providers/airplay/test_player.py @@ -4,7 +4,8 @@ import pytest -from music_assistant.providers.airplay.constants import StreamingProtocol +from music_assistant.constants import PLAYER_CONTROL_NATIVE, PLAYER_CONTROL_NONE +from music_assistant.providers.airplay.constants import CONF_STORED_VOLUME, StreamingProtocol from music_assistant.providers.airplay.player import AirPlayPlayer @@ -200,3 +201,43 @@ async def test_volume_mute_no_stream(airplay_player: AirPlayPlayer) -> None: assert airplay_player._attr_volume_muted is True mock_update.assert_called_once() + + +def test_sync_volume_level_skips_parent_with_native_volume_control( + airplay_player: AirPlayPlayer, +) -> None: + """Do not copy native parent volume into the AirPlay stream volume.""" + parent = MagicMock() + parent.state.volume_level = 36 + parent.volume_control = PLAYER_CONTROL_NATIVE + airplay_player.mass.players.get_player.return_value = parent + airplay_player.set_protocol_parent_id("native_parent") + airplay_player._attr_volume_level = 48 + + with patch.object(AirPlayPlayer, "update_state") as mock_update: + airplay_player.sync_volume_level() + + assert airplay_player._attr_volume_level == 48 + airplay_player.mass.config.set_raw_player_config_value.assert_not_called() + mock_update.assert_not_called() + + +def test_sync_volume_level_uses_parent_without_native_volume_control( + airplay_player: AirPlayPlayer, +) -> None: + """Keep syncing parent volume for parents that delegate volume to a protocol/control.""" + parent = MagicMock() + parent.state.volume_level = 36 + parent.volume_control = PLAYER_CONTROL_NONE + airplay_player.mass.players.get_player.return_value = parent + airplay_player.set_protocol_parent_id("protocol_parent") + airplay_player._attr_volume_level = 48 + + with patch.object(AirPlayPlayer, "update_state") as mock_update: + airplay_player.sync_volume_level() + + assert airplay_player._attr_volume_level == 36 + airplay_player.mass.config.set_raw_player_config_value.assert_called_once_with( + airplay_player.player_id, CONF_STORED_VOLUME, 36 + ) + mock_update.assert_called_once() From 22b22a58e16ded9985d477c1be5213fed1dcfc4c Mon Sep 17 00:00:00 2001 From: Jacob Yundt Date: Mon, 25 May 2026 10:35:31 -0400 Subject: [PATCH 2/2] Fix AirPlay volume test typing --- music_assistant/providers/airplay/player.py | 3 ++- tests/providers/airplay/test_player.py | 10 +++++----- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/music_assistant/providers/airplay/player.py b/music_assistant/providers/airplay/player.py index 108b8383e0..47d35ea2c5 100644 --- a/music_assistant/providers/airplay/player.py +++ b/music_assistant/providers/airplay/player.py @@ -8,6 +8,7 @@ from typing import TYPE_CHECKING, cast from music_assistant_models.config_entries import ConfigEntry, ConfigValueOption, ConfigValueType +from music_assistant_models.constants import PLAYER_CONTROL_NATIVE from music_assistant_models.enums import ( ConfigEntryType, IdentifierType, @@ -16,7 +17,7 @@ PlayerType, ) -from music_assistant.constants import CONF_ENTRY_SYNC_ADJUST, PLAYER_CONTROL_NATIVE +from music_assistant.constants import CONF_ENTRY_SYNC_ADJUST from music_assistant.helpers.util import get_primary_ip_address_from_zeroconf, is_valid_mac_address from music_assistant.models.player import DeviceInfo, Player, PlayerMedia diff --git a/tests/providers/airplay/test_player.py b/tests/providers/airplay/test_player.py index 5f819a4a54..e8945817ca 100644 --- a/tests/providers/airplay/test_player.py +++ b/tests/providers/airplay/test_player.py @@ -3,8 +3,8 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest +from music_assistant_models.constants import PLAYER_CONTROL_NATIVE, PLAYER_CONTROL_NONE -from music_assistant.constants import PLAYER_CONTROL_NATIVE, PLAYER_CONTROL_NONE from music_assistant.providers.airplay.constants import CONF_STORED_VOLUME, StreamingProtocol from music_assistant.providers.airplay.player import AirPlayPlayer @@ -210,7 +210,7 @@ def test_sync_volume_level_skips_parent_with_native_volume_control( parent = MagicMock() parent.state.volume_level = 36 parent.volume_control = PLAYER_CONTROL_NATIVE - airplay_player.mass.players.get_player.return_value = parent + airplay_player.mass.players.get_player.return_value = parent # type: ignore[attr-defined] airplay_player.set_protocol_parent_id("native_parent") airplay_player._attr_volume_level = 48 @@ -218,7 +218,7 @@ def test_sync_volume_level_skips_parent_with_native_volume_control( airplay_player.sync_volume_level() assert airplay_player._attr_volume_level == 48 - airplay_player.mass.config.set_raw_player_config_value.assert_not_called() + airplay_player.mass.config.set_raw_player_config_value.assert_not_called() # type: ignore[attr-defined] mock_update.assert_not_called() @@ -229,7 +229,7 @@ def test_sync_volume_level_uses_parent_without_native_volume_control( parent = MagicMock() parent.state.volume_level = 36 parent.volume_control = PLAYER_CONTROL_NONE - airplay_player.mass.players.get_player.return_value = parent + airplay_player.mass.players.get_player.return_value = parent # type: ignore[attr-defined] airplay_player.set_protocol_parent_id("protocol_parent") airplay_player._attr_volume_level = 48 @@ -237,7 +237,7 @@ def test_sync_volume_level_uses_parent_without_native_volume_control( airplay_player.sync_volume_level() assert airplay_player._attr_volume_level == 36 - airplay_player.mass.config.set_raw_player_config_value.assert_called_once_with( + airplay_player.mass.config.set_raw_player_config_value.assert_called_once_with( # type: ignore[attr-defined] airplay_player.player_id, CONF_STORED_VOLUME, 36 ) mock_update.assert_called_once()