/
/
1"""Tests for the Sendspin virtual player API."""
2
3from __future__ import annotations
4
5import asyncio
6from typing import TYPE_CHECKING, cast
7from unittest.mock import AsyncMock, MagicMock, patch
8
9import pytest
10from music_assistant_models.enums import PlayerFeature, PlayerType
11from music_assistant_models.errors import SetupFailedError
12
13from music_assistant.constants import CONF_PLAYERS
14from music_assistant.providers.sendspin.constants import (
15 CONF_VIRTUAL_PLAYER_OWNER,
16 VIRTUAL_PLAYER_ID_PREFIX,
17)
18from music_assistant.providers.sendspin.provider import SendspinProvider
19
20if TYPE_CHECKING:
21 from collections.abc import Callable
22
23 from music_assistant.mass import MusicAssistant
24
25
26def _get_sendspin_provider(mass: MusicAssistant) -> SendspinProvider:
27 """Return the loaded Sendspin provider instance."""
28 provider = mass.get_provider("sendspin")
29 assert provider is not None
30 return cast("SendspinProvider", provider)
31
32
33async def _wait_for(condition: Callable[[], bool], timeout: float = 5.0) -> None:
34 """Wait until the given condition callable returns True."""
35 loop = asyncio.get_running_loop()
36 deadline = loop.time() + timeout
37 while loop.time() < deadline:
38 if condition():
39 return
40 await asyncio.sleep(0.05)
41 raise TimeoutError("Condition not met within timeout")
42
43
44async def test_create_virtual_player(mass: MusicAssistant) -> None:
45 """Test creating a virtual player registers a hidden queue-owning player."""
46 sendspin = _get_sendspin_provider(mass)
47 player_id = await sendspin.create_virtual_player(
48 owner_instance_id=sendspin.instance_id,
49 display_name="Test Session",
50 )
51 assert player_id.startswith(VIRTUAL_PLAYER_ID_PREFIX)
52 assert sendspin.is_virtual_player(player_id)
53
54 player = mass.players.get_player(player_id)
55 assert player is not None
56 assert player.type == PlayerType.PLAYER
57 assert player.hidden_by_default is True
58 assert player.private is True
59 assert player.expose_to_ha_by_default is False
60 assert PlayerFeature.VOLUME_SET not in player.supported_features
61 assert PlayerFeature.VOLUME_MUTE not in player.supported_features
62 assert PlayerFeature.SET_MEMBERS in player.supported_features
63 assert mass.player_queues.get(player_id) is not None
64 # the owner marker must be persisted for orphan sweeps
65 assert (
66 mass.config.get_raw_player_config_value(player_id, CONF_VIRTUAL_PLAYER_OWNER)
67 == sendspin.instance_id
68 )
69
70
71async def test_create_virtual_player_custom_id(mass: MusicAssistant) -> None:
72 """Test creating a virtual player with a caller-supplied id."""
73 sendspin = _get_sendspin_provider(mass)
74 player_id = await sendspin.create_virtual_player(
75 owner_instance_id=sendspin.instance_id,
76 display_name="Test Session",
77 player_id="my_session",
78 )
79 assert player_id == f"{VIRTUAL_PLAYER_ID_PREFIX}my_session"
80 assert mass.players.get_player(player_id) is not None
81
82
83async def test_create_virtual_player_invalid_id(mass: MusicAssistant) -> None:
84 """Test that a player_id with unsafe characters is rejected."""
85 sendspin = _get_sendspin_provider(mass)
86 with pytest.raises(SetupFailedError, match="Invalid player_id"):
87 await sendspin.create_virtual_player(
88 owner_instance_id=sendspin.instance_id,
89 display_name="Test Session",
90 player_id="my/session",
91 )
92
93
94async def test_create_virtual_player_duplicate(mass: MusicAssistant) -> None:
95 """Test that creating a duplicate virtual player raises."""
96 sendspin = _get_sendspin_provider(mass)
97 player_id = await sendspin.create_virtual_player(
98 owner_instance_id=sendspin.instance_id,
99 display_name="Test Session",
100 player_id="my_session",
101 )
102 with pytest.raises(SetupFailedError):
103 await sendspin.create_virtual_player(
104 owner_instance_id=sendspin.instance_id,
105 display_name="Test Session",
106 player_id=player_id,
107 )
108
109
110async def test_create_virtual_player_cancellation_cleans_partial_player() -> None:
111 """Test cancellation rolls back a partially-created virtual player."""
112 sendspin = SendspinProvider.__new__(SendspinProvider)
113 sendspin.mass = MagicMock()
114 sendspin.server_api = MagicMock()
115 sendspin.logger = MagicMock()
116 sendspin._virtual_players = {}
117 owner = MagicMock(instance_id="owner--test")
118 sendspin.mass.get_provider.return_value = owner
119 client_registered = asyncio.Event()
120 creation_started = asyncio.Event()
121 never_finish = asyncio.Event()
122 client = MagicMock()
123
124 def _register_virtual_player_client(_player_id: str, _display_name: str) -> None:
125 client_registered.set()
126
127 async def _wait_for_virtual_player(_player_id: str) -> None:
128 creation_started.set()
129 await never_finish.wait()
130
131 sendspin.server_api.get_client.side_effect = lambda _player_id: (
132 client if client_registered.is_set() else None
133 )
134 sendspin.server_api.remove_client = AsyncMock()
135 sendspin.mass.players.unregister = AsyncMock()
136
137 with (
138 patch.object(
139 sendspin,
140 "_get_virtual_player_config_owner",
141 return_value=None,
142 ),
143 patch.object(
144 sendspin,
145 "_register_virtual_player_client",
146 side_effect=_register_virtual_player_client,
147 ),
148 patch.object(
149 sendspin,
150 "_wait_for_virtual_player",
151 new=AsyncMock(side_effect=_wait_for_virtual_player),
152 ),
153 ):
154 creation_task = asyncio.create_task(
155 sendspin.create_virtual_player(
156 owner_instance_id=owner.instance_id,
157 display_name="Test Session",
158 player_id="cancelled",
159 )
160 )
161 await creation_started.wait()
162 creation_task.cancel()
163
164 with pytest.raises(asyncio.CancelledError):
165 await creation_task
166
167 player_id = f"{VIRTUAL_PLAYER_ID_PREFIX}cancelled"
168 assert not sendspin.is_virtual_player(player_id)
169 sendspin.mass.players.unregister.assert_awaited_once_with(player_id, permanent=True)
170 sendspin.server_api.remove_client.assert_awaited_once_with(player_id)
171 sendspin.mass.players.delete_player_config.assert_called_once_with(player_id)
172
173
174async def test_create_virtual_player_owner_not_loaded(mass: MusicAssistant) -> None:
175 """Test that creating a virtual player for an unknown owner raises."""
176 sendspin = _get_sendspin_provider(mass)
177 with pytest.raises(SetupFailedError):
178 await sendspin.create_virtual_player(
179 owner_instance_id="nonexistent_provider",
180 display_name="Test Session",
181 )
182
183
184async def test_create_virtual_player_owned_by_other_provider(mass: MusicAssistant) -> None:
185 """Test that a persisted virtual player id can not be claimed by another owner."""
186 sendspin = _get_sendspin_provider(mass)
187 player_id = f"{VIRTUAL_PLAYER_ID_PREFIX}claimed"
188 mass.config.set(
189 f"{CONF_PLAYERS}/{player_id}",
190 {
191 "player_id": player_id,
192 "provider": sendspin.instance_id,
193 "values": {CONF_VIRTUAL_PLAYER_OWNER: "some_other_provider"},
194 },
195 )
196 with pytest.raises(SetupFailedError, match="owned by"):
197 await sendspin.create_virtual_player(
198 owner_instance_id=sendspin.instance_id,
199 display_name="Test Session",
200 player_id=player_id,
201 )
202
203
204async def test_remove_virtual_player(mass: MusicAssistant) -> None:
205 """Test removing a virtual player cleans up player, client and config."""
206 sendspin = _get_sendspin_provider(mass)
207 player_id = await sendspin.create_virtual_player(
208 owner_instance_id=sendspin.instance_id,
209 display_name="Test Session",
210 )
211 await sendspin.remove_virtual_player(player_id)
212 assert not sendspin.is_virtual_player(player_id)
213 assert mass.players.get_player(player_id) is None
214 assert sendspin.server_api.get_client(player_id) is None
215 assert mass.config.get(f"{CONF_PLAYERS}/{player_id}") is None
216
217
218async def test_remove_virtual_player_retries_after_partial_failure() -> None:
219 """Retain virtual-player ownership until removal completes successfully."""
220 sendspin = SendspinProvider.__new__(SendspinProvider)
221 sendspin.mass = MagicMock()
222 sendspin.server_api = MagicMock()
223 sendspin.logger = MagicMock()
224 player_id = f"{VIRTUAL_PLAYER_ID_PREFIX}retry"
225 sendspin._virtual_players = {player_id: "owner--test"}
226 sendspin.mass.players.unregister = AsyncMock()
227 sendspin.server_api.get_client.return_value = MagicMock()
228 sendspin.server_api.remove_client = AsyncMock(
229 side_effect=[RuntimeError("client removal failed"), None]
230 )
231
232 with pytest.raises(RuntimeError, match="client removal failed"):
233 await sendspin.remove_virtual_player(player_id)
234
235 assert sendspin.is_virtual_player(player_id)
236
237 await sendspin.remove_virtual_player(player_id)
238
239 assert not sendspin.is_virtual_player(player_id)
240 assert sendspin.mass.players.unregister.await_count == 2
241 assert sendspin.server_api.remove_client.await_count == 2
242 sendspin.mass.players.delete_player_config.assert_called_once_with(player_id)
243
244
245async def test_remove_virtual_player_rejects_regular_player(mass: MusicAssistant) -> None:
246 """Test that removal is refused for players that are not virtual players."""
247 sendspin = _get_sendspin_provider(mass)
248 with pytest.raises(ValueError, match="not a virtual player"):
249 await sendspin.remove_virtual_player("some_regular_player")
250 # even a prefixed id is refused when it was never created as virtual player
251 with pytest.raises(ValueError, match="not a virtual player"):
252 await sendspin.remove_virtual_player(f"{VIRTUAL_PLAYER_ID_PREFIX}unknown")
253
254
255async def test_virtual_player_removed_on_owner_unload(mass: MusicAssistant) -> None:
256 """Test that unloading the owner provider removes its virtual players."""
257 await mass.config._create_provider_instance("profiler", {})
258 owner = mass.get_provider("profiler")
259 assert owner is not None
260 await owner.initialized.wait()
261
262 sendspin = _get_sendspin_provider(mass)
263 player_id = await sendspin.create_virtual_player(
264 owner_instance_id=owner.instance_id,
265 display_name="Test Session",
266 )
267 assert mass.players.get_player(player_id) is not None
268
269 await mass.unload_provider(owner.instance_id)
270 await _wait_for(lambda: mass.players.get_player(player_id) is None)
271 assert not sendspin.is_virtual_player(player_id)
272 assert sendspin.server_api.get_client(player_id) is None
273
274
275async def test_orphan_virtual_player_config_sweep(mass: MusicAssistant) -> None:
276 """Test that stale virtual player configs are swept at provider startup."""
277 sendspin = _get_sendspin_provider(mass)
278 orphan_id = f"{VIRTUAL_PLAYER_ID_PREFIX}orphan"
279 kept_id = f"{VIRTUAL_PLAYER_ID_PREFIX}kept"
280 leftover_id = f"{VIRTUAL_PLAYER_ID_PREFIX}leftover"
281 for player_id, owner in (
282 (orphan_id, "removed_provider"),
283 (kept_id, sendspin.instance_id),
284 ):
285 mass.config.set(
286 f"{CONF_PLAYERS}/{player_id}",
287 {
288 "player_id": player_id,
289 "provider": sendspin.instance_id,
290 "values": {CONF_VIRTUAL_PLAYER_OWNER: owner},
291 },
292 )
293 # a prefixed config without owner marker must be left alone
294 mass.config.set(
295 f"{CONF_PLAYERS}/{leftover_id}",
296 {"player_id": leftover_id, "provider": sendspin.instance_id, "values": {}},
297 )
298 # a config of another provider must never be touched by the sweep
299 foreign_id = f"{VIRTUAL_PLAYER_ID_PREFIX}foreign"
300 mass.config.set(
301 f"{CONF_PLAYERS}/{foreign_id}",
302 {"player_id": foreign_id, "provider": "other_provider", "values": {}},
303 )
304
305 sendspin._remove_orphan_virtual_player_configs()
306
307 assert mass.config.get(f"{CONF_PLAYERS}/{orphan_id}") is None
308 assert mass.config.get(f"{CONF_PLAYERS}/{leftover_id}") is not None
309 assert mass.config.get(f"{CONF_PLAYERS}/{kept_id}") is not None
310 assert mass.config.get(f"{CONF_PLAYERS}/{foreign_id}") is not None
311