/
/
/
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 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