/
/
/
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
9from music_assistant_models.background_task import TaskSchedule
10from music_assistant_models.config_entries import ProviderConfig
11from music_assistant_models.enums import MediaType, ProviderType
12from music_assistant_models.errors import LoginFailed
13from music_assistant_models.provider import ProviderManifest
14
15from music_assistant.controllers.music import MusicController
16from music_assistant.controllers.players import PlayerController
17from music_assistant.controllers.tasks import TasksController
18from music_assistant.controllers.tasks.constants import TASK_UPDATE_TIMER_ID
19from music_assistant.mass import MusicAssistant
20from music_assistant.models.music_provider import MusicProvider
21from music_assistant.models.player import Player
22from music_assistant.models.player_provider import PlayerProvider
23
24if TYPE_CHECKING:
25 import pytest
26 from music_assistant_models.config_entries import ProviderError
27
28
29def _make_mass(
30 monkeypatch: pytest.MonkeyPatch,
31) -> tuple[MusicAssistant, list[ProviderError], AsyncMock]:
32 """Return a bare MusicAssistant (bypassing __init__) recording last-error writes."""
33 mass = object.__new__(MusicAssistant)
34 recorded: list[ProviderError] = []
35 config = MagicMock()
36 config.update_provider_last_error = MagicMock(
37 side_effect=lambda _instance_id, error: recorded.append(error)
38 )
39 unload = AsyncMock()
40 monkeypatch.setattr(mass, "config", config, raising=False)
41 monkeypatch.setattr(mass, "unload_provider", unload)
42 return mass, recorded, unload
43
44
45async def test_unload_provider_with_error_preserves_auth_code(
46 monkeypatch: pytest.MonkeyPatch,
47) -> None:
48 """A LoginFailed keeps its error code + translation so the provider shows AUTH_REQUIRED."""
49 mass, recorded, unload = _make_mass(monkeypatch)
50 await mass.unload_provider_with_error("spotify--1", LoginFailed("token revoked"))
51 assert recorded[0].error_code == LoginFailed.error_code
52 assert recorded[0].translation_key == LoginFailed.translation_key
53 unload.assert_awaited_once_with("spotify--1")
54
55
56async def test_unload_provider_with_error_string_is_generic(
57 monkeypatch: pytest.MonkeyPatch,
58) -> None:
59 """A plain string message is recorded as a generic error (code 999)."""
60 mass, recorded, _unload = _make_mass(monkeypatch)
61 await mass.unload_provider_with_error("airplay--1", "daemon failed to start")
62 assert recorded[0].error_code == 999
63 assert recorded[0].message == "daemon failed to start"
64
65
66async def test_unload_provider_waits_for_running_sync(
67 mass_minimal: MusicAssistant,
68 monkeypatch: pytest.MonkeyPatch,
69) -> None:
70 """A provider must not be unloaded while its own library sync is still unwinding."""
71 mass_minimal.tasks = TasksController(mass_minimal)
72 await mass_minimal.tasks.setup(await mass_minimal.config.get_core_config("tasks"))
73 mass_minimal.tasks.initialized.set()
74 mass_minimal.music = MusicController(mass_minimal)
75 # discovery is not set up on the minimal instance and plays no part in this test
76 monkeypatch.setattr(mass_minimal.discovery, "on_provider_unload", MagicMock())
77
78 sync_started = asyncio.Event()
79 sync_finished = False
80 sync_finished_on_unload: bool | None = None
81
82 class SyncingProvider(MusicProvider):
83 """Provider that records the sync state observed by its unload."""
84
85 async def sync_library(self, media_type: MediaType) -> None:
86 """Unused: the sync task handler is registered directly by this test."""
87
88 async def unload(self, is_removed: bool = False) -> None:
89 """Handle unload of the provider."""
90 nonlocal sync_finished_on_unload
91 sync_finished_on_unload = sync_finished
92
93 provider_config = ProviderConfig(
94 values={},
95 type=ProviderType.MUSIC,
96 domain="test_provider",
97 instance_id="test_provider--instance",
98 name="Test provider",
99 )
100 monkeypatch.setattr(provider_config, "get_value", lambda *_args, **_kwargs: "GLOBAL")
101 provider = SyncingProvider(
102 mass_minimal,
103 manifest=ProviderManifest(
104 type=ProviderType.MUSIC,
105 domain="test_provider",
106 name="Test provider",
107 description="Test provider",
108 codeowners=["@music-assistant"],
109 ),
110 config=provider_config,
111 )
112 provider.available = True
113 mass_minimal._providers[provider.instance_id] = provider
114
115 async def sync_handler() -> None:
116 nonlocal sync_finished
117 sync_started.set()
118 try:
119 await asyncio.sleep(30)
120 finally:
121 # cleanup that yields to the event loop, like a sync releasing its resources
122 await asyncio.sleep(0.05)
123 sync_finished = True
124
125 task_id = mass_minimal.music._get_sync_task_id(provider, MediaType.TRACK)
126 mass_minimal.tasks.register_scheduled_task(
127 task_id=task_id,
128 name="Sync tracks",
129 handler=sync_handler,
130 schedule=TaskSchedule.hourly(every=12),
131 )
132 mass_minimal.tasks.run_task(task_id)
133 await asyncio.wait_for(sync_started.wait(), timeout=2)
134
135 try:
136 await mass_minimal.unload_provider(provider.instance_id)
137 finally:
138 mass_minimal.cancel_timer(TASK_UPDATE_TIMER_ID)
139 await mass_minimal.tasks.close()
140
141 assert sync_finished_on_unload is True
142
143
144async def test_unload_provider_unregisters_hidden_players(
145 mass_minimal: MusicAssistant,
146 monkeypatch: pytest.MonkeyPatch,
147) -> None:
148 """Unloading a player provider also unregisters its disabled and initializing players."""
149 mass_minimal.players = PlayerController(mass_minimal)
150 mass_minimal.music = MagicMock(unschedule_provider_sync=AsyncMock())
151 mass_minimal.player_queues = MagicMock()
152 # discovery is not set up on the minimal instance and plays no part in this test
153 monkeypatch.setattr(mass_minimal.discovery, "on_provider_unload", MagicMock())
154
155 unloaded_players: list[str] = []
156
157 class RecordingPlayer(Player):
158 """Player that records that it was unloaded."""
159
160 async def on_unload(self) -> None:
161 """Handle unload of the player."""
162 unloaded_players.append(self.player_id)
163 await super().on_unload()
164
165 def add_provider(instance_id: str) -> PlayerProvider:
166 provider_config = ProviderConfig(
167 values={},
168 type=ProviderType.PLAYER,
169 domain="test_player_provider",
170 instance_id=instance_id,
171 name="Test player provider",
172 )
173 monkeypatch.setattr(provider_config, "get_value", lambda *_args, **_kwargs: "GLOBAL")
174 provider = PlayerProvider(
175 mass_minimal,
176 manifest=ProviderManifest(
177 type=ProviderType.PLAYER,
178 domain="test_player_provider",
179 name="Test player provider",
180 description="Test player provider",
181 codeowners=["@music-assistant"],
182 ),
183 config=provider_config,
184 )
185 provider.available = True
186 mass_minimal._providers[instance_id] = provider
187 return provider
188
189 def add_player(
190 provider: PlayerProvider,
191 player_id: str,
192 enabled: bool = True,
193 initialized: bool = True,
194 ) -> RecordingPlayer:
195 player = RecordingPlayer(provider, player_id)
196 if initialized:
197 player.set_initialized()
198 if not enabled:
199 # config and state are kept in sync so the player reads as disabled
200 # even if its state gets recalculated from the config
201 player.config.enabled = False
202 player.state.enabled = False
203 mass_minimal.players._players[player_id] = player
204 return player
205
206 provider = add_provider("test_player_provider--instance")
207 other_provider = add_provider("test_player_provider--other")
208 add_player(provider, "enabled_player")
209 # a player provider may deliberately keep a disabled player registered (msx_bridge does)
210 add_player(provider, "disabled_player", enabled=False)
211 # a player that is still being set up when its provider goes away
212 add_player(provider, "initializing_player", initialized=False)
213 other_player = add_player(other_provider, "other_provider_player")
214
215 try:
216 await mass_minimal.unload_provider(provider.instance_id)
217 finally:
218 # unregistering schedules a debounced state update for the players that remain
219 mass_minimal.cancel_timer(f"player_update_state_{other_player.player_id}")
220
221 assert set(unloaded_players) == {"enabled_player", "disabled_player", "initializing_player"}
222 assert set(mass_minimal.players._players) == {other_player.player_id}
223