/
/
/
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 # discovery is not set up on the minimal instance and plays no part in this test
160 monkeypatch.setattr(mass_minimal.discovery, "on_provider_unload", MagicMock())
161
162 unloaded_players: list[str] = []
163
164 class RecordingPlayer(Player):
165 """Player that records that it was unloaded."""
166
167 async def on_unload(self) -> None:
168 """Handle unload of the player."""
169 unloaded_players.append(self.player_id)
170 await super().on_unload()
171
172 def add_provider(instance_id: str) -> PlayerProvider:
173 provider_config = ProviderConfig(
174 values={},
175 type=ProviderType.PLAYER,
176 domain="test_player_provider",
177 instance_id=instance_id,
178 name="Test player provider",
179 )
180 monkeypatch.setattr(provider_config, "get_value", lambda *_args, **_kwargs: "GLOBAL")
181 provider = PlayerProvider(
182 mass_minimal,
183 manifest=ProviderManifest(
184 type=ProviderType.PLAYER,
185 domain="test_player_provider",
186 name="Test player provider",
187 description="Test player provider",
188 codeowners=["@music-assistant"],
189 ),
190 config=provider_config,
191 )
192 provider.available = True
193 mass_minimal._providers[instance_id] = provider
194 return provider
195
196 def add_player(
197 provider: PlayerProvider,
198 player_id: str,
199 enabled: bool = True,
200 initialized: bool = True,
201 ) -> RecordingPlayer:
202 player = RecordingPlayer(provider, player_id)
203 if initialized:
204 player.set_initialized()
205 if not enabled:
206 # config and state are kept in sync so the player reads as disabled
207 # even if its state gets recalculated from the config
208 player.config.enabled = False
209 player.state.enabled = False
210 mass_minimal.players._players[player_id] = player
211 return player
212
213 provider = add_provider("test_player_provider--instance")
214 other_provider = add_provider("test_player_provider--other")
215 add_player(provider, "enabled_player")
216 # a player provider may deliberately keep a disabled player registered (msx_bridge does)
217 add_player(provider, "disabled_player", enabled=False)
218 # a player that is still being set up when its provider goes away
219 add_player(provider, "initializing_player", initialized=False)
220 other_player = add_player(other_provider, "other_provider_player")
221 provider_player_ids = {"enabled_player", "disabled_player", "initializing_player"}
222 all_player_ids = provider_player_ids | {other_player.player_id}
223
224 try:
225 await mass_minimal.unload_provider(provider.instance_id, is_removed=is_removed)
226 finally:
227 # unregistering schedules a debounced state update for the players that remain
228 mass_minimal.cancel_timer(f"player_update_state_{other_player.player_id}")
229
230 assert set(unloaded_players) == provider_player_ids
231 assert set(mass_minimal.players._players) == {other_player.player_id}
232
233 # a removed provider takes the configs of all its players with it, including the ones
234 # its own players listing hides; a plain reload must leave every config untouched so
235 # the players come back with their settings
236 stored_configs = {
237 player_id
238 for player_id in all_player_ids
239 if mass_minimal.config.get(f"{CONF_PLAYERS}/{player_id}") is not None
240 }
241 assert stored_configs == ({other_player.player_id} if is_removed else all_player_ids)
242
243 # the queue controller is the other consumer of the removal flag: it drops the
244 # persisted queue of a player only when that player is gone for good
245 assert {
246 (mock_call.args[0], mock_call.kwargs["permanent"])
247 for mock_call in mass_minimal.player_queues.on_player_remove.call_args_list
248 } == {(player_id, is_removed) for player_id in provider_player_ids}
249
250
251class _TeardownProvider(PlayerProvider):
252 """Player provider that records whether its own unload ran."""
253
254 unloaded = False
255
256 async def unload(self, is_removed: bool = False) -> None:
257 """Handle unload of the provider."""
258 self.unloaded = True
259
260
261class _TeardownPlayer(Player):
262 """Player that records its teardown and optionally fails it."""
263
264 def __init__(self, provider: PlayerProvider, player_id: str, fails: bool = False) -> None:
265 """
266 Initialize the player.
267
268 :param provider: Player provider this player belongs to.
269 :param player_id: ID of the player.
270 :param fails: Raise from on_unload to simulate a provider that fails to release it.
271 """
272 super().__init__(provider, player_id)
273 self._attr_name = player_id
274 self._attr_available = True
275 self._fails = fails
276 self.unloaded = False
277
278 async def on_unload(self) -> None:
279 """Handle logic when the player is unloaded from the Player controller."""
280 self.unloaded = True
281 if self._fails:
282 msg = "device is gone"
283 raise RuntimeError(msg)
284
285
286def _setup_player_provider(
287 mass: MusicAssistant,
288 monkeypatch: pytest.MonkeyPatch,
289 failing_player_id: str,
290) -> tuple[_TeardownProvider, list[_TeardownPlayer], list[EventType]]:
291 """
292 Wire a minimal mass with one player provider owning three registered players.
293
294 :param mass: Minimal MusicAssistant instance to wire up.
295 :param monkeypatch: Pytest monkeypatch fixture.
296 :param failing_player_id: ID of the player whose on_unload must raise.
297 :return: The provider, its players (in registration order) and the recorded event types.
298 """
299 monkeypatch.setattr(
300 mass, "music", MagicMock(unschedule_provider_sync=AsyncMock()), raising=False
301 )
302 monkeypatch.setattr(mass, "player_queues", MagicMock(), raising=False)
303 monkeypatch.setattr(mass, "_update_available_providers_cache", AsyncMock())
304 monkeypatch.setattr(mass.discovery, "on_provider_unload", MagicMock())
305 signalled: list[EventType] = []
306 monkeypatch.setattr(
307 mass, "signal_event", lambda event, *_args, **_kwargs: signalled.append(event)
308 )
309 monkeypatch.setattr(mass, "players", PlayerController(mass), raising=False)
310
311 provider = _TeardownProvider(
312 mass,
313 manifest=ProviderManifest(
314 type=ProviderType.PLAYER,
315 domain="test_player_provider",
316 name="Test player provider",
317 description="Test player provider",
318 codeowners=["@music-assistant"],
319 ),
320 config=ProviderConfig(
321 values={},
322 type=ProviderType.PLAYER,
323 domain="test_player_provider",
324 instance_id="test_player_provider--instance",
325 name="Test player provider",
326 ),
327 )
328 mass._providers[provider.instance_id] = provider
329
330 players = [
331 _TeardownPlayer(provider, player_id, fails=player_id == failing_player_id)
332 for player_id in ("player_a", "player_b", "player_c")
333 ]
334 for player in players:
335 mass.players._players[player.player_id] = player
336 player.set_initialized()
337 return provider, players, signalled
338
339
340async def test_failing_player_teardown_does_not_strand_the_others(
341 mass_minimal: MusicAssistant,
342 monkeypatch: pytest.MonkeyPatch,
343) -> None:
344 """A player that fails to release must not block the rest of the provider unload."""
345 provider, players, signalled = _setup_player_provider(mass_minimal, monkeypatch, "player_a")
346
347 await mass_minimal.unload_provider(provider.instance_id)
348
349 assert all(player.unloaded for player in players)
350 assert mass_minimal.players._players == {}
351 assert provider.unloaded
352 assert provider.instance_id not in mass_minimal._providers
353 assert EventType.PROVIDERS_UPDATED in signalled
354
355
356async def test_failing_unregister_still_deregisters_the_provider(
357 mass_minimal: MusicAssistant,
358 monkeypatch: pytest.MonkeyPatch,
359) -> None:
360 """An unregister that raises must still leave the provider deregistered and signalled."""
361 provider, _players, signalled = _setup_player_provider(mass_minimal, monkeypatch, "player_a")
362 monkeypatch.setattr(
363 mass_minimal.players, "unregister", AsyncMock(side_effect=RuntimeError("teardown blew up"))
364 )
365
366 await mass_minimal.unload_provider(provider.instance_id)
367
368 assert provider.instance_id not in mass_minimal._providers
369 assert EventType.PROVIDERS_UPDATED in signalled
370
371
372async def _setup_bare_player_provider(
373 mass: MusicAssistant, monkeypatch: pytest.MonkeyPatch
374) -> PlayerProvider:
375 """Put a bare player provider on a minimal mass instance, ready to be unloaded."""
376 mass.players = PlayerController(mass)
377 mass.music = MagicMock(unschedule_provider_sync=AsyncMock())
378 # no queues exist in these tests, so get() must report a miss rather than a mock
379 mass.player_queues = MagicMock(on_player_register=AsyncMock(), get=MagicMock(return_value=None))
380 # discovery is not set up on the minimal instance and plays no part in these tests
381 monkeypatch.setattr(mass.discovery, "on_provider_unload", MagicMock())
382 # a registration reads the player's cached power state, so the cache has to work here
383 # for it to run all the way to the point where the player enters the registry
384 await mass.cache.setup(await mass.config.get_core_config("cache"))
385
386 provider_config = ProviderConfig(
387 values={},
388 type=ProviderType.PLAYER,
389 domain="test_player_provider",
390 instance_id="test_player_provider--instance",
391 name="Test player provider",
392 )
393 monkeypatch.setattr(provider_config, "get_value", lambda *_args, **_kwargs: "GLOBAL")
394 provider = PlayerProvider(
395 mass,
396 manifest=ProviderManifest(
397 type=ProviderType.PLAYER,
398 domain="test_player_provider",
399 name="Test player provider",
400 description="Test player provider",
401 codeowners=["@music-assistant"],
402 ),
403 config=provider_config,
404 )
405 provider.available = True
406 mass._providers[provider.instance_id] = provider
407 return provider
408
409
410async def test_unload_provider_rejects_late_player_registration(
411 mass_minimal: MusicAssistant,
412 monkeypatch: pytest.MonkeyPatch,
413) -> None:
414 """A player provider on its way out can no longer register players."""
415 provider = await _setup_bare_player_provider(mass_minimal, monkeypatch)
416 monkeypatch.setattr(
417 "music_assistant.controllers.players.controller.enrich_device_mac_address",
418 AsyncMock(),
419 )
420
421 late_player = Player(provider, "late_player")
422
423 class LateRegisteringPlayer(Player):
424 """Player whose unload lets a discovery callback register another player."""
425
426 async def on_unload(self) -> None:
427 """Handle unload of the player."""
428 await super().on_unload()
429 # stands in for a discovery that was already running when the unload started
430 # and only reaches the controller once the players have been unregistered
431 await self.mass.players.register_or_update(late_player)
432
433 player = LateRegisteringPlayer(provider, "existing_player")
434 player.set_initialized()
435 mass_minimal.players._players[player.player_id] = player
436
437 await mass_minimal.unload_provider(provider.instance_id)
438
439 # without the guard the late player survives the unload with no provider behind it,
440 # so it is never unregistered and its on_unload never runs
441 assert mass_minimal.players._players == {}
442
443
444async def test_unload_provider_rejects_in_flight_player_registration(
445 mass_minimal: MusicAssistant,
446 monkeypatch: pytest.MonkeyPatch,
447) -> None:
448 """A registration that is already running when the unload starts is dropped as well."""
449 provider = await _setup_bare_player_provider(mass_minimal, monkeypatch)
450
451 registration_started = asyncio.Event()
452 resume_registration = asyncio.Event()
453
454 async def _blocked_enrich(*_args: object, **_kwargs: object) -> None:
455 """Park the registration in one of its awaits until the test releases it."""
456 registration_started.set()
457 await resume_registration.wait()
458
459 monkeypatch.setattr(
460 "music_assistant.controllers.players.controller.enrich_device_mac_address",
461 _blocked_enrich,
462 )
463
464 register = asyncio.create_task(mass_minimal.players.register(Player(provider, "in_flight")))
465 await asyncio.wait_for(registration_started.wait(), timeout=5)
466
467 # start the unload with the registration parked, and release it once the provider is
468 # flagged: the player is not in the registry yet, so the unregister pass cannot see it
469 unload = asyncio.create_task(mass_minimal.unload_provider(provider.instance_id))
470 async with asyncio.timeout(5):
471 while not provider.unloading:
472 await asyncio.sleep(0)
473 resume_registration.set()
474 await asyncio.gather(register, unload)
475
476 assert mass_minimal.players._players == {}
477