/
/
/
1"""
2Regression tests for the cleanup that runs when a player (or its provider) 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
14Also covers removing a whole player provider: its unregistered players must have their
15DSP/queue settings and persisted queue cache wiped along with their player config,
16while players of other providers are left untouched.
17"""
18
19import logging
20from collections.abc import Callable, Generator
21from types import SimpleNamespace
22from unittest.mock import MagicMock
23
24import pytest
25from music_assistant_models.enums import (
26 PlaybackState,
27 PlayerFeature,
28 PlayerType,
29 ProviderFeature,
30 ProviderType,
31)
32
33from music_assistant.constants import (
34 CONF_PLAYER_DSP,
35 CONF_PLAYER_QUEUES,
36 CONF_PLAYERS,
37 CONF_PROTOCOL_PARENT_ID,
38 CONF_PROVIDERS,
39)
40from music_assistant.controllers.player_queues.constants import (
41 CACHE_CATEGORY_PLAYER_QUEUE_ITEMS,
42 CACHE_CATEGORY_PLAYER_QUEUE_STATE,
43)
44from music_assistant.mass import MusicAssistant
45from music_assistant.models.player import DeviceInfo, Player
46
47PARENT_ID = "up_esp32"
48PROTOCOL_ID = "spb_esp32"
49PLAYER_ID = "test_player_1"
50# a real, non-builtin player provider with no config entries of its own, so a raw
51# provider config can be stored without going through the setup flow; it is
52# single-instance, so its instance id equals its domain
53PLAYER_PROVIDER_DOMAIN = "dlna"
54OTHER_PROVIDER_INSTANCE_ID = "other_provider"
55
56
57class StubProtocolPlayer:
58 """Minimal stand-in for a registered protocol player."""
59
60 def __init__(self, parent_id: str | None) -> None:
61 """Initialize the stub with the given (live) protocol parent."""
62 self.player_id = PROTOCOL_ID
63 # a registered player is looked up and polled by the running server,
64 # so carry the state fields those paths read
65 self.state = SimpleNamespace(
66 type=PlayerType.PROTOCOL,
67 playback_state=PlaybackState.IDLE,
68 available=True,
69 enabled=True,
70 )
71 self.needs_poll = False
72 self.protocol_parent_id = parent_id
73 self.refreshed = False
74 self.provider = SimpleNamespace(instance_id="sendspin")
75
76 def set_protocol_parent_id(self, parent_id: str | None) -> None:
77 """Set the live protocol parent."""
78 self.protocol_parent_id = parent_id
79
80 def refresh_state(self) -> None:
81 """Record that the state was refreshed."""
82 self.refreshed = True
83
84
85@pytest.fixture(name="register_protocol_player")
86def register_protocol_player_fixture(
87 mass: MusicAssistant,
88) -> Generator[Callable[[str | None], StubProtocolPlayer]]:
89 """Register a stub protocol player on the player controller for the test."""
90
91 def _register(parent_id: str | None) -> StubProtocolPlayer:
92 protocol_player = StubProtocolPlayer(parent_id)
93 mass.players._players[PROTOCOL_ID] = protocol_player # type: ignore[assignment]
94 return protocol_player
95
96 yield _register
97 mass.players._players.pop(PROTOCOL_ID, None)
98
99
100def _pop_scheduled_evaluation(mass: MusicAssistant) -> bool:
101 """Return True if a protocol evaluation is pending for the protocol player."""
102 if handle := mass.players._pending_protocol_evaluations.pop(PROTOCOL_ID, None):
103 handle.cancel()
104 return True
105 return False
106
107
108def _store_configs(mass: MusicAssistant, enabled: bool) -> None:
109 """Store a universal player config with a single linked protocol player."""
110 mass.config.set(
111 f"{CONF_PLAYERS}/{PARENT_ID}",
112 {
113 "player_id": PARENT_ID,
114 "provider": "universal_player",
115 "player_type": "player",
116 "enabled": enabled,
117 "values": {"linked_protocol_ids": [PROTOCOL_ID]},
118 },
119 )
120 mass.config.set(
121 f"{CONF_PLAYERS}/{PROTOCOL_ID}",
122 {
123 "player_id": PROTOCOL_ID,
124 "provider": "sendspin",
125 "player_type": "protocol",
126 "enabled": enabled,
127 "values": {CONF_PROTOCOL_PARENT_ID: PARENT_ID},
128 },
129 )
130 mass.config.set(f"{CONF_PLAYER_DSP}/{PROTOCOL_ID}", {"enabled": True})
131
132
133def _store_player_config(
134 mass: MusicAssistant, player_id: str, enabled: bool = False, provider: str = "test_provider"
135) -> None:
136 """Store a plain player config with customised queue settings."""
137 mass.config.set(
138 f"{CONF_PLAYERS}/{player_id}",
139 {
140 "player_id": player_id,
141 "provider": provider,
142 "player_type": "player",
143 "enabled": enabled,
144 "values": {},
145 },
146 )
147 mass.config.set(
148 f"{CONF_PLAYER_QUEUES}/{player_id}",
149 {"queue_id": player_id, "values": {"crossfade_duration": 9}},
150 )
151
152
153def _store_provider_config(mass: MusicAssistant) -> None:
154 """Store a raw config for the player provider under test."""
155 mass.config.set(
156 f"{CONF_PROVIDERS}/{PLAYER_PROVIDER_DOMAIN}",
157 {
158 "type": "player",
159 "domain": PLAYER_PROVIDER_DOMAIN,
160 "instance_id": PLAYER_PROVIDER_DOMAIN,
161 "enabled": True,
162 "name": "DLNA",
163 "values": {},
164 },
165 )
166
167
168async def _store_queue_cache(mass: MusicAssistant, player_id: str) -> None:
169 """Store cached queue state and items for the given player."""
170 for category in (CACHE_CATEGORY_PLAYER_QUEUE_STATE, CACHE_CATEGORY_PLAYER_QUEUE_ITEMS):
171 await mass.cache.set(
172 key=player_id,
173 data={"queue_id": player_id},
174 provider="player_queues",
175 category=category,
176 persistent=True,
177 )
178
179
180async def _get_queue_cache(mass: MusicAssistant, player_id: str) -> list[object]:
181 """Return the cached queue state and items for the given player."""
182 return [
183 await mass.cache.get(key=player_id, provider="player_queues", category=category)
184 for category in (CACHE_CATEGORY_PLAYER_QUEUE_STATE, CACHE_CATEGORY_PLAYER_QUEUE_ITEMS)
185 ]
186
187
188class _TestProvider:
189 """Minimal PlayerProvider stand-in that supports removing its players."""
190
191 def __init__(self, mass: MusicAssistant) -> None:
192 """Initialize the test provider."""
193 self.mass = mass
194 self.domain = "test_provider"
195 self.instance_id = "test_provider"
196 self.name = "Test Provider"
197 self.available = True
198 self.logger = logging.getLogger("test.test_provider")
199 self.manifest = MagicMock()
200 self.manifest.domain = self.domain
201 self.manifest.name = self.name
202 self.manifest.type = ProviderType.PLAYER
203 self.type = ProviderType.PLAYER
204
205 def check_feature(self, feature: ProviderFeature) -> None:
206 """Accept every feature check."""
207
208 async def remove_player(self, player_id: str) -> None:
209 """Remove the player, like a real provider does."""
210 await self.mass.players.unregister(player_id, permanent=True)
211
212 async def unload(self, is_removed: bool = False) -> None:
213 """Unload the provider (nothing to clean up)."""
214
215
216class _TestPlayer(Player):
217 """Minimal player stand-in."""
218
219 def __init__(self, provider: _TestProvider, player_id: str) -> None:
220 """Initialize the test player."""
221 super().__init__(provider, player_id) # type: ignore[arg-type]
222 self._attr_name = "Test Player"
223 self._attr_type = PlayerType.PLAYER
224 self._attr_available = True
225 self._attr_powered = True
226 self._attr_supported_features = {PlayerFeature.VOLUME_SET, PlayerFeature.PLAY_MEDIA}
227 self._attr_device_info = DeviceInfo(model="Test Model", manufacturer="Test Manufacturer")
228 self._cache.clear()
229 self.update_state(signal_event=False)
230
231 async def stop(self) -> None:
232 """Stop playback - required abstract method."""
233
234
235async def test_remove_wipes_unregistered_protocol_configs(mass: MusicAssistant) -> None:
236 """Removing a disabled player also wipes the config of its linked protocol player."""
237 _store_configs(mass, enabled=False)
238
239 await mass.config.remove_player_config(PARENT_ID)
240
241 assert mass.config.get(f"{CONF_PLAYERS}/{PARENT_ID}") is None
242 assert mass.config.get(f"{CONF_PLAYERS}/{PROTOCOL_ID}") is None
243 assert mass.config.get(f"{CONF_PLAYER_DSP}/{PROTOCOL_ID}") is None
244
245
246async def test_remove_wipes_protocol_configs_with_a_half_broken_link(
247 mass: MusicAssistant,
248) -> None:
249 """A protocol player is wiped by its own parent reference, not by the parent's list."""
250 _store_configs(mass, enabled=False)
251 mass.config.set(f"{CONF_PLAYERS}/{PARENT_ID}/values/linked_protocol_ids", [])
252
253 await mass.config.remove_player_config(PARENT_ID)
254
255 assert mass.config.get(f"{CONF_PLAYERS}/{PROTOCOL_ID}") is None
256
257
258async def test_remove_keeps_reparented_protocol_configs(mass: MusicAssistant) -> None:
259 """A protocol player that already moved to another parent keeps its config."""
260 _store_configs(mass, enabled=True)
261 mass.config.set(f"{CONF_PLAYERS}/{PROTOCOL_ID}/values/{CONF_PROTOCOL_PARENT_ID}", "cast_1")
262
263 mass.players.delete_player_config(PARENT_ID)
264
265 assert mass.config.get(f"{CONF_PLAYERS}/{PARENT_ID}") is None
266 assert mass.config.get(f"{CONF_PLAYERS}/{PROTOCOL_ID}") is not None
267
268
269async def test_remove_keeps_registered_protocol_configs(
270 mass: MusicAssistant,
271 register_protocol_player: Callable[[str | None], StubProtocolPlayer],
272) -> None:
273 """A protocol player that is still registered keeps its config to be re-parented."""
274 _store_configs(mass, enabled=True)
275 register_protocol_player(PARENT_ID)
276
277 mass.players.delete_player_config(PARENT_ID)
278
279 assert mass.config.get(f"{CONF_PLAYERS}/{PARENT_ID}") is None
280 assert mass.config.get(f"{CONF_PLAYERS}/{PROTOCOL_ID}") is not None
281 assert mass.config.get(f"{CONF_PLAYER_DSP}/{PROTOCOL_ID}") is not None
282 _pop_scheduled_evaluation(mass)
283
284
285async def test_remove_detaches_registered_protocol_player(
286 mass: MusicAssistant,
287 register_protocol_player: Callable[[str | None], StubProtocolPlayer],
288) -> None:
289 """Removing an unregistered parent detaches its still registered protocol player."""
290 _store_configs(mass, enabled=True)
291 protocol_player = register_protocol_player(PARENT_ID)
292
293 await mass.config.remove_player_config(PARENT_ID)
294
295 assert mass.config.get(f"{CONF_PLAYERS}/{PARENT_ID}") is None
296 assert mass.config.get(f"{CONF_PLAYERS}/{PROTOCOL_ID}") is not None
297 assert protocol_player.protocol_parent_id is None
298 assert protocol_player.refreshed
299 assert mass.config.get(f"{CONF_PLAYERS}/{PROTOCOL_ID}/values/{CONF_PROTOCOL_PARENT_ID}") is None
300 assert _pop_scheduled_evaluation(mass)
301
302
303async def test_remove_detaches_protocol_player_waiting_for_its_parent(
304 mass: MusicAssistant,
305 register_protocol_player: Callable[[str | None], StubProtocolPlayer],
306) -> None:
307 """A protocol player that only has the parent link in its config is detached too."""
308 _store_configs(mass, enabled=True)
309 protocol_player = register_protocol_player(None)
310
311 await mass.config.remove_player_config(PARENT_ID)
312
313 assert mass.config.get(f"{CONF_PLAYERS}/{PROTOCOL_ID}/values/{CONF_PROTOCOL_PARENT_ID}") is None
314 assert protocol_player.protocol_parent_id is None
315 assert _pop_scheduled_evaluation(mass)
316
317
318async def test_remove_leaves_unrelated_protocol_player_alone(
319 mass: MusicAssistant,
320 register_protocol_player: Callable[[str | None], StubProtocolPlayer],
321) -> None:
322 """A protocol player of another parent keeps its link when a player is removed."""
323 _store_configs(mass, enabled=True)
324 protocol_player = register_protocol_player("cast_1")
325
326 mass.players.delete_player_config(PARENT_ID)
327
328 assert protocol_player.protocol_parent_id == "cast_1"
329 assert not _pop_scheduled_evaluation(mass)
330
331
332async def test_remove_config_wipes_queue_config(mass: MusicAssistant) -> None:
333 """Removing the config of an unregistered player also wipes its queue settings."""
334 _store_player_config(mass, PLAYER_ID)
335 await _store_queue_cache(mass, PLAYER_ID)
336
337 await mass.config.remove_player_config(PLAYER_ID)
338
339 assert mass.config.get(f"{CONF_PLAYER_QUEUES}/{PLAYER_ID}") is None
340 assert await _get_queue_cache(mass, PLAYER_ID) == [None, None]
341
342
343async def test_remove_player_wipes_queue_config(mass: MusicAssistant) -> None:
344 """Removing an unregistered player also wipes its queue settings."""
345 _store_player_config(mass, PLAYER_ID)
346 await _store_queue_cache(mass, PLAYER_ID)
347
348 await mass.players.remove(PLAYER_ID)
349
350 assert mass.config.get(f"{CONF_PLAYERS}/{PLAYER_ID}") is None
351 assert mass.config.get(f"{CONF_PLAYER_QUEUES}/{PLAYER_ID}") is None
352 assert await _get_queue_cache(mass, PLAYER_ID) == [None, None]
353
354
355async def test_remove_registered_player_wipes_queue_config(mass: MusicAssistant) -> None:
356 """Removing a registered player also wipes its queue settings and state."""
357 _store_player_config(mass, PLAYER_ID, enabled=True)
358 provider = _TestProvider(mass)
359 player = _TestPlayer(provider, PLAYER_ID)
360 mass.players._players[PLAYER_ID] = player
361 await mass.player_queues.on_player_register(player)
362 await _store_queue_cache(mass, PLAYER_ID)
363
364 await mass.config.remove_player_config(PLAYER_ID)
365
366 assert mass.players.get_player(PLAYER_ID) is None
367 assert mass.player_queues.get(PLAYER_ID) is None
368 assert mass.config.get(f"{CONF_PLAYERS}/{PLAYER_ID}") is None
369 assert mass.config.get(f"{CONF_PLAYER_QUEUES}/{PLAYER_ID}") is None
370 assert await _get_queue_cache(mass, PLAYER_ID) == [None, None]
371
372
373async def test_remove_wipes_queue_config_of_linked_protocol_player(
374 mass: MusicAssistant,
375) -> None:
376 """The queue settings of a wiped protocol player config go along with it."""
377 _store_configs(mass, enabled=False)
378 mass.config.set(
379 f"{CONF_PLAYER_QUEUES}/{PROTOCOL_ID}",
380 {"queue_id": PROTOCOL_ID, "values": {"crossfade_duration": 9}},
381 )
382 await _store_queue_cache(mass, PROTOCOL_ID)
383
384 await mass.config.remove_player_config(PARENT_ID)
385
386 assert mass.config.get(f"{CONF_PLAYER_QUEUES}/{PROTOCOL_ID}") is None
387 assert await _get_queue_cache(mass, PROTOCOL_ID) == [None, None]
388
389
390async def test_remove_keeps_queue_config_of_registered_protocol_player(
391 mass: MusicAssistant,
392 register_protocol_player: Callable[[str | None], StubProtocolPlayer],
393) -> None:
394 """A protocol player that keeps its config also keeps its queue settings and state."""
395 _store_configs(mass, enabled=True)
396 mass.config.set(
397 f"{CONF_PLAYER_QUEUES}/{PROTOCOL_ID}",
398 {"queue_id": PROTOCOL_ID, "values": {"crossfade_duration": 9}},
399 )
400 await _store_queue_cache(mass, PROTOCOL_ID)
401 register_protocol_player(PARENT_ID)
402
403 mass.players.delete_player_config(PARENT_ID)
404
405 assert mass.config.get(f"{CONF_PLAYER_QUEUES}/{PROTOCOL_ID}") is not None
406 assert await _get_queue_cache(mass, PROTOCOL_ID) == [
407 {"queue_id": PROTOCOL_ID},
408 {"queue_id": PROTOCOL_ID},
409 ]
410 _pop_scheduled_evaluation(mass)
411
412
413async def test_remove_provider_config_wipes_unregistered_player_config(
414 mass: MusicAssistant,
415) -> None:
416 """Removing a provider also wipes the DSP/queue settings of its unregistered players."""
417 _store_provider_config(mass)
418 _store_player_config(mass, PLAYER_ID, provider=PLAYER_PROVIDER_DOMAIN)
419 mass.config.set(f"{CONF_PLAYER_DSP}/{PLAYER_ID}", {"enabled": True})
420 await _store_queue_cache(mass, PLAYER_ID)
421
422 await mass.config.remove_provider_config(PLAYER_PROVIDER_DOMAIN)
423
424 assert mass.config.get(f"{CONF_PLAYERS}/{PLAYER_ID}") is None
425 assert mass.config.get(f"{CONF_PLAYER_DSP}/{PLAYER_ID}") is None
426 assert mass.config.get(f"{CONF_PLAYER_QUEUES}/{PLAYER_ID}") is None
427 assert await _get_queue_cache(mass, PLAYER_ID) == [None, None]
428
429
430async def test_remove_provider_config_keeps_other_providers_player_config(
431 mass: MusicAssistant,
432) -> None:
433 """A player belonging to a different provider keeps its config untouched."""
434 _store_provider_config(mass)
435 _store_player_config(mass, PLAYER_ID, provider=OTHER_PROVIDER_INSTANCE_ID)
436 mass.config.set(f"{CONF_PLAYER_DSP}/{PLAYER_ID}", {"enabled": True})
437 await _store_queue_cache(mass, PLAYER_ID)
438
439 await mass.config.remove_provider_config(PLAYER_PROVIDER_DOMAIN)
440
441 assert mass.config.get(f"{CONF_PLAYERS}/{PLAYER_ID}") is not None
442 assert mass.config.get(f"{CONF_PLAYER_DSP}/{PLAYER_ID}") is not None
443 assert mass.config.get(f"{CONF_PLAYER_QUEUES}/{PLAYER_ID}") is not None
444 assert await _get_queue_cache(mass, PLAYER_ID) == [
445 {"queue_id": PLAYER_ID},
446 {"queue_id": PLAYER_ID},
447 ]
448
449
450async def test_remove_provider_config_wipes_linked_protocol_config(
451 mass: MusicAssistant,
452) -> None:
453 """The config of an unregistered protocol player goes along with its parent's provider."""
454 _store_provider_config(mass)
455 _store_configs(mass, enabled=False)
456 mass.config.set(f"{CONF_PLAYERS}/{PARENT_ID}/provider", PLAYER_PROVIDER_DOMAIN)
457
458 await mass.config.remove_provider_config(PLAYER_PROVIDER_DOMAIN)
459
460 assert mass.config.get(f"{CONF_PLAYERS}/{PARENT_ID}") is None
461 assert mass.config.get(f"{CONF_PLAYERS}/{PROTOCOL_ID}") is None
462 assert mass.config.get(f"{CONF_PLAYER_DSP}/{PROTOCOL_ID}") is None
463
464
465async def test_remove_provider_config_detaches_registered_protocol_player(
466 mass: MusicAssistant,
467 register_protocol_player: Callable[[str | None], StubProtocolPlayer],
468) -> None:
469 """A still registered protocol player of another provider is detached, not wiped."""
470 _store_provider_config(mass)
471 _store_configs(mass, enabled=True)
472 mass.config.set(f"{CONF_PLAYERS}/{PARENT_ID}/provider", PLAYER_PROVIDER_DOMAIN)
473 protocol_player = register_protocol_player(PARENT_ID)
474
475 await mass.config.remove_provider_config(PLAYER_PROVIDER_DOMAIN)
476
477 assert mass.config.get(f"{CONF_PLAYERS}/{PARENT_ID}") is None
478 assert mass.config.get(f"{CONF_PLAYERS}/{PROTOCOL_ID}") is not None
479 assert protocol_player.protocol_parent_id is None
480 assert mass.config.get(f"{CONF_PLAYERS}/{PROTOCOL_ID}/values/{CONF_PROTOCOL_PARENT_ID}") is None
481 assert _pop_scheduled_evaluation(mass)
482