/
/
/
1"""
2Regression tests for protocol-prefixed config values not reverting to default.
3
4Reproduces music-assistant/support#5685: for a player that streams via a linked
5protocol (e.g. a Sonos/Universal parent whose audio output is a separate DLNA
6"protocol player"), the config UI exposes protocol-specific entries as virtual
7entries keyed ``<protocol_player_id>||protocol||<actual_key>``. Setting such an
8entry (e.g. ``flow_mode_sample_rate`` / ``crossfade_different_sample_rates``) to
9a non-default value saved fine, but setting it back to the default did not stick.
10
11The linked protocol player is the canonical store for these values. A redundant
12copy persisted on the parent shadowed the protocol player's value once it was
13reset to its default (the parent copy lingered while the protocol player dropped
14the now-default value), so the old value kept reappearing.
15"""
16
17import logging
18from typing import Any
19from unittest.mock import AsyncMock, MagicMock
20
21from music_assistant_models.enums import PlayerFeature, PlayerType, ProviderType
22from music_assistant_models.player import OutputProtocol
23
24from music_assistant.constants import (
25 CONF_FLOW_MODE_SAMPLE_RATE,
26 CONF_PROTOCOL_KEY_SPLITTER,
27 FLOW_MODE_SAMPLE_RATE_BIT_PERFECT,
28 FLOW_MODE_SAMPLE_RATE_SMART,
29)
30from music_assistant.mass import MusicAssistant
31from music_assistant.models.player import DeviceInfo, Player
32
33PARENT_ID = "sonos_123"
34CHILD_ID = "dlna_AABBCCDDEEFF"
35PREFIXED_KEY = f"{CHILD_ID}{CONF_PROTOCOL_KEY_SPLITTER}{CONF_FLOW_MODE_SAMPLE_RATE}"
36
37
38class _TestProvider:
39 """Minimal PlayerProvider stand-in backed by the real MusicAssistant."""
40
41 def __init__(self, mass: MusicAssistant, domain: str) -> None:
42 """Initialize the test provider."""
43 self.mass = mass
44 self.domain = domain
45 self.instance_id = domain
46 self.translation_owner = f"provider.{domain}"
47 self.name = f"{domain.title()} Provider"
48 self.available = True
49 self.logger = logging.getLogger(f"test.{domain}")
50 self.manifest = MagicMock()
51 self.manifest.domain = domain
52 self.manifest.name = self.name
53 self.manifest.type = ProviderType.PLAYER
54 # registered in mass._providers, so the stand-in must survive the provider
55 # bookkeeping that mass.stop()/unload_provider applies to every provider
56 self.type = ProviderType.PLAYER
57 self.players: list[Player] = []
58
59 async def unload(self, is_removed: bool = False) -> None:
60 """Unload the provider (nothing to clean up)."""
61
62
63class _TestPlayer(Player):
64 """Minimal concrete Player for driving the real ConfigController."""
65
66 def __init__(
67 self,
68 provider: _TestProvider,
69 player_id: str,
70 name: str,
71 player_type: PlayerType,
72 ) -> None:
73 """Initialize the test player."""
74 super().__init__(provider, player_id) # type: ignore[arg-type]
75 self._attr_name = name
76 self._attr_type = player_type
77 self._attr_available = True
78 self._attr_powered = True
79 # PLAY_MEDIA is required for the audio/protocol config entries to be emitted.
80 self._attr_supported_features = {PlayerFeature.VOLUME_SET, PlayerFeature.PLAY_MEDIA}
81 self._attr_device_info = DeviceInfo(model="Test Model", manufacturer="Test Manufacturer")
82 self._cache.clear()
83 self.update_state(signal_event=False)
84
85 async def stop(self) -> None:
86 """Stop playback - required abstract method."""
87
88
89async def _setup_parent_with_protocol_child(mass: MusicAssistant) -> _TestPlayer:
90 """Register a native parent player linked to a DLNA protocol child player."""
91 sonos_provider = _TestProvider(mass, "sonos")
92 dlna_provider = _TestProvider(mass, "dlna")
93 mass._providers[sonos_provider.instance_id] = sonos_provider # type: ignore[assignment]
94 mass._providers[dlna_provider.instance_id] = dlna_provider # type: ignore[assignment]
95 mass._provider_manifests[sonos_provider.domain] = sonos_provider.manifest
96 mass._provider_manifests[dlna_provider.domain] = dlna_provider.manifest
97
98 # building the players auto-creates their (real) config roots via create_default_player_config
99 parent = _TestPlayer(sonos_provider, PARENT_ID, "Living Room", PlayerType.PLAYER)
100 child = _TestPlayer(dlna_provider, CHILD_ID, "Living Room DLNA", PlayerType.PROTOCOL)
101
102 mass.players._players[PARENT_ID] = parent
103 mass.players._players[CHILD_ID] = child
104
105 # link the DLNA protocol output to the parent so the virtual prefixed entry is emitted
106 parent.set_linked_output_protocols(
107 [
108 OutputProtocol(
109 output_protocol_id=CHILD_ID,
110 name="DLNA",
111 protocol_domain="dlna",
112 priority=50,
113 )
114 ]
115 )
116 # output_protocols is a cached property populated during __init__'s update_state
117 # (native-only at that point); drop the cache so the freshly linked protocol shows up
118 parent._cache.clear()
119
120 # avoid driving the full player-manager reload machinery
121 mass.players.on_player_config_change = AsyncMock() # type: ignore[method-assign]
122 return parent
123
124
125def _parent_stored_values(mass: MusicAssistant) -> dict[str, Any]:
126 """Return the parent player's raw persisted config values."""
127 raw = mass.config.get(f"players/{PARENT_ID}") or {}
128 values: dict[str, Any] = raw.get("values", {})
129 return values
130
131
132async def test_protocol_prefixed_reset_to_default_sticks(mass: MusicAssistant) -> None:
133 """Resetting a protocol-prefixed value to its default must stick on read-back."""
134 await _setup_parent_with_protocol_child(mass)
135
136 # sanity: the virtual prefixed entry is exposed and defaults to "smart"
137 config = await mass.config.get_player_config(PARENT_ID)
138 assert config.values[PREFIXED_KEY].value == FLOW_MODE_SAMPLE_RATE_SMART
139
140 # set the prefixed value to a non-default, alongside an unrelated parent change
141 await mass.config.save_player_config(
142 PARENT_ID,
143 {PREFIXED_KEY: FLOW_MODE_SAMPLE_RATE_BIT_PERFECT, "tts_pre_announce": False},
144 )
145 config = await mass.config.get_player_config(PARENT_ID)
146 assert config.values[PREFIXED_KEY].value == FLOW_MODE_SAMPLE_RATE_BIT_PERFECT
147 # canonical store is the child protocol player; the parent must not carry a copy
148 assert (
149 mass.config.get_raw_player_config_value(CHILD_ID, CONF_FLOW_MODE_SAMPLE_RATE)
150 == FLOW_MODE_SAMPLE_RATE_BIT_PERFECT
151 )
152 assert PREFIXED_KEY not in _parent_stored_values(mass)
153
154 # reset the prefixed value back to its default
155 await mass.config.save_player_config(PARENT_ID, {PREFIXED_KEY: FLOW_MODE_SAMPLE_RATE_SMART})
156
157 # the child drops the now-default value, and the parent retains no shadowing copy
158 assert mass.config.get_raw_player_config_value(CHILD_ID, CONF_FLOW_MODE_SAMPLE_RATE) is None
159 assert PREFIXED_KEY not in _parent_stored_values(mass)
160 config = await mass.config.get_player_config(PARENT_ID)
161 assert config.values[PREFIXED_KEY].value == FLOW_MODE_SAMPLE_RATE_SMART
162
163
164async def test_stale_prefixed_copy_does_not_shadow_default(mass: MusicAssistant) -> None:
165 """
166 A pre-existing stale prefixed copy on the parent must not shadow the default.
167
168 Covers installs that already accumulated a redundant prefixed value before the
169 fix: the protocol player holds the default (no stored value), so the config must
170 read back as the default regardless of the lingering parent copy.
171 """
172 await _setup_parent_with_protocol_child(mass)
173
174 # simulate a stale copy as written by older versions
175 mass.config.set(f"players/{PARENT_ID}/values/{PREFIXED_KEY}", FLOW_MODE_SAMPLE_RATE_BIT_PERFECT)
176 assert PREFIXED_KEY in _parent_stored_values(mass)
177 # child protocol player is at its default (nothing stored)
178 assert mass.config.get_raw_player_config_value(CHILD_ID, CONF_FLOW_MODE_SAMPLE_RATE) is None
179
180 # read-back must reflect the protocol player's default, not the stale parent copy
181 config = await mass.config.get_player_config(PARENT_ID)
182 assert config.values[PREFIXED_KEY].value == FLOW_MODE_SAMPLE_RATE_SMART
183
184
185async def test_full_form_save_does_not_persist_prefixed_copy_on_parent(
186 mass: MusicAssistant,
187) -> None:
188 """A full-form save must keep protocol-prefixed entries off the parent config."""
189 await _setup_parent_with_protocol_child(mass)
190
191 await mass.config.save_player_config(
192 PARENT_ID,
193 {PREFIXED_KEY: FLOW_MODE_SAMPLE_RATE_BIT_PERFECT, "tts_pre_announce": False},
194 )
195
196 stored = _parent_stored_values(mass)
197 # parent-level (non-protocol) change is fine to persist
198 assert stored.get("tts_pre_announce") is False
199 # protocol-prefixed entries are virtual mirrors of the child and must never live on the parent
200 assert not any(CONF_PROTOCOL_KEY_SPLITTER in key for key in stored)
201
202
203async def test_injected_protocol_entry_resolves_under_origin_provider(
204 mass: MusicAssistant,
205) -> None:
206 """Injected protocol entries carry their origin provider's owner + bare key, not the host's."""
207 await _setup_parent_with_protocol_child(mass)
208
209 # config/players/get (parsed PlayerConfig) path
210 config = await mass.config.get_player_config(PARENT_ID)
211 entry = config.values[PREFIXED_KEY]
212 assert entry.translation_owner == "provider.dlna"
213 assert entry.translation_key == CONF_FLOW_MODE_SAMPLE_RATE
214
215 # config/players/get_entries (raw entries) path
216 entries = await mass.config.get_player_config_entries(PARENT_ID)
217 proto_entry = next(entry for entry in entries if entry.key == PREFIXED_KEY)
218 assert proto_entry.translation_owner == "provider.dlna"
219 assert proto_entry.translation_key == CONF_FLOW_MODE_SAMPLE_RATE
220
221
222async def test_player_entries_resolve_under_their_own_provider(mass: MusicAssistant) -> None:
223 """A player's own entries carry its provider's namespace, so its strings.json is consulted."""
224 await _setup_parent_with_protocol_child(mass)
225
226 entries = await mass.config.get_player_config_entries(PARENT_ID)
227 own_entries = [entry for entry in entries if CONF_PROTOCOL_KEY_SPLITTER not in entry.key]
228
229 assert own_entries
230 assert {entry.translation_owner for entry in own_entries} == {"provider.sonos"}
231