/
/
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.expose_to_ha_by_default is False
59 assert PlayerFeature.VOLUME_SET not in player.supported_features
60 assert PlayerFeature.VOLUME_MUTE not in player.supported_features
61 assert PlayerFeature.SET_MEMBERS in player.supported_features
62 assert mass.player_queues.get(player_id) is not None
63 # the owner marker must be persisted for orphan sweeps
64 assert (
65 mass.config.get_raw_player_config_value(player_id, CONF_VIRTUAL_PLAYER_OWNER)
66 == sendspin.instance_id
67 )
68
69
70async def test_create_virtual_player_custom_id(mass: MusicAssistant) -> None:
71 """Test creating a virtual player with a caller-supplied id."""
72 sendspin = _get_sendspin_provider(mass)
73 player_id = await sendspin.create_virtual_player(
74 owner_instance_id=sendspin.instance_id,
75 display_name="Test Session",
76 player_id="my_session",
77 )
78 assert player_id == f"{VIRTUAL_PLAYER_ID_PREFIX}my_session"
79 assert mass.players.get_player(player_id) is not None
80
81
82async def test_create_virtual_player_invalid_id(mass: MusicAssistant) -> None:
83 """Test that a player_id with unsafe characters is rejected."""
84 sendspin = _get_sendspin_provider(mass)
85 with pytest.raises(SetupFailedError, match="Invalid player_id"):
86 await sendspin.create_virtual_player(
87 owner_instance_id=sendspin.instance_id,
88 display_name="Test Session",
89 player_id="my/session",
90 )
91
92
93async def test_create_virtual_player_duplicate(mass: MusicAssistant) -> None:
94 """Test that creating a duplicate virtual player raises."""
95 sendspin = _get_sendspin_provider(mass)
96 player_id = await sendspin.create_virtual_player(
97 owner_instance_id=sendspin.instance_id,
98 display_name="Test Session",
99 player_id="my_session",
100 )
101 with pytest.raises(SetupFailedError):
102 await sendspin.create_virtual_player(
103 owner_instance_id=sendspin.instance_id,
104 display_name="Test Session",
105 player_id=player_id,
106 )
107
108
109async def test_create_virtual_player_cancellation_cleans_partial_player() -> None:
110 """Test cancellation rolls back a partially-created virtual player."""
111 sendspin = SendspinProvider.__new__(SendspinProvider)
112 sendspin.mass = MagicMock()
113 sendspin.server_api = MagicMock()
114 sendspin.logger = MagicMock()
115 sendspin._virtual_players = {}
116 owner = MagicMock(instance_id="owner--test")
117 sendspin.mass.get_provider.return_value = owner
118 client_registered = asyncio.Event()
119 creation_started = asyncio.Event()
120 never_finish = asyncio.Event()
121 client = MagicMock()
122
123 def _register_virtual_player_client(_player_id: str, _display_name: str) -> None:
124 client_registered.set()
125
126 async def _wait_for_virtual_player(_player_id: str) -> None:
127 creation_started.set()
128 await never_finish.wait()
129
130 sendspin.server_api.get_client.side_effect = lambda _player_id: (
131 client if client_registered.is_set() else None
132 )
133 sendspin.server_api.remove_client = AsyncMock()
134 sendspin.mass.players.unregister = AsyncMock()
135
136 with (
137 patch.object(
138 sendspin,
139 "_get_virtual_player_config_owner",
140 return_value=None,
141 ),
142 patch.object(
143 sendspin,
144 "_register_virtual_player_client",
145 side_effect=_register_virtual_player_client,
146 ),
147 patch.object(
148 sendspin,
149 "_wait_for_virtual_player",
150 new=AsyncMock(side_effect=_wait_for_virtual_player),
151 ),
152 ):
153 creation_task = asyncio.create_task(
154 sendspin.create_virtual_player(
155 owner_instance_id=owner.instance_id,
156 display_name="Test Session",
157 player_id="cancelled",
158 )
159 )
160 await creation_started.wait()
161 creation_task.cancel()
162
163 with pytest.raises(asyncio.CancelledError):
164 await creation_task
165
166 player_id = f"{VIRTUAL_PLAYER_ID_PREFIX}cancelled"
167 assert not sendspin.is_virtual_player(player_id)
168 sendspin.mass.players.unregister.assert_awaited_once_with(player_id, permanent=True)
169 sendspin.server_api.remove_client.assert_awaited_once_with(player_id)
170 sendspin.mass.players.delete_player_config.assert_called_once_with(player_id)
171
172
173async def test_create_virtual_player_owner_not_loaded(mass: MusicAssistant) -> None:
174 """Test that creating a virtual player for an unknown owner raises."""
175 sendspin = _get_sendspin_provider(mass)
176 with pytest.raises(SetupFailedError):
177 await sendspin.create_virtual_player(
178 owner_instance_id="nonexistent_provider",
179 display_name="Test Session",
180 )
181
182
183async def test_create_virtual_player_owned_by_other_provider(mass: MusicAssistant) -> None:
184 """Test that a persisted virtual player id can not be claimed by another owner."""
185 sendspin = _get_sendspin_provider(mass)
186 player_id = f"{VIRTUAL_PLAYER_ID_PREFIX}claimed"
187 mass.config.set(
188 f"{CONF_PLAYERS}/{player_id}",
189 {
190 "player_id": player_id,
191 "provider": sendspin.instance_id,
192 "values": {CONF_VIRTUAL_PLAYER_OWNER: "some_other_provider"},
193 },
194 )
195 with pytest.raises(SetupFailedError, match="owned by"):
196 await sendspin.create_virtual_player(
197 owner_instance_id=sendspin.instance_id,
198 display_name="Test Session",
199 player_id=player_id,
200 )
201
202
203async def test_remove_virtual_player(mass: MusicAssistant) -> None:
204 """Test removing a virtual player cleans up player, client and config."""
205 sendspin = _get_sendspin_provider(mass)
206 player_id = await sendspin.create_virtual_player(
207 owner_instance_id=sendspin.instance_id,
208 display_name="Test Session",
209 )
210 await sendspin.remove_virtual_player(player_id)
211 assert not sendspin.is_virtual_player(player_id)
212 assert mass.players.get_player(player_id) is None
213 assert sendspin.server_api.get_client(player_id) is None
214 assert mass.config.get(f"{CONF_PLAYERS}/{player_id}") is None
215
216
217async def test_remove_virtual_player_retries_after_partial_failure() -> None:
218 """Retain virtual-player ownership until removal completes successfully."""
219 sendspin = SendspinProvider.__new__(SendspinProvider)
220 sendspin.mass = MagicMock()
221 sendspin.server_api = MagicMock()
222 sendspin.logger = MagicMock()
223 player_id = f"{VIRTUAL_PLAYER_ID_PREFIX}retry"
224 sendspin._virtual_players = {player_id: "owner--test"}
225 sendspin.mass.players.unregister = AsyncMock()
226 sendspin.server_api.get_client.return_value = MagicMock()
227 sendspin.server_api.remove_client = AsyncMock(
228 side_effect=[RuntimeError("client removal failed"), None]
229 )
230
231 with pytest.raises(RuntimeError, match="client removal failed"):
232 await sendspin.remove_virtual_player(player_id)
233
234 assert sendspin.is_virtual_player(player_id)
235
236 await sendspin.remove_virtual_player(player_id)
237
238 assert not sendspin.is_virtual_player(player_id)
239 assert sendspin.mass.players.unregister.await_count == 2
240 assert sendspin.server_api.remove_client.await_count == 2
241 sendspin.mass.players.delete_player_config.assert_called_once_with(player_id)
242
243
244async def test_remove_virtual_player_rejects_regular_player(mass: MusicAssistant) -> None:
245 """Test that removal is refused for players that are not virtual players."""
246 sendspin = _get_sendspin_provider(mass)
247 with pytest.raises(ValueError, match="not a virtual player"):
248 await sendspin.remove_virtual_player("some_regular_player")
249 # even a prefixed id is refused when it was never created as virtual player
250 with pytest.raises(ValueError, match="not a virtual player"):
251 await sendspin.remove_virtual_player(f"{VIRTUAL_PLAYER_ID_PREFIX}unknown")
252
253
254async def test_virtual_player_removed_on_owner_unload(mass: MusicAssistant) -> None:
255 """Test that unloading the owner provider removes its virtual players."""
256 await mass.config._create_provider_instance("profiler", {})
257 owner = mass.get_provider("profiler")
258 assert owner is not None
259 await owner.initialized.wait()
260
261 sendspin = _get_sendspin_provider(mass)
262 player_id = await sendspin.create_virtual_player(
263 owner_instance_id=owner.instance_id,
264 display_name="Test Session",
265 )
266 assert mass.players.get_player(player_id) is not None
267
268 await mass.unload_provider(owner.instance_id)
269 await _wait_for(lambda: mass.players.get_player(player_id) is None)
270 assert not sendspin.is_virtual_player(player_id)
271 assert sendspin.server_api.get_client(player_id) is None
272
273
274async def test_orphan_virtual_player_config_sweep(mass: MusicAssistant) -> None:
275 """Test that stale virtual player configs are swept at provider startup."""
276 sendspin = _get_sendspin_provider(mass)
277 orphan_id = f"{VIRTUAL_PLAYER_ID_PREFIX}orphan"
278 kept_id = f"{VIRTUAL_PLAYER_ID_PREFIX}kept"
279 leftover_id = f"{VIRTUAL_PLAYER_ID_PREFIX}leftover"
280 for player_id, owner in (
281 (orphan_id, "removed_provider"),
282 (kept_id, sendspin.instance_id),
283 ):
284 mass.config.set(
285 f"{CONF_PLAYERS}/{player_id}",
286 {
287 "player_id": player_id,
288 "provider": sendspin.instance_id,
289 "values": {CONF_VIRTUAL_PLAYER_OWNER: owner},
290 },
291 )
292 # a prefixed config without owner marker must be left alone
293 mass.config.set(
294 f"{CONF_PLAYERS}/{leftover_id}",
295 {"player_id": leftover_id, "provider": sendspin.instance_id, "values": {}},
296 )
297 # a config of another provider must never be touched by the sweep
298 foreign_id = f"{VIRTUAL_PLAYER_ID_PREFIX}foreign"
299 mass.config.set(
300 f"{CONF_PLAYERS}/{foreign_id}",
301 {"player_id": foreign_id, "provider": "other_provider", "values": {}},
302 )
303
304 sendspin._remove_orphan_virtual_player_configs()
305
306 assert mass.config.get(f"{CONF_PLAYERS}/{orphan_id}") is None
307 assert mass.config.get(f"{CONF_PLAYERS}/{leftover_id}") is not None
308 assert mass.config.get(f"{CONF_PLAYERS}/{kept_id}") is not None
309 assert mass.config.get(f"{CONF_PLAYERS}/{foreign_id}") is not None
310