/
/
/
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
22
23from music_assistant.constants import (
24 CONF_FLOW_MODE_SAMPLE_RATE,
25 CONF_PREFER_WAV_FOR_LIVE_SOURCES,
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, LinkedOutputProtocol, 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 LinkedOutputProtocol(
109 output_protocol_id=CHILD_ID,
110 protocol_domain="dlna",
111 priority=50,
112 )
113 ]
114 )
115 # output_protocols is a cached property populated during __init__'s update_state
116 # (native-only at that point); drop the cache so the freshly linked protocol shows up
117 parent._cache.clear()
118
119 # avoid driving the full player-manager reload machinery
120 mass.players.on_player_config_change = AsyncMock() # type: ignore[method-assign]
121 return parent
122
123
124def _parent_stored_values(mass: MusicAssistant) -> dict[str, Any]:
125 """Return the parent player's raw persisted config values."""
126 raw = mass.config.get(f"players/{PARENT_ID}") or {}
127 values: dict[str, Any] = raw.get("values", {})
128 return values
129
130
131async def test_protocol_prefixed_reset_to_default_sticks(mass: MusicAssistant) -> None:
132 """Resetting a protocol-prefixed value to its default must stick on read-back."""
133 await _setup_parent_with_protocol_child(mass)
134
135 # sanity: the virtual prefixed entry is exposed and defaults to "smart"
136 config = await mass.config.get_player_config(PARENT_ID)
137 assert config.values[PREFIXED_KEY].value == FLOW_MODE_SAMPLE_RATE_SMART
138
139 # set the prefixed value to a non-default, alongside an unrelated parent change
140 await mass.config.save_player_config(
141 PARENT_ID,
142 {PREFIXED_KEY: FLOW_MODE_SAMPLE_RATE_BIT_PERFECT, "tts_pre_announce": False},
143 )
144 config = await mass.config.get_player_config(PARENT_ID)
145 assert config.values[PREFIXED_KEY].value == FLOW_MODE_SAMPLE_RATE_BIT_PERFECT
146 # canonical store is the child protocol player; the parent must not carry a copy
147 assert (
148 mass.config.get_raw_player_config_value(CHILD_ID, CONF_FLOW_MODE_SAMPLE_RATE)
149 == FLOW_MODE_SAMPLE_RATE_BIT_PERFECT
150 )
151 assert PREFIXED_KEY not in _parent_stored_values(mass)
152
153 # reset the prefixed value back to its default
154 await mass.config.save_player_config(PARENT_ID, {PREFIXED_KEY: FLOW_MODE_SAMPLE_RATE_SMART})
155
156 # the child drops the now-default value, and the parent retains no shadowing copy
157 assert mass.config.get_raw_player_config_value(CHILD_ID, CONF_FLOW_MODE_SAMPLE_RATE) is None
158 assert PREFIXED_KEY not in _parent_stored_values(mass)
159 config = await mass.config.get_player_config(PARENT_ID)
160 assert config.values[PREFIXED_KEY].value == FLOW_MODE_SAMPLE_RATE_SMART
161
162
163async def test_stale_prefixed_copy_does_not_shadow_default(mass: MusicAssistant) -> None:
164 """
165 A pre-existing stale prefixed copy on the parent must not shadow the default.
166
167 Covers installs that already accumulated a redundant prefixed value before the
168 fix: the protocol player holds the default (no stored value), so the config must
169 read back as the default regardless of the lingering parent copy.
170 """
171 await _setup_parent_with_protocol_child(mass)
172
173 # simulate a stale copy as written by older versions
174 mass.config.set(f"players/{PARENT_ID}/values/{PREFIXED_KEY}", FLOW_MODE_SAMPLE_RATE_BIT_PERFECT)
175 assert PREFIXED_KEY in _parent_stored_values(mass)
176 # child protocol player is at its default (nothing stored)
177 assert mass.config.get_raw_player_config_value(CHILD_ID, CONF_FLOW_MODE_SAMPLE_RATE) is None
178
179 # read-back must reflect the protocol player's default, not the stale parent copy
180 config = await mass.config.get_player_config(PARENT_ID)
181 assert config.values[PREFIXED_KEY].value == FLOW_MODE_SAMPLE_RATE_SMART
182
183
184async def test_full_form_save_does_not_persist_prefixed_copy_on_parent(
185 mass: MusicAssistant,
186) -> None:
187 """A full-form save must keep protocol-prefixed entries off the parent config."""
188 await _setup_parent_with_protocol_child(mass)
189
190 await mass.config.save_player_config(
191 PARENT_ID,
192 {PREFIXED_KEY: FLOW_MODE_SAMPLE_RATE_BIT_PERFECT, "tts_pre_announce": False},
193 )
194
195 stored = _parent_stored_values(mass)
196 # parent-level (non-protocol) change is fine to persist
197 assert stored.get("tts_pre_announce") is False
198 # protocol-prefixed entries are virtual mirrors of the child and must never live on the parent
199 assert not any(CONF_PROTOCOL_KEY_SPLITTER in key for key in stored)
200
201
202async def test_injected_protocol_entry_resolves_under_origin_provider(
203 mass: MusicAssistant,
204) -> None:
205 """Injected protocol entries carry their origin provider's owner + bare key, not the host's."""
206 await _setup_parent_with_protocol_child(mass)
207
208 # config/players/get (parsed PlayerConfig) path
209 config = await mass.config.get_player_config(PARENT_ID)
210 entry = config.values[PREFIXED_KEY]
211 assert entry.translation_owner == "provider.dlna"
212 assert entry.translation_key == CONF_FLOW_MODE_SAMPLE_RATE
213
214 # config/players/get_entries (raw entries) path
215 entries = await mass.config.get_player_config_entries(PARENT_ID)
216 proto_entry = next(entry for entry in entries if entry.key == PREFIXED_KEY)
217 assert proto_entry.translation_owner == "provider.dlna"
218 assert proto_entry.translation_key == CONF_FLOW_MODE_SAMPLE_RATE
219
220
221async def test_player_entries_resolve_under_their_own_provider(mass: MusicAssistant) -> None:
222 """A player's own entries carry its provider's namespace, so its strings.json is consulted."""
223 await _setup_parent_with_protocol_child(mass)
224
225 entries = await mass.config.get_player_config_entries(PARENT_ID)
226 own_entries = [entry for entry in entries if CONF_PROTOCOL_KEY_SPLITTER not in entry.key]
227
228 assert own_entries
229 assert {entry.translation_owner for entry in own_entries} == {"provider.sonos"}
230
231
232async def test_live_source_wav_preference_is_only_exposed_for_http_players(
233 mass: MusicAssistant,
234) -> None:
235 """Only HTTP player protocols expose the live source WAV preference."""
236 await _setup_parent_with_protocol_child(mass)
237 parent_entries = await mass.config.get_player_config_entries(PARENT_ID)
238 dlna_key = f"{CHILD_ID}{CONF_PROTOCOL_KEY_SPLITTER}{CONF_PREFER_WAV_FOR_LIVE_SOURCES}"
239 assert next(entry for entry in parent_entries if entry.key == dlna_key).default_value is False
240
241 sendspin_provider = _TestProvider(mass, "sendspin")
242 mass._providers[sendspin_provider.instance_id] = sendspin_provider # type: ignore[assignment]
243 mass._provider_manifests[sendspin_provider.domain] = sendspin_provider.manifest
244 sendspin_player = _TestPlayer(
245 sendspin_provider,
246 "sendspin_1",
247 "Sendspin Player",
248 PlayerType.PROTOCOL,
249 )
250
251 entries = await mass.config._get_player_config_entries(sendspin_player)
252
253 assert CONF_PREFER_WAV_FOR_LIVE_SOURCES not in {entry.key for entry in entries}
254