/
/
/
1"""Tests for provider unload."""
2
3from __future__ import annotations
4
5import asyncio
6from typing import TYPE_CHECKING
7from unittest.mock import AsyncMock, MagicMock
8
9import pytest
10from music_assistant_models.background_task import TaskSchedule
11from music_assistant_models.config_entries import ProviderConfig
12from music_assistant_models.enums import EventType, MediaType, ProviderType
13from music_assistant_models.errors import LoginFailed
14from music_assistant_models.provider import ProviderManifest
15
16from music_assistant.constants import CONF_PLAYERS
17from music_assistant.controllers.music import MusicController
18from music_assistant.controllers.players import PlayerController
19from music_assistant.controllers.tasks import TasksController
20from music_assistant.controllers.tasks.constants import TASK_UPDATE_TIMER_ID
21from music_assistant.mass import MusicAssistant
22from music_assistant.models.music_provider import MusicProvider
23from music_assistant.models.player import Player
24from music_assistant.models.player_provider import PlayerProvider
25
26if TYPE_CHECKING:
27 from music_assistant_models.config_entries import ProviderError
28
29
30def _make_mass(
31 monkeypatch: pytest.MonkeyPatch,
32) -> tuple[MusicAssistant, list[ProviderError], AsyncMock]:
33 """Return a bare MusicAssistant (bypassing __init__) recording last-error writes."""
34 mass = object.__new__(MusicAssistant)
35 recorded: list[ProviderError] = []
36 config = MagicMock()
37 config.update_provider_last_error = MagicMock(
38 side_effect=lambda _instance_id, error: recorded.append(error)
39 )
40 unload = AsyncMock()
41 monkeypatch.setattr(mass, "config", config, raising=False)
42 monkeypatch.setattr(mass, "unload_provider", unload)
43 return mass, recorded, unload
44
45
46async def test_unload_provider_with_error_preserves_auth_code(
47 monkeypatch: pytest.MonkeyPatch,
48) -> None:
49 """A LoginFailed keeps its error code + translation so the provider shows AUTH_REQUIRED."""
50 mass, recorded, unload = _make_mass(monkeypatch)
51 await mass.unload_provider_with_error("spotify--1", LoginFailed("token revoked"))
52 assert recorded[0].error_code == LoginFailed.error_code
53 assert recorded[0].translation_key == LoginFailed.translation_key
54 unload.assert_awaited_once_with("spotify--1")
55
56
57async def test_unload_provider_with_error_string_is_generic(
58 monkeypatch: pytest.MonkeyPatch,
59) -> None:
60 """A plain string message is recorded as a generic error (code 999)."""
61 mass, recorded, _unload = _make_mass(monkeypatch)
62 await mass.unload_provider_with_error("airplay--1", "daemon failed to start")
63 assert recorded[0].error_code == 999
64 assert recorded[0].message == "daemon failed to start"
65
66
67async def test_unload_provider_waits_for_running_sync(
68 mass_minimal: MusicAssistant,
69 monkeypatch: pytest.MonkeyPatch,
70) -> None:
71 """A provider must not be unloaded while its own library sync is still unwinding."""
72 mass_minimal.tasks = TasksController(mass_minimal)
73 await mass_minimal.tasks.setup(await mass_minimal.config.get_core_config("tasks"))
74 mass_minimal.tasks.initialized.set()
75 mass_minimal.music = MusicController(mass_minimal)
76 # discovery is not set up on the minimal instance and plays no part in this test
77 monkeypatch.setattr(mass_minimal.discovery, "on_provider_unload", MagicMock())
78
79 sync_started = asyncio.Event()
80 sync_finished = False
81 sync_finished_on_unload: bool | None = None
82
83 class SyncingProvider(MusicProvider):
84 """Provider that records the sync state observed by its unload."""
85
86 async def sync_library(self, media_type: MediaType) -> None:
87 """Unused: the sync task handler is registered directly by this test."""
88
89 async def unload(self, is_removed: bool = False) -> None:
90 """Handle unload of the provider."""
91 nonlocal sync_finished_on_unload
92 sync_finished_on_unload = sync_finished
93
94 provider_config = ProviderConfig(
95 values={},
96 type=ProviderType.MUSIC,
97 domain="test_provider",
98 instance_id="test_provider--instance",
99 name="Test provider",
100 )
101 monkeypatch.setattr(provider_config, "get_value", lambda *_args, **_kwargs: "GLOBAL")
102 provider = SyncingProvider(
103 mass_minimal,
104 manifest=ProviderManifest(
105 type=ProviderType.MUSIC,
106 domain="test_provider",
107 name="Test provider",
108 description="Test provider",
109 codeowners=["@music-assistant"],
110 ),
111 config=provider_config,
112 )
113 provider.available = True
114 mass_minimal._providers[provider.instance_id] = provider
115
116 async def sync_handler() -> None:
117 nonlocal sync_finished
118 sync_started.set()
119 try:
120 await asyncio.sleep(30)
121 finally:
122 # cleanup that yields to the event loop, like a sync releasing its resources
123 await asyncio.sleep(0.05)
124 sync_finished = True
125
126 task_id = mass_minimal.music._get_sync_task_id(provider, MediaType.TRACK)
127 mass_minimal.tasks.register_scheduled_task(
128 task_id=task_id,
129 name="Sync tracks",
130 handler=sync_handler,
131 schedule=TaskSchedule.hourly(every=12),
132 )
133 mass_minimal.tasks.run_task(task_id)
134 await asyncio.wait_for(sync_started.wait(), timeout=2)
135
136 try:
137 await mass_minimal.unload_provider(provider.instance_id)
138 finally:
139 mass_minimal.cancel_timer(TASK_UPDATE_TIMER_ID)
140 await mass_minimal.tasks.close()
141
142 assert sync_finished_on_unload is True
143
144
145@pytest.mark.parametrize("is_removed", [False, True])
146async def test_unload_provider_unregisters_hidden_players(
147 mass_minimal: MusicAssistant,
148 monkeypatch: pytest.MonkeyPatch,
149 is_removed: bool,
150) -> None:
151 """
152 Unloading a player provider also unregisters its disabled and initializing players.
153
154 Removing the provider deletes their configs as well, a plain reload keeps them.
155 """
156 mass_minimal.players = PlayerController(mass_minimal)
157 mass_minimal.music = MagicMock(unschedule_provider_sync=AsyncMock())
158 mass_minimal.player_queues = MagicMock()
159 # wiping a player config strips that player from the user access filters
160 mass_minimal.webserver = MagicMock(auth=MagicMock(remove_from_user_filters=AsyncMock()))
161 # discovery is not set up on the minimal instance and plays no part in this test
162 monkeypatch.setattr(mass_minimal.discovery, "on_provider_unload", MagicMock())
163
164 unloaded_players: list[str] = []
165
166 class RecordingPlayer(Player):
167 """Player that records that it was unloaded."""
168
169 async def on_unload(self) -> None:
170 """Handle unload of the player."""
171 unloaded_players.append(self.player_id)
172 await super().on_unload()
173
174 def add_provider(instance_id: str) -> PlayerProvider:
175 provider_config = ProviderConfig(
176 values={},
177 type=ProviderType.PLAYER,
178 domain="test_player_provider",
179 instance_id=instance_id,
180 name="Test player provider",
181 )
182 monkeypatch.setattr(provider_config, "get_value", lambda *_args, **_kwargs: "GLOBAL")
183 provider = PlayerProvider(
184 mass_minimal,
185 manifest=ProviderManifest(
186 type=ProviderType.PLAYER,
187 domain="test_player_provider",
188 name="Test player provider",
189 description="Test player provider",
190 codeowners=["@music-assistant"],
191 ),
192 config=provider_config,
193 )
194 provider.available = True
195 mass_minimal._providers[instance_id] = provider
196 return provider
197
198 def add_player(
199 provider: PlayerProvider,
200 player_id: str,
201 enabled: bool = True,
202 initialized: bool = True,
203 ) -> RecordingPlayer:
204 player = RecordingPlayer(provider, player_id)
205 if initialized:
206 player.set_initialized()
207 if not enabled:
208 # config and state are kept in sync so the player reads as disabled
209 # even if its state gets recalculated from the config
210 player.config.enabled = False
211 player.state.enabled = False
212 mass_minimal.players._players[player_id] = player
213 return player
214
215 provider = add_provider("test_player_provider--instance")
216 other_provider = add_provider("test_player_provider--other")
217 add_player(provider, "enabled_player")
218 # a player provider may deliberately keep a disabled player registered (msx_bridge does)
219 add_player(provider, "disabled_player", enabled=False)
220 # a player that is still being set up when its provider goes away
221 add_player(provider, "initializing_player", initialized=False)
222 other_player = add_player(other_provider, "other_provider_player")
223 provider_player_ids = {"enabled_player", "disabled_player", "initializing_player"}
224 all_player_ids = provider_player_ids | {other_player.player_id}
225
226 try:
227 await mass_minimal.unload_provider(provider.instance_id, is_removed=is_removed)
228 finally:
229 # unregistering schedules a debounced state update for the players that remain
230 mass_minimal.cancel_timer(f"player_update_state_{other_player.player_id}")
231
232 assert set(unloaded_players) == provider_player_ids
233 assert set(mass_minimal.players._players) == {other_player.player_id}
234
235 # a removed provider takes the configs of all its players with it, including the ones
236 # its own players listing hides; a plain reload must leave every config untouched so
237 # the players come back with their settings
238 stored_configs = {
239 player_id
240 for player_id in all_player_ids
241 if mass_minimal.config.get(f"{CONF_PLAYERS}/{player_id}") is not None
242 }
243 assert stored_configs == ({other_player.player_id} if is_removed else all_player_ids)
244
245 # the queue controller is the other consumer of the removal flag: it drops the
246 # persisted queue of a player only when that player is gone for good
247 assert {
248 (mock_call.args[0], mock_call.kwargs["permanent"])
249 for mock_call in mass_minimal.player_queues.on_player_remove.call_args_list
250 } == {(player_id, is_removed) for player_id in provider_player_ids}
251
252
253class _TeardownProvider(PlayerProvider):
254 """Player provider that records whether its own unload ran."""
255
256 unloaded = False
257
258 async def unload(self, is_removed: bool = False) -> None:
259 """Handle unload of the provider."""
260 self.unloaded = True
261
262
263class _TeardownPlayer(Player):
264 """Player that records its teardown and optionally fails it."""
265
266 def __init__(self, provider: PlayerProvider, player_id: str, fails: bool = False) -> None:
267 """
268 Initialize the player.
269
270 :param provider: Player provider this player belongs to.
271 :param player_id: ID of the player.
272 :param fails: Raise from on_unload to simulate a provider that fails to release it.
273 """
274 super().__init__(provider, player_id)
275 self._attr_name = player_id
276 self._attr_available = True
277 self._fails = fails
278 self.unloaded = False
279
280 async def on_unload(self) -> None:
281 """Handle logic when the player is unloaded from the Player controller."""
282 self.unloaded = True
283 if self._fails:
284 msg = "device is gone"
285 raise RuntimeError(msg)
286
287
288def _setup_player_provider(
289 mass: MusicAssistant,
290 monkeypatch: pytest.MonkeyPatch,
291 failing_player_id: str,
292) -> tuple[_TeardownProvider, list[_TeardownPlayer], list[EventType]]:
293 """
294 Wire a minimal mass with one player provider owning three registered players.
295
296 :param mass: Minimal MusicAssistant instance to wire up.
297 :param monkeypatch: Pytest monkeypatch fixture.
298 :param failing_player_id: ID of the player whose on_unload must raise.
299 :return: The provider, its players (in registration order) and the recorded event types.
300 """
301 monkeypatch.setattr(
302 mass, "music", MagicMock(unschedule_provider_sync=AsyncMock()), raising=False
303 )
304 monkeypatch.setattr(mass, "player_queues", MagicMock(), raising=False)
305 monkeypatch.setattr(mass, "_update_available_providers_cache", AsyncMock())
306 monkeypatch.setattr(mass.discovery, "on_provider_unload", MagicMock())
307 signalled: list[EventType] = []
308 monkeypatch.setattr(
309 mass, "signal_event", lambda event, *_args, **_kwargs: signalled.append(event)
310 )
311 monkeypatch.setattr(mass, "players", PlayerController(mass), raising=False)
312
313 provider = _TeardownProvider(
314 mass,
315 manifest=ProviderManifest(
316 type=ProviderType.PLAYER,
317 domain="test_player_provider",
318 name="Test player provider",
319 description="Test player provider",
320 codeowners=["@music-assistant"],
321 ),
322 config=ProviderConfig(
323 values={},
324 type=ProviderType.PLAYER,
325 domain="test_player_provider",
326 instance_id="test_player_provider--instance",
327 name="Test player provider",
328 ),
329 )
330 mass._providers[provider.instance_id] = provider
331
332 players = [
333 _TeardownPlayer(provider, player_id, fails=player_id == failing_player_id)
334 for player_id in ("player_a", "player_b", "player_c")
335 ]
336 for player in players:
337 mass.players._players[player.player_id] = player
338 player.set_initialized()
339 return provider, players, signalled
340
341
342async def test_failing_player_teardown_does_not_strand_the_others(
343 mass_minimal: MusicAssistant,
344 monkeypatch: pytest.MonkeyPatch,
345) -> None:
346 """A player that fails to release must not block the rest of the provider unload."""
347 provider, players, signalled = _setup_player_provider(mass_minimal, monkeypatch, "player_a")
348
349 await mass_minimal.unload_provider(provider.instance_id)
350
351 assert all(player.unloaded for player in players)
352 assert mass_minimal.players._players == {}
353 assert provider.unloaded
354 assert provider.instance_id not in mass_minimal._providers
355 assert EventType.PROVIDERS_UPDATED in signalled
356
357
358async def test_failing_unregister_still_deregisters_the_provider(
359 mass_minimal: MusicAssistant,
360 monkeypatch: pytest.MonkeyPatch,
361) -> None:
362 """An unregister that raises must still leave the provider deregistered and signalled."""
363 provider, _players, signalled = _setup_player_provider(mass_minimal, monkeypatch, "player_a")
364 monkeypatch.setattr(
365 mass_minimal.players, "unregister", AsyncMock(side_effect=RuntimeError("teardown blew up"))
366 )
367
368 await mass_minimal.unload_provider(provider.instance_id)
369
370 assert provider.instance_id not in mass_minimal._providers
371 assert EventType.PROVIDERS_UPDATED in signalled
372
373
374async def _setup_bare_player_provider(
375 mass: MusicAssistant, monkeypatch: pytest.MonkeyPatch
376) -> PlayerProvider:
377 """Put a bare player provider on a minimal mass instance, ready to be unloaded."""
378 mass.players = PlayerController(mass)
379 mass.music = MagicMock(unschedule_provider_sync=AsyncMock())
380 # no queues exist in these tests, so get() must report a miss rather than a mock
381 mass.player_queues = MagicMock(on_player_register=AsyncMock(), get=MagicMock(return_value=None))
382 # discovery is not set up on the minimal instance and plays no part in these tests
383 monkeypatch.setattr(mass.discovery, "on_provider_unload", MagicMock())
384 # a registration reads the player's cached power state, so the cache has to work here
385 # for it to run all the way to the point where the player enters the registry
386 await mass.cache.setup(await mass.config.get_core_config("cache"))
387
388 provider_config = ProviderConfig(
389 values={},
390 type=ProviderType.PLAYER,
391 domain="test_player_provider",
392 instance_id="test_player_provider--instance",
393 name="Test player provider",
394 )
395 monkeypatch.setattr(provider_config, "get_value", lambda *_args, **_kwargs: "GLOBAL")
396 provider = PlayerProvider(
397 mass,
398 manifest=ProviderManifest(
399 type=ProviderType.PLAYER,
400 domain="test_player_provider",
401 name="Test player provider",
402 description="Test player provider",
403 codeowners=["@music-assistant"],
404 ),
405 config=provider_config,
406 )
407 provider.available = True
408 mass._providers[provider.instance_id] = provider
409 return provider
410
411
412async def test_unload_provider_rejects_late_player_registration(
413 mass_minimal: MusicAssistant,
414 monkeypatch: pytest.MonkeyPatch,
415) -> None:
416 """A player provider on its way out can no longer register players."""
417 provider = await _setup_bare_player_provider(mass_minimal, monkeypatch)
418 monkeypatch.setattr(
419 "music_assistant.controllers.players.controller.enrich_device_mac_address",
420 AsyncMock(),
421 )
422
423 late_player = Player(provider, "late_player")
424
425 class LateRegisteringPlayer(Player):
426 """Player whose unload lets a discovery callback register another player."""
427
428 async def on_unload(self) -> None:
429 """Handle unload of the player."""
430 await super().on_unload()
431 # stands in for a discovery that was already running when the unload started
432 # and only reaches the controller once the players have been unregistered
433 await self.mass.players.register_or_update(late_player)
434
435 player = LateRegisteringPlayer(provider, "existing_player")
436 player.set_initialized()
437 mass_minimal.players._players[player.player_id] = player
438
439 await mass_minimal.unload_provider(provider.instance_id)
440
441 # without the guard the late player survives the unload with no provider behind it,
442 # so it is never unregistered and its on_unload never runs
443 assert mass_minimal.players._players == {}
444
445
446async def test_unload_provider_rejects_in_flight_player_registration(
447 mass_minimal: MusicAssistant,
448 monkeypatch: pytest.MonkeyPatch,
449) -> None:
450 """A registration that is already running when the unload starts is dropped as well."""
451 provider = await _setup_bare_player_provider(mass_minimal, monkeypatch)
452
453 registration_started = asyncio.Event()
454 resume_registration = asyncio.Event()
455
456 async def _blocked_enrich(*_args: object, **_kwargs: object) -> None:
457 """Park the registration in one of its awaits until the test releases it."""
458 registration_started.set()
459 await resume_registration.wait()
460
461 monkeypatch.setattr(
462 "music_assistant.controllers.players.controller.enrich_device_mac_address",
463 _blocked_enrich,
464 )
465
466 register = asyncio.create_task(mass_minimal.players.register(Player(provider, "in_flight")))
467 await asyncio.wait_for(registration_started.wait(), timeout=5)
468
469 # start the unload with the registration parked, and release it once the provider is
470 # flagged: the player is not in the registry yet, so the unregister pass cannot see it
471 unload = asyncio.create_task(mass_minimal.unload_provider(provider.instance_id))
472 async with asyncio.timeout(5):
473 while not provider.unloading:
474 await asyncio.sleep(0)
475 resume_registration.set()
476 await asyncio.gather(register, unload)
477
478 assert mass_minimal.players._players == {}
479