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