/
/
/
1"""Tests for the Sonos player connection/reconnect handling."""
2
3import asyncio
4import logging
5from unittest.mock import AsyncMock, MagicMock
6
7import pytest
8from aiohttp import ConnectionTimeoutError
9from aiosonos.api.models import MusicService
10from aiosonos.api.models import PlayBackState as SonosPlayBackState
11from aiosonos.exceptions import CannotConnect, FailedCommand
12from music_assistant_models.enums import PlaybackState
13from music_assistant_models.player import PlayerMedia
14
15from music_assistant.constants import EXTERNAL_PAUSE_IDLE_TIMEOUT
16from music_assistant.mass import MusicAssistant
17from music_assistant.providers.sonos.const import SOURCE_SPOTIFY
18from music_assistant.providers.sonos.player import SonosPlayer
19
20
21def _bind_player(mass: MusicAssistant | MagicMock) -> tuple[SonosPlayer, MagicMock]:
22 """Create a SonosPlayer bound to the given MusicAssistant, with a mocked client."""
23 player = SonosPlayer.__new__(SonosPlayer)
24 client = MagicMock()
25 client.disconnect = AsyncMock()
26 player.mass = mass
27 player.logger = logging.getLogger("test.sonos.player")
28 player._player_id = "sonos_player"
29 player._listen_task = None
30 player.connected = False
31 player.client = client
32 player._on_unload_callbacks = []
33 player.update_state = MagicMock() # type: ignore[misc, method-assign]
34 return player, client
35
36
37def _make_player() -> tuple[SonosPlayer, MagicMock]:
38 """Create a SonosPlayer with mocked connection dependencies."""
39 mass = MagicMock()
40 mass.closing = False
41 mass.players.get_player.return_value = MagicMock()
42 player, _ = _bind_player(mass)
43 return player, mass
44
45
46async def _connect_player(player: SonosPlayer, client: MagicMock) -> None:
47 """Connect the player to a listener that stays alive until it is cancelled."""
48
49 async def _start_listening(init_ready: asyncio.Event) -> None:
50 init_ready.set()
51 await asyncio.sleep(3600)
52
53 client.connect = AsyncMock()
54 client.start_listening = _start_listening
55 await player._connect()
56
57
58@pytest.mark.asyncio
59async def test_connect_timeout_reschedules_reconnect() -> None:
60 """Test a blackholed connection (timeout, not refused) still schedules a retry."""
61 player, mass = _make_player()
62 player.client.connect = AsyncMock( # type: ignore[method-assign]
63 side_effect=ConnectionTimeoutError("Connection timeout to host https://x:1443")
64 )
65
66 await player._connect(retry_on_fail=30)
67
68 assert player._attr_available is False
69 mass.call_later.assert_called_once()
70 args, _ = mass.call_later.call_args
71 assert args[0] == min(30 + 30, 3600)
72 assert args[1] == player._connect
73
74
75@pytest.mark.asyncio
76async def test_connect_timeout_without_retry_raises() -> None:
77 """Test a connection failure without retry_on_fail still propagates."""
78 player, mass = _make_player()
79 player.client.connect = AsyncMock( # type: ignore[method-assign]
80 side_effect=ConnectionTimeoutError("Connection timeout to host https://x:1443")
81 )
82
83 with pytest.raises(ConnectionTimeoutError):
84 await player._connect(retry_on_fail=0)
85
86 mass.call_later.assert_not_called()
87
88
89@pytest.mark.asyncio
90async def test_connect_websocket_handshake_failure_reschedules_reconnect() -> None:
91 """Test a websocket handshake failure also reschedules a retry."""
92 player, mass = _make_player()
93 player.client.connect = AsyncMock( # type: ignore[method-assign]
94 side_effect=CannotConnect(OSError("handshake failed"))
95 )
96
97 await player._connect(retry_on_fail=30)
98
99 assert player._attr_available is False
100 mass.call_later.assert_called_once()
101
102
103@pytest.mark.asyncio
104async def test_on_unload_disconnects_without_reconnecting(timer_mass: MusicAssistant) -> None:
105 """Test an unloaded player disconnects and its aborted listener does not reconnect."""
106 player, client = _bind_player(timer_mass)
107 await _connect_player(player, client)
108 listener = player._listen_task
109 assert listener is not None
110
111 await player.on_unload()
112
113 assert player.connected is False
114 client.disconnect.assert_awaited_once()
115 # let the aborted listener run its cleanup
116 with pytest.raises(asyncio.CancelledError):
117 await listener
118 assert timer_mass._tracked_timers == {}
119
120
121@pytest.mark.asyncio
122async def test_on_unload_cancels_a_pending_reconnect(timer_mass: MusicAssistant) -> None:
123 """Test a reconnect that is still waiting to fire does not connect after the unload."""
124 player, _ = _bind_player(timer_mass)
125 connect_attempts: list[int] = []
126
127 async def _connect(retry_on_fail: int = 0) -> None:
128 connect_attempts.append(retry_on_fail)
129
130 player._connect = _connect # type: ignore[method-assign]
131 player.reconnect(0)
132 handle = timer_mass._tracked_timers[f"sonos_reconnect_{player.player_id}"]
133
134 await player.on_unload()
135 await asyncio.sleep(0.05)
136
137 assert handle.cancelled()
138 assert connect_attempts == []
139
140
141@pytest.mark.asyncio
142async def test_on_unload_cancels_a_scheduled_airplay_group_restore(
143 timer_mass: MusicAssistant,
144) -> None:
145 """Test the AirPlay group restore scheduled for a player does not run after the unload."""
146 player, client = _bind_player(timer_mass)
147 player._attr_name = "Sonos Player"
148 client.player.is_coordinator = True
149 client.player.group_members = [player.player_id, "sonos_player_2"]
150 output_protocol = MagicMock()
151 output_protocol.protocol_domain = "airplay"
152
153 await player.on_protocol_playback(output_protocol)
154 handle = timer_mass._tracked_timers[f"restore_airplay_group_{player.player_id}"]
155
156 await player.on_unload()
157
158 assert handle.cancelled()
159 assert timer_mass._tracked_timers == {}
160
161
162@pytest.mark.asyncio
163async def test_on_unload_cancels_an_airplay_group_restore_that_already_started(
164 timer_mass: MusicAssistant,
165) -> None:
166 """Test a group restore that already started is aborted when the player is unloaded."""
167 player, _ = _bind_player(timer_mass)
168 restoring = asyncio.Event()
169
170 async def _restore_airplay_group() -> None:
171 restoring.set()
172 await asyncio.sleep(5)
173
174 task_id = f"restore_airplay_group_{player.player_id}"
175 timer_mass.call_later(0, _restore_airplay_group, task_id=task_id)
176 await restoring.wait()
177 task = timer_mass._tracked_tasks[task_id]
178
179 await player.on_unload()
180
181 with pytest.raises(asyncio.CancelledError):
182 await task
183
184
185@pytest.mark.asyncio
186async def test_on_unload_unsubscribes_before_disconnecting(timer_mass: MusicAssistant) -> None:
187 """Test the registered unload callbacks run before the client is disconnected."""
188 player, client = _bind_player(timer_mass)
189 calls: list[str] = []
190 player._on_unload_callbacks.append(lambda: calls.append("unsubscribe"))
191 client.disconnect = AsyncMock(side_effect=lambda: calls.append("disconnect"))
192
193 await player.on_unload()
194
195 assert calls == ["unsubscribe", "disconnect"]
196
197
198@pytest.mark.asyncio
199async def test_a_listener_ending_during_the_unload_cannot_rearm_a_reconnect(
200 timer_mass: MusicAssistant,
201) -> None:
202 """Test a listener that ends while the player is unloading does not schedule a reconnect."""
203 player, client = _bind_player(timer_mass)
204 socket_drops = asyncio.Event()
205
206 async def _start_listening(init_ready: asyncio.Event) -> None:
207 init_ready.set()
208 await socket_drops.wait()
209 raise ConnectionResetError("socket dropped")
210
211 client.connect = AsyncMock()
212 client.start_listening = _start_listening
213 await player._connect()
214 listener = player._listen_task
215 assert listener is not None
216
217 # release the listener so it is queued to run its finally, then unload without
218 # yielding in between: the cancellations and connected=False must be indivisible
219 socket_drops.set()
220 await player.on_unload()
221
222 with pytest.raises(asyncio.CancelledError):
223 await listener
224 assert timer_mass._tracked_timers == {}
225 assert timer_mass._tracked_tasks == {}
226
227
228@pytest.mark.asyncio
229async def test_on_unload_survives_a_failing_disconnect(timer_mass: MusicAssistant) -> None:
230 """Test a speaker that cannot be disconnected does not abort the unload."""
231 player, client = _bind_player(timer_mass)
232 client.disconnect = AsyncMock(side_effect=OSError("speaker unreachable"))
233 player.reconnect(0)
234
235 await player.on_unload()
236
237 assert player.connected is False
238 assert timer_mass._tracked_timers == {}
239
240
241def _make_externally_paused_player() -> tuple[SonosPlayer, MagicMock, MagicMock]:
242 """Create a player reporting a paused external source, as Sonos does for Spotify Connect."""
243 mass = MagicMock()
244 mass.closing = False
245 player, client = _bind_player(mass)
246 player._attr_playback_state = PlaybackState.PAUSED
247 player._attr_active_source = SOURCE_SPOTIFY
248 player._attr_current_media = PlayerMedia(uri="spotify:track:1", title="Shout")
249 return player, mass, client
250
251
252def _refuse_to_resume(client: MagicMock) -> None:
253 """Let the speaker reject the play command, as it does for a session it no longer has."""
254 client.player.is_passive = False
255 client.player.group.play = AsyncMock(side_effect=FailedCommand("ERROR_PLAYBACK_FAILED"))
256
257
258@pytest.mark.asyncio
259async def test_a_source_that_refuses_to_resume_is_ended_right_away() -> None:
260 """Test a dead session does not have to sit out the grace period to be given up on."""
261 player, _, client = _make_externally_paused_player()
262 _refuse_to_resume(client)
263
264 await player.play()
265
266 assert player._attr_playback_state is PlaybackState.IDLE
267 assert player._attr_active_source is None
268 assert player._attr_current_media is None
269
270
271@pytest.mark.asyncio
272async def test_a_failing_play_on_our_own_queue_still_raises() -> None:
273 """Test a failure that is not about a stale external source is not swallowed."""
274 player, _, client = _make_externally_paused_player()
275 player._attr_active_source = None
276 _refuse_to_resume(client)
277
278 with pytest.raises(FailedCommand):
279 await player.play()
280
281
282@pytest.mark.asyncio
283async def test_a_coordinator_change_does_not_end_a_live_source() -> None:
284 """Test the race the speaker reports while regrouping is not read as a source that is gone."""
285 player, _, client = _make_externally_paused_player()
286 client.player.is_passive = False
287 client.player.group.play = AsyncMock(
288 side_effect=FailedCommand("ERROR_PLAYBACK_FAILED groupCoordinatorChanged")
289 )
290
291 with pytest.raises(FailedCommand):
292 await player.play()
293
294 assert player._attr_playback_state is PlaybackState.PAUSED
295 assert player._attr_active_source == SOURCE_SPOTIFY
296
297
298def _speaker_reporting_paused_spotify() -> tuple[SonosPlayer, MagicMock]:
299 """Create a connected player whose speaker reports the Spotify Connect state we captured."""
300 mass = MagicMock()
301 mass.closing = False
302 player, client = _bind_player(mass)
303 player.connected = True
304 player._attr_source_list = []
305 player._attr_group_members = []
306 player._attr_can_group_with = set()
307 player._provider = MagicMock(instance_id="sonos")
308 client.player.is_coordinator = True
309 client.player.group_members = ["sonos_player"]
310 group = client.player.group
311 group.playback_state = SonosPlayBackState.PLAYBACK_STATE_PAUSED
312 group.position = 42.0
313 group.container_type = "spotify.connect"
314 group.active_service = MusicService.SPOTIFY
315 group.playback_metadata = {
316 "container": {"name": "Spotify", "service": {"name": "Spotify"}},
317 "currentItem": {"id": "1", "track": {"name": "Shout"}},
318 }
319 return player, mass
320
321
322def test_a_paused_connect_session_is_handed_to_the_stale_source_check() -> None:
323 """Test what the speaker reports for Spotify Connect reaches the shared grace period."""
324 player, _ = _speaker_reporting_paused_spotify()
325
326 player.on_player_event(None)
327
328 assert player._attr_playback_state is PlaybackState.PAUSED
329 assert player._attr_active_source == SOURCE_SPOTIFY
330 # giving up on such a source is handled for every player alike, from update_state,
331 # so the speaker only has to opt in and let the state calculation see it
332 assert player._attr_external_pause_idle_timeout == EXTERNAL_PAUSE_IDLE_TIMEOUT
333 player.update_state.assert_called_once() # type: ignore[attr-defined]
334