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