/
/
/
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_cleanup_failed_creation_awaits_a_slow_teardown() -> None:
246 """Test that a slow teardown is awaited to completion instead of being retried."""
247 sendspin = SendspinProvider.__new__(SendspinProvider)
248 sendspin.mass = MagicMock()
249 sendspin.server_api = MagicMock()
250 sendspin.logger = MagicMock()
251 player_id = f"{VIRTUAL_PLAYER_ID_PREFIX}slow"
252 sendspin._virtual_players = {player_id: "owner--test"}
253 sendspin.server_api.get_client.return_value = None
254
255 async def _slow_unregister(_player_id: str, **_kwargs: object) -> None:
256 # outlasts the 2 second bound this path used to carry
257 await asyncio.sleep(2.5)
258
259 sendspin.mass.players.unregister = AsyncMock(side_effect=_slow_unregister)
260
261 await sendspin._cleanup_failed_virtual_player_creation(player_id)
262
263 assert sendspin.mass.players.unregister.await_count == 1
264 assert not sendspin.is_virtual_player(player_id)
265 sendspin.mass.players.delete_player_config.assert_called_once_with(player_id)
266 sendspin.logger.warning.assert_not_called()
267
268
269async def test_cleanup_failed_creation_retries_a_raised_teardown() -> None:
270 """Test that a teardown raising once is retried and then reported as cleaned up."""
271 sendspin = SendspinProvider.__new__(SendspinProvider)
272 sendspin.mass = MagicMock()
273 sendspin.server_api = MagicMock()
274 sendspin.logger = MagicMock()
275 player_id = f"{VIRTUAL_PLAYER_ID_PREFIX}flaky"
276 sendspin._virtual_players = {player_id: "owner--test"}
277 sendspin.server_api.get_client.return_value = None
278 sendspin.mass.players.unregister = AsyncMock(
279 side_effect=[RuntimeError("teardown failed"), None]
280 )
281
282 await sendspin._cleanup_failed_virtual_player_creation(player_id)
283
284 assert sendspin.mass.players.unregister.await_count == 2
285 assert not sendspin.is_virtual_player(player_id)
286 sendspin.logger.warning.assert_not_called()
287
288
289async def test_cleanup_failed_creation_skips_an_already_removed_player() -> None:
290 """Test that cleanup stops immediately when the player is already gone."""
291 sendspin = SendspinProvider.__new__(SendspinProvider)
292 sendspin.mass = MagicMock()
293 sendspin.server_api = MagicMock()
294 sendspin.logger = MagicMock()
295 player_id = f"{VIRTUAL_PLAYER_ID_PREFIX}gone"
296 # already torn down by a racing removal, e.g. the owner-unloaded sweep
297 sendspin._virtual_players = {}
298 sendspin.mass.players.unregister = AsyncMock()
299
300 async with asyncio.timeout(0.5):
301 await sendspin._cleanup_failed_virtual_player_creation(player_id)
302
303 sendspin.mass.players.unregister.assert_not_awaited()
304 sendspin.mass.players.delete_player_config.assert_not_called()
305 sendspin.logger.warning.assert_not_called()
306
307
308async def test_cleanup_failed_creation_keeps_a_reclaimable_config() -> None:
309 """Test that cleanup leaves a persisted config it did not create in place."""
310 sendspin = SendspinProvider.__new__(SendspinProvider)
311 sendspin.mass = MagicMock()
312 sendspin.server_api = MagicMock()
313 sendspin.logger = MagicMock()
314 sendspin.config = MagicMock(instance_id="sendspin--test")
315 player_id = f"{VIRTUAL_PLAYER_ID_PREFIX}reclaimable"
316 # unloading drops the in-memory entries but keeps the configs on purpose
317 sendspin._virtual_players = {}
318 sendspin.mass.config.get.return_value = {
319 "provider": sendspin.instance_id,
320 "values": {CONF_VIRTUAL_PLAYER_OWNER: "owner--test"},
321 }
322 sendspin.server_api.get_client.return_value = None
323 sendspin.mass.players.unregister = AsyncMock()
324
325 await sendspin._cleanup_failed_virtual_player_creation(player_id)
326
327 sendspin.mass.players.delete_player_config.assert_not_called()
328 sendspin.mass.players.unregister.assert_not_awaited()
329 sendspin.logger.warning.assert_not_called()
330
331
332async def test_remove_virtual_player_rejects_regular_player(mass: MusicAssistant) -> None:
333 """Test that removal is refused for players that are not virtual players."""
334 sendspin = _get_sendspin_provider(mass)
335 with pytest.raises(ValueError, match="not a virtual player"):
336 await sendspin.remove_virtual_player("some_regular_player")
337 # even a prefixed id is refused when it was never created as virtual player
338 with pytest.raises(ValueError, match="not a virtual player"):
339 await sendspin.remove_virtual_player(f"{VIRTUAL_PLAYER_ID_PREFIX}unknown")
340
341
342async def test_virtual_player_removed_on_owner_unload(mass: MusicAssistant) -> None:
343 """Test that unloading the owner provider removes its virtual players."""
344 await mass.config._create_provider_instance("profiler", {})
345 owner = mass.get_provider("profiler")
346 assert owner is not None
347 await owner.initialized.wait()
348
349 sendspin = _get_sendspin_provider(mass)
350 player_id = await sendspin.create_virtual_player(
351 owner_instance_id=owner.instance_id,
352 display_name="Test Session",
353 )
354 assert mass.players.get_player(player_id) is not None
355
356 await mass.unload_provider(owner.instance_id)
357 await _wait_for(lambda: mass.players.get_player(player_id) is None)
358 assert not sendspin.is_virtual_player(player_id)
359 assert sendspin.server_api.get_client(player_id) is None
360
361
362async def test_orphan_virtual_player_config_sweep(mass: MusicAssistant) -> None:
363 """Test that stale virtual player configs are swept at provider startup."""
364 sendspin = _get_sendspin_provider(mass)
365 orphan_id = f"{VIRTUAL_PLAYER_ID_PREFIX}orphan"
366 kept_id = f"{VIRTUAL_PLAYER_ID_PREFIX}kept"
367 leftover_id = f"{VIRTUAL_PLAYER_ID_PREFIX}leftover"
368 for player_id, owner in (
369 (orphan_id, "removed_provider"),
370 (kept_id, sendspin.instance_id),
371 ):
372 mass.config.set(
373 f"{CONF_PLAYERS}/{player_id}",
374 {
375 "player_id": player_id,
376 "provider": sendspin.instance_id,
377 "values": {CONF_VIRTUAL_PLAYER_OWNER: owner},
378 },
379 )
380 # a prefixed config without owner marker must be left alone
381 mass.config.set(
382 f"{CONF_PLAYERS}/{leftover_id}",
383 {"player_id": leftover_id, "provider": sendspin.instance_id, "values": {}},
384 )
385 # a config of another provider must never be touched by the sweep
386 foreign_id = f"{VIRTUAL_PLAYER_ID_PREFIX}foreign"
387 mass.config.set(
388 f"{CONF_PLAYERS}/{foreign_id}",
389 {"player_id": foreign_id, "provider": "other_provider", "values": {}},
390 )
391
392 sendspin._remove_orphan_virtual_player_configs()
393
394 assert mass.config.get(f"{CONF_PLAYERS}/{orphan_id}") is None
395 assert mass.config.get(f"{CONF_PLAYERS}/{leftover_id}") is not None
396 assert mass.config.get(f"{CONF_PLAYERS}/{kept_id}") is not None
397 assert mass.config.get(f"{CONF_PLAYERS}/{foreign_id}") is not None
398