/
/
/
1"""
2Regression tests for the cleanup that runs when a player is removed.
3
4Reproduces the case where a player is first disabled and then removed: disabling
5cascades to the linked protocol players, so none of them is registered anymore when
6the removal comes in. The leftover (disabled) protocol config is not shown anywhere
7and keeps the device from ever registering again, and the leftover queue settings and
8queue state are silently inherited by a device that returns under the same player id.
9
10Also covers the mirrored case where the player being removed is not registered (e.g.
11its provider was unloaded) while one of its protocol players still is: that protocol
12player must be detached from the removed player instead of keeping a dead parent link.
13"""
14
15import logging
16from collections.abc import Callable, Generator
17from types import SimpleNamespace
18from unittest.mock import MagicMock
19
20import pytest
21from music_assistant_models.enums import (
22 PlaybackState,
23 PlayerFeature,
24 PlayerType,
25 ProviderFeature,
26 ProviderType,
27)
28
29from music_assistant.constants import (
30 CONF_PLAYER_DSP,
31 CONF_PLAYER_QUEUES,
32 CONF_PLAYERS,
33 CONF_PROTOCOL_PARENT_ID,
34)
35from music_assistant.controllers.player_queues.constants import (
36 CACHE_CATEGORY_PLAYER_QUEUE_ITEMS,
37 CACHE_CATEGORY_PLAYER_QUEUE_STATE,
38)
39from music_assistant.mass import MusicAssistant
40from music_assistant.models.player import DeviceInfo, Player
41
42PARENT_ID = "up_esp32"
43PROTOCOL_ID = "spb_esp32"
44PLAYER_ID = "test_player_1"
45
46
47class StubProtocolPlayer:
48 """Minimal stand-in for a registered protocol player."""
49
50 def __init__(self, parent_id: str | None) -> None:
51 """Initialize the stub with the given (live) protocol parent."""
52 self.player_id = PROTOCOL_ID
53 # a registered player is looked up and polled by the running server,
54 # so carry the state fields those paths read
55 self.state = SimpleNamespace(
56 type=PlayerType.PROTOCOL,
57 playback_state=PlaybackState.IDLE,
58 available=True,
59 enabled=True,
60 )
61 self.needs_poll = False
62 self.protocol_parent_id = parent_id
63 self.refreshed = False
64
65 def set_protocol_parent_id(self, parent_id: str | None) -> None:
66 """Set the live protocol parent."""
67 self.protocol_parent_id = parent_id
68
69 def refresh_state(self) -> None:
70 """Record that the state was refreshed."""
71 self.refreshed = True
72
73
74@pytest.fixture(name="register_protocol_player")
75def register_protocol_player_fixture(
76 mass: MusicAssistant,
77) -> Generator[Callable[[str | None], StubProtocolPlayer]]:
78 """Register a stub protocol player on the player controller for the test."""
79
80 def _register(parent_id: str | None) -> StubProtocolPlayer:
81 protocol_player = StubProtocolPlayer(parent_id)
82 mass.players._players[PROTOCOL_ID] = protocol_player # type: ignore[assignment]
83 return protocol_player
84
85 yield _register
86 mass.players._players.pop(PROTOCOL_ID, None)
87
88
89def _pop_scheduled_evaluation(mass: MusicAssistant) -> bool:
90 """Return True if a protocol evaluation is pending for the protocol player."""
91 if handle := mass.players._pending_protocol_evaluations.pop(PROTOCOL_ID, None):
92 handle.cancel()
93 return True
94 return False
95
96
97def _store_configs(mass: MusicAssistant, enabled: bool) -> None:
98 """Store a universal player config with a single linked protocol player."""
99 mass.config.set(
100 f"{CONF_PLAYERS}/{PARENT_ID}",
101 {
102 "player_id": PARENT_ID,
103 "provider": "universal_player",
104 "player_type": "player",
105 "enabled": enabled,
106 "values": {"linked_protocol_ids": [PROTOCOL_ID]},
107 },
108 )
109 mass.config.set(
110 f"{CONF_PLAYERS}/{PROTOCOL_ID}",
111 {
112 "player_id": PROTOCOL_ID,
113 "provider": "sendspin",
114 "player_type": "protocol",
115 "enabled": enabled,
116 "values": {CONF_PROTOCOL_PARENT_ID: PARENT_ID},
117 },
118 )
119 mass.config.set(f"{CONF_PLAYER_DSP}/{PROTOCOL_ID}", {"enabled": True})
120
121
122def _store_player_config(mass: MusicAssistant, player_id: str, enabled: bool = False) -> None:
123 """Store a plain player config with customised queue settings."""
124 mass.config.set(
125 f"{CONF_PLAYERS}/{player_id}",
126 {
127 "player_id": player_id,
128 "provider": "test_provider",
129 "player_type": "player",
130 "enabled": enabled,
131 "values": {},
132 },
133 )
134 mass.config.set(
135 f"{CONF_PLAYER_QUEUES}/{player_id}",
136 {"queue_id": player_id, "values": {"crossfade_duration": 9}},
137 )
138
139
140async def _store_queue_cache(mass: MusicAssistant, player_id: str) -> None:
141 """Store cached queue state and items for the given player."""
142 for category in (CACHE_CATEGORY_PLAYER_QUEUE_STATE, CACHE_CATEGORY_PLAYER_QUEUE_ITEMS):
143 await mass.cache.set(
144 key=player_id,
145 data={"queue_id": player_id},
146 provider="player_queues",
147 category=category,
148 persistent=True,
149 )
150
151
152async def _get_queue_cache(mass: MusicAssistant, player_id: str) -> list[object]:
153 """Return the cached queue state and items for the given player."""
154 return [
155 await mass.cache.get(key=player_id, provider="player_queues", category=category)
156 for category in (CACHE_CATEGORY_PLAYER_QUEUE_STATE, CACHE_CATEGORY_PLAYER_QUEUE_ITEMS)
157 ]
158
159
160class _TestProvider:
161 """Minimal PlayerProvider stand-in that supports removing its players."""
162
163 def __init__(self, mass: MusicAssistant) -> None:
164 """Initialize the test provider."""
165 self.mass = mass
166 self.domain = "test_provider"
167 self.instance_id = "test_provider"
168 self.name = "Test Provider"
169 self.available = True
170 self.logger = logging.getLogger("test.test_provider")
171 self.manifest = MagicMock()
172 self.manifest.domain = self.domain
173 self.manifest.name = self.name
174 self.manifest.type = ProviderType.PLAYER
175 self.type = ProviderType.PLAYER
176
177 def check_feature(self, feature: ProviderFeature) -> None:
178 """Accept every feature check."""
179
180 async def remove_player(self, player_id: str) -> None:
181 """Remove the player, like a real provider does."""
182 await self.mass.players.unregister(player_id, permanent=True)
183
184 async def unload(self, is_removed: bool = False) -> None:
185 """Unload the provider (nothing to clean up)."""
186
187
188class _TestPlayer(Player):
189 """Minimal player stand-in."""
190
191 def __init__(self, provider: _TestProvider, player_id: str) -> None:
192 """Initialize the test player."""
193 super().__init__(provider, player_id) # type: ignore[arg-type]
194 self._attr_name = "Test Player"
195 self._attr_type = PlayerType.PLAYER
196 self._attr_available = True
197 self._attr_powered = True
198 self._attr_supported_features = {PlayerFeature.VOLUME_SET, PlayerFeature.PLAY_MEDIA}
199 self._attr_device_info = DeviceInfo(model="Test Model", manufacturer="Test Manufacturer")
200 self._cache.clear()
201 self.update_state(signal_event=False)
202
203 async def stop(self) -> None:
204 """Stop playback - required abstract method."""
205
206
207async def test_remove_wipes_unregistered_protocol_configs(mass: MusicAssistant) -> None:
208 """Removing a disabled player also wipes the config of its linked protocol player."""
209 _store_configs(mass, enabled=False)
210
211 await mass.config.remove_player_config(PARENT_ID)
212
213 assert mass.config.get(f"{CONF_PLAYERS}/{PARENT_ID}") is None
214 assert mass.config.get(f"{CONF_PLAYERS}/{PROTOCOL_ID}") is None
215 assert mass.config.get(f"{CONF_PLAYER_DSP}/{PROTOCOL_ID}") is None
216
217
218async def test_remove_wipes_protocol_configs_with_a_half_broken_link(
219 mass: MusicAssistant,
220) -> None:
221 """A protocol player is wiped by its own parent reference, not by the parent's list."""
222 _store_configs(mass, enabled=False)
223 mass.config.set(f"{CONF_PLAYERS}/{PARENT_ID}/values/linked_protocol_ids", [])
224
225 await mass.config.remove_player_config(PARENT_ID)
226
227 assert mass.config.get(f"{CONF_PLAYERS}/{PROTOCOL_ID}") is None
228
229
230async def test_remove_keeps_reparented_protocol_configs(mass: MusicAssistant) -> None:
231 """A protocol player that already moved to another parent keeps its config."""
232 _store_configs(mass, enabled=True)
233 mass.config.set(f"{CONF_PLAYERS}/{PROTOCOL_ID}/values/{CONF_PROTOCOL_PARENT_ID}", "cast_1")
234
235 mass.players.delete_player_config(PARENT_ID)
236
237 assert mass.config.get(f"{CONF_PLAYERS}/{PARENT_ID}") is None
238 assert mass.config.get(f"{CONF_PLAYERS}/{PROTOCOL_ID}") is not None
239
240
241async def test_remove_keeps_registered_protocol_configs(
242 mass: MusicAssistant,
243 register_protocol_player: Callable[[str | None], StubProtocolPlayer],
244) -> None:
245 """A protocol player that is still registered keeps its config to be re-parented."""
246 _store_configs(mass, enabled=True)
247 register_protocol_player(PARENT_ID)
248
249 mass.players.delete_player_config(PARENT_ID)
250
251 assert mass.config.get(f"{CONF_PLAYERS}/{PARENT_ID}") is None
252 assert mass.config.get(f"{CONF_PLAYERS}/{PROTOCOL_ID}") is not None
253 assert mass.config.get(f"{CONF_PLAYER_DSP}/{PROTOCOL_ID}") is not None
254 _pop_scheduled_evaluation(mass)
255
256
257async def test_remove_detaches_registered_protocol_player(
258 mass: MusicAssistant,
259 register_protocol_player: Callable[[str | None], StubProtocolPlayer],
260) -> None:
261 """Removing an unregistered parent detaches its still registered protocol player."""
262 _store_configs(mass, enabled=True)
263 protocol_player = register_protocol_player(PARENT_ID)
264
265 await mass.config.remove_player_config(PARENT_ID)
266
267 assert mass.config.get(f"{CONF_PLAYERS}/{PARENT_ID}") is None
268 assert mass.config.get(f"{CONF_PLAYERS}/{PROTOCOL_ID}") is not None
269 assert protocol_player.protocol_parent_id is None
270 assert protocol_player.refreshed
271 assert mass.config.get(f"{CONF_PLAYERS}/{PROTOCOL_ID}/values/{CONF_PROTOCOL_PARENT_ID}") is None
272 assert _pop_scheduled_evaluation(mass)
273
274
275async def test_remove_detaches_protocol_player_waiting_for_its_parent(
276 mass: MusicAssistant,
277 register_protocol_player: Callable[[str | None], StubProtocolPlayer],
278) -> None:
279 """A protocol player that only has the parent link in its config is detached too."""
280 _store_configs(mass, enabled=True)
281 protocol_player = register_protocol_player(None)
282
283 await mass.config.remove_player_config(PARENT_ID)
284
285 assert mass.config.get(f"{CONF_PLAYERS}/{PROTOCOL_ID}/values/{CONF_PROTOCOL_PARENT_ID}") is None
286 assert protocol_player.protocol_parent_id is None
287 assert _pop_scheduled_evaluation(mass)
288
289
290async def test_remove_leaves_unrelated_protocol_player_alone(
291 mass: MusicAssistant,
292 register_protocol_player: Callable[[str | None], StubProtocolPlayer],
293) -> None:
294 """A protocol player of another parent keeps its link when a player is removed."""
295 _store_configs(mass, enabled=True)
296 protocol_player = register_protocol_player("cast_1")
297
298 mass.players.delete_player_config(PARENT_ID)
299
300 assert protocol_player.protocol_parent_id == "cast_1"
301 assert not _pop_scheduled_evaluation(mass)
302
303
304async def test_remove_config_wipes_queue_config(mass: MusicAssistant) -> None:
305 """Removing the config of an unregistered player also wipes its queue settings."""
306 _store_player_config(mass, PLAYER_ID)
307 await _store_queue_cache(mass, PLAYER_ID)
308
309 await mass.config.remove_player_config(PLAYER_ID)
310
311 assert mass.config.get(f"{CONF_PLAYER_QUEUES}/{PLAYER_ID}") is None
312 assert await _get_queue_cache(mass, PLAYER_ID) == [None, None]
313
314
315async def test_remove_player_wipes_queue_config(mass: MusicAssistant) -> None:
316 """Removing an unregistered player also wipes its queue settings."""
317 _store_player_config(mass, PLAYER_ID)
318 await _store_queue_cache(mass, PLAYER_ID)
319
320 await mass.players.remove(PLAYER_ID)
321
322 assert mass.config.get(f"{CONF_PLAYERS}/{PLAYER_ID}") is None
323 assert mass.config.get(f"{CONF_PLAYER_QUEUES}/{PLAYER_ID}") is None
324 assert await _get_queue_cache(mass, PLAYER_ID) == [None, None]
325
326
327async def test_remove_registered_player_wipes_queue_config(mass: MusicAssistant) -> None:
328 """Removing a registered player also wipes its queue settings and state."""
329 _store_player_config(mass, PLAYER_ID, enabled=True)
330 provider = _TestProvider(mass)
331 player = _TestPlayer(provider, PLAYER_ID)
332 mass.players._players[PLAYER_ID] = player
333 await mass.player_queues.on_player_register(player)
334 await _store_queue_cache(mass, PLAYER_ID)
335
336 await mass.config.remove_player_config(PLAYER_ID)
337
338 assert mass.players.get_player(PLAYER_ID) is None
339 assert mass.player_queues.get(PLAYER_ID) is None
340 assert mass.config.get(f"{CONF_PLAYERS}/{PLAYER_ID}") is None
341 assert mass.config.get(f"{CONF_PLAYER_QUEUES}/{PLAYER_ID}") is None
342 assert await _get_queue_cache(mass, PLAYER_ID) == [None, None]
343
344
345async def test_remove_wipes_queue_config_of_linked_protocol_player(
346 mass: MusicAssistant,
347) -> None:
348 """The queue settings of a wiped protocol player config go along with it."""
349 _store_configs(mass, enabled=False)
350 mass.config.set(
351 f"{CONF_PLAYER_QUEUES}/{PROTOCOL_ID}",
352 {"queue_id": PROTOCOL_ID, "values": {"crossfade_duration": 9}},
353 )
354 await _store_queue_cache(mass, PROTOCOL_ID)
355
356 await mass.config.remove_player_config(PARENT_ID)
357
358 assert mass.config.get(f"{CONF_PLAYER_QUEUES}/{PROTOCOL_ID}") is None
359 assert await _get_queue_cache(mass, PROTOCOL_ID) == [None, None]
360
361
362async def test_remove_keeps_queue_config_of_registered_protocol_player(
363 mass: MusicAssistant,
364 register_protocol_player: Callable[[str | None], StubProtocolPlayer],
365) -> None:
366 """A protocol player that keeps its config also keeps its queue settings and state."""
367 _store_configs(mass, enabled=True)
368 mass.config.set(
369 f"{CONF_PLAYER_QUEUES}/{PROTOCOL_ID}",
370 {"queue_id": PROTOCOL_ID, "values": {"crossfade_duration": 9}},
371 )
372 await _store_queue_cache(mass, PROTOCOL_ID)
373 register_protocol_player(PARENT_ID)
374
375 mass.players.delete_player_config(PARENT_ID)
376
377 assert mass.config.get(f"{CONF_PLAYER_QUEUES}/{PROTOCOL_ID}") is not None
378 assert await _get_queue_cache(mass, PROTOCOL_ID) == [
379 {"queue_id": PROTOCOL_ID},
380 {"queue_id": PROTOCOL_ID},
381 ]
382 _pop_scheduled_evaluation(mass)
383