/
/
/
1"""Tests for the Sonos player connection/reconnect handling."""
2
3import asyncio
4import logging
5import time
6from unittest.mock import AsyncMock, MagicMock
7
8import pytest
9from aiohttp import ConnectionTimeoutError
10from aiosonos.api.models import MusicService
11from aiosonos.api.models import PlayBackState as SonosPlayBackState
12from aiosonos.exceptions import CannotConnect, FailedCommand
13from music_assistant_models.enums import PlaybackState
14from music_assistant_models.player import PlayerMedia
15
16from music_assistant.mass import MusicAssistant
17from music_assistant.providers.sonos.const import (
18 EXTERNAL_PAUSE_IDLE_TIMEOUT,
19 SOURCE_SPOTIFY,
20)
21from music_assistant.providers.sonos.player import SonosPlayer
22
23
24def _bind_player(mass: MusicAssistant | MagicMock) -> tuple[SonosPlayer, MagicMock]:
25 """Create a SonosPlayer bound to the given MusicAssistant, with a mocked client."""
26 player = SonosPlayer.__new__(SonosPlayer)
27 client = MagicMock()
28 client.disconnect = AsyncMock()
29 player.mass = mass
30 player.logger = logging.getLogger("test.sonos.player")
31 player._player_id = "sonos_player"
32 player._listen_task = None
33 player.connected = False
34 player.client = client
35 player._on_unload_callbacks = []
36 player.update_state = MagicMock() # type: ignore[misc, method-assign]
37 return player, client
38
39
40def _make_player() -> tuple[SonosPlayer, MagicMock]:
41 """Create a SonosPlayer with mocked connection dependencies."""
42 mass = MagicMock()
43 mass.closing = False
44 mass.players.get_player.return_value = MagicMock()
45 player, _ = _bind_player(mass)
46 return player, mass
47
48
49async def _connect_player(player: SonosPlayer, client: MagicMock) -> None:
50 """Connect the player to a listener that stays alive until it is cancelled."""
51
52 async def _start_listening(init_ready: asyncio.Event) -> None:
53 init_ready.set()
54 await asyncio.sleep(3600)
55
56 client.connect = AsyncMock()
57 client.start_listening = _start_listening
58 await player._connect()
59
60
61@pytest.mark.asyncio
62async def test_connect_timeout_reschedules_reconnect() -> None:
63 """Test a blackholed connection (timeout, not refused) still schedules a retry."""
64 player, mass = _make_player()
65 player.client.connect = AsyncMock( # type: ignore[method-assign]
66 side_effect=ConnectionTimeoutError("Connection timeout to host https://x:1443")
67 )
68
69 await player._connect(retry_on_fail=30)
70
71 assert player._attr_available is False
72 mass.call_later.assert_called_once()
73 args, _ = mass.call_later.call_args
74 assert args[0] == min(30 + 30, 3600)
75 assert args[1] == player._connect
76
77
78@pytest.mark.asyncio
79async def test_connect_timeout_without_retry_raises() -> None:
80 """Test a connection failure without retry_on_fail still propagates."""
81 player, mass = _make_player()
82 player.client.connect = AsyncMock( # type: ignore[method-assign]
83 side_effect=ConnectionTimeoutError("Connection timeout to host https://x:1443")
84 )
85
86 with pytest.raises(ConnectionTimeoutError):
87 await player._connect(retry_on_fail=0)
88
89 mass.call_later.assert_not_called()
90
91
92@pytest.mark.asyncio
93async def test_connect_websocket_handshake_failure_reschedules_reconnect() -> None:
94 """Test a websocket handshake failure also reschedules a retry."""
95 player, mass = _make_player()
96 player.client.connect = AsyncMock( # type: ignore[method-assign]
97 side_effect=CannotConnect(OSError("handshake failed"))
98 )
99
100 await player._connect(retry_on_fail=30)
101
102 assert player._attr_available is False
103 mass.call_later.assert_called_once()
104
105
106@pytest.mark.asyncio
107async def test_on_unload_disconnects_without_reconnecting(timer_mass: MusicAssistant) -> None:
108 """Test an unloaded player disconnects and its aborted listener does not reconnect."""
109 player, client = _bind_player(timer_mass)
110 await _connect_player(player, client)
111 listener = player._listen_task
112 assert listener is not None
113
114 await player.on_unload()
115
116 assert player.connected is False
117 client.disconnect.assert_awaited_once()
118 # let the aborted listener run its cleanup
119 with pytest.raises(asyncio.CancelledError):
120 await listener
121 assert timer_mass._tracked_timers == {}
122
123
124@pytest.mark.asyncio
125async def test_on_unload_cancels_a_pending_reconnect(timer_mass: MusicAssistant) -> None:
126 """Test a reconnect that is still waiting to fire does not connect after the unload."""
127 player, _ = _bind_player(timer_mass)
128 connect_attempts: list[int] = []
129
130 async def _connect(retry_on_fail: int = 0) -> None:
131 connect_attempts.append(retry_on_fail)
132
133 player._connect = _connect # type: ignore[method-assign]
134 player.reconnect(0)
135 handle = timer_mass._tracked_timers[f"sonos_reconnect_{player.player_id}"]
136
137 await player.on_unload()
138 await asyncio.sleep(0.05)
139
140 assert handle.cancelled()
141 assert connect_attempts == []
142
143
144@pytest.mark.asyncio
145async def test_on_unload_cancels_a_scheduled_airplay_group_restore(
146 timer_mass: MusicAssistant,
147) -> None:
148 """Test the AirPlay group restore scheduled for a player does not run after the unload."""
149 player, client = _bind_player(timer_mass)
150 player._attr_name = "Sonos Player"
151 client.player.is_coordinator = True
152 client.player.group_members = [player.player_id, "sonos_player_2"]
153 output_protocol = MagicMock()
154 output_protocol.protocol_domain = "airplay"
155
156 await player.on_protocol_playback(output_protocol)
157 handle = timer_mass._tracked_timers[f"restore_airplay_group_{player.player_id}"]
158
159 await player.on_unload()
160
161 assert handle.cancelled()
162 assert timer_mass._tracked_timers == {}
163
164
165@pytest.mark.asyncio
166async def test_on_unload_cancels_an_airplay_group_restore_that_already_started(
167 timer_mass: MusicAssistant,
168) -> None:
169 """Test a group restore that already started is aborted when the player is unloaded."""
170 player, _ = _bind_player(timer_mass)
171 restoring = asyncio.Event()
172
173 async def _restore_airplay_group() -> None:
174 restoring.set()
175 await asyncio.sleep(5)
176
177 task_id = f"restore_airplay_group_{player.player_id}"
178 timer_mass.call_later(0, _restore_airplay_group, task_id=task_id)
179 await restoring.wait()
180 task = timer_mass._tracked_tasks[task_id]
181
182 await player.on_unload()
183
184 with pytest.raises(asyncio.CancelledError):
185 await task
186
187
188@pytest.mark.asyncio
189async def test_on_unload_unsubscribes_before_disconnecting(timer_mass: MusicAssistant) -> None:
190 """Test the registered unload callbacks run before the client is disconnected."""
191 player, client = _bind_player(timer_mass)
192 calls: list[str] = []
193 player._on_unload_callbacks.append(lambda: calls.append("unsubscribe"))
194 client.disconnect = AsyncMock(side_effect=lambda: calls.append("disconnect"))
195
196 await player.on_unload()
197
198 assert calls == ["unsubscribe", "disconnect"]
199
200
201@pytest.mark.asyncio
202async def test_a_listener_ending_during_the_unload_cannot_rearm_a_reconnect(
203 timer_mass: MusicAssistant,
204) -> None:
205 """Test a listener that ends while the player is unloading does not schedule a reconnect."""
206 player, client = _bind_player(timer_mass)
207 socket_drops = asyncio.Event()
208
209 async def _start_listening(init_ready: asyncio.Event) -> None:
210 init_ready.set()
211 await socket_drops.wait()
212 raise ConnectionResetError("socket dropped")
213
214 client.connect = AsyncMock()
215 client.start_listening = _start_listening
216 await player._connect()
217 listener = player._listen_task
218 assert listener is not None
219
220 # release the listener so it is queued to run its finally, then unload without
221 # yielding in between: the cancellations and connected=False must be indivisible
222 socket_drops.set()
223 await player.on_unload()
224
225 with pytest.raises(asyncio.CancelledError):
226 await listener
227 assert timer_mass._tracked_timers == {}
228 assert timer_mass._tracked_tasks == {}
229
230
231@pytest.mark.asyncio
232async def test_on_unload_survives_a_failing_disconnect(timer_mass: MusicAssistant) -> None:
233 """Test a speaker that cannot be disconnected does not abort the unload."""
234 player, client = _bind_player(timer_mass)
235 client.disconnect = AsyncMock(side_effect=OSError("speaker unreachable"))
236 player.reconnect(0)
237
238 await player.on_unload()
239
240 assert player.connected is False
241 assert timer_mass._tracked_timers == {}
242
243
244def _make_externally_paused_player() -> tuple[SonosPlayer, MagicMock, MagicMock]:
245 """Create a player reporting a paused external source, as Sonos does for Spotify Connect."""
246 mass = MagicMock()
247 mass.closing = False
248 player, client = _bind_player(mass)
249 player._external_pause_since = None
250 player._attr_playback_state = PlaybackState.PAUSED
251 player._attr_active_source = SOURCE_SPOTIFY
252 player._attr_current_media = PlayerMedia(uri="spotify:track:1", title="Shout")
253 return player, mass, client
254
255
256def _refuse_to_resume(client: MagicMock) -> None:
257 """Let the speaker reject the play command, as it does for a session it no longer has."""
258 client.player.is_passive = False
259 client.player.group.play = AsyncMock(side_effect=FailedCommand("ERROR_PLAYBACK_FAILED"))
260
261
262def test_a_paused_external_source_is_left_alone_at_first() -> None:
263 """Test a source that just paused stays resumable, so a real pause still works."""
264 player, mass, _ = _make_externally_paused_player()
265
266 player._expire_stale_external_pause()
267
268 assert player._attr_playback_state is PlaybackState.PAUSED
269 assert player._attr_active_source == SOURCE_SPOTIFY
270 # Sonos reports nothing when the session goes stale, so we must come back to it ourselves
271 assert mass.call_later.call_args.kwargs["task_id"] == "sonos_external_pause_sonos_player"
272
273
274def test_a_source_paused_within_the_grace_period_stays_resumable() -> None:
275 """Test a genuine pause can still be resumed from MA for as long as the grace period lasts."""
276 player, _, _ = _make_externally_paused_player()
277 player._external_pause_since = time.time() - EXTERNAL_PAUSE_IDLE_TIMEOUT + 5
278
279 player._expire_stale_external_pause()
280
281 assert player._attr_playback_state is PlaybackState.PAUSED
282 assert player._attr_active_source == SOURCE_SPOTIFY
283 assert player._attr_current_media is not None
284
285
286def test_an_external_source_paused_for_too_long_is_reported_as_idle() -> None:
287 """Test an abandoned session stops looking resumable, so play starts our own queue."""
288 player, _, _ = _make_externally_paused_player()
289 player._external_pause_since = time.time() - EXTERNAL_PAUSE_IDLE_TIMEOUT - 1
290
291 player._expire_stale_external_pause()
292
293 assert player._attr_playback_state is PlaybackState.IDLE
294 assert player._attr_active_source is None
295 assert player._attr_current_media is None
296
297
298def test_the_pause_clock_only_runs_while_the_player_reports_paused() -> None:
299 """Test resuming the source clears the clock, so a later pause gets the full grace period."""
300 player, _, _ = _make_externally_paused_player()
301 player._expire_stale_external_pause()
302 assert player._external_pause_since is not None
303
304 player._attr_playback_state = PlaybackState.PLAYING
305 player._expire_stale_external_pause()
306
307 assert player._external_pause_since is None
308
309
310def test_playback_that_is_not_paused_is_never_expired() -> None:
311 """Test our own playback (already mapped to idle by Sonos) never starts the clock."""
312 player, mass = _make_player()
313 player._external_pause_since = None
314 player._attr_playback_state = PlaybackState.IDLE
315 player._attr_active_source = None
316 player._attr_current_media = None
317
318 player._expire_stale_external_pause()
319
320 assert player._external_pause_since is None
321 mass.call_later.assert_not_called()
322
323
324@pytest.mark.asyncio
325async def test_on_unload_cancels_a_pending_external_pause_check(
326 timer_mass: MusicAssistant,
327) -> None:
328 """Test unloading a player leaves no timer behind to re-evaluate a paused source."""
329 player, _ = _bind_player(timer_mass)
330 player._external_pause_since = None
331 player._attr_playback_state = PlaybackState.PAUSED
332 player._attr_active_source = SOURCE_SPOTIFY
333 player._attr_current_media = None
334 player._expire_stale_external_pause()
335 assert timer_mass._tracked_timers != {}
336
337 await player.on_unload()
338
339 assert timer_mass._tracked_timers == {}
340
341
342@pytest.mark.asyncio
343async def test_a_source_that_refuses_to_resume_is_ended_right_away() -> None:
344 """Test a dead session does not have to sit out the grace period to be given up on."""
345 player, _, client = _make_externally_paused_player()
346 _refuse_to_resume(client)
347
348 await player.play()
349
350 assert player._attr_playback_state is PlaybackState.IDLE
351 assert player._attr_active_source is None
352 assert player._attr_current_media is None
353
354
355@pytest.mark.asyncio
356async def test_an_ended_source_is_not_picked_back_up_from_what_sonos_reports() -> None:
357 """Test the next update does not resurrect the source Sonos still reports as paused."""
358 player, _, client = _make_externally_paused_player()
359 _refuse_to_resume(client)
360 await player.play()
361
362 # Sonos keeps reporting the very same paused source on every update that follows
363 player._attr_playback_state = PlaybackState.PAUSED
364 player._attr_active_source = SOURCE_SPOTIFY
365 player._expire_stale_external_pause()
366
367 assert player._attr_playback_state is PlaybackState.IDLE
368 assert player._attr_active_source is None
369
370
371@pytest.mark.asyncio
372async def test_a_failing_play_on_our_own_queue_still_raises() -> None:
373 """Test a failure that is not about a stale external source is not swallowed."""
374 player, _, client = _make_externally_paused_player()
375 player._attr_active_source = None
376 _refuse_to_resume(client)
377
378 with pytest.raises(FailedCommand):
379 await player.play()
380
381
382@pytest.mark.asyncio
383async def test_a_coordinator_change_does_not_end_a_live_source() -> None:
384 """Test the race the speaker reports while regrouping is not read as a source that is gone."""
385 player, _, client = _make_externally_paused_player()
386 client.player.is_passive = False
387 client.player.group.play = AsyncMock(
388 side_effect=FailedCommand("ERROR_PLAYBACK_FAILED groupCoordinatorChanged")
389 )
390
391 with pytest.raises(FailedCommand):
392 await player.play()
393
394 assert player._attr_playback_state is PlaybackState.PAUSED
395 assert player._attr_active_source == SOURCE_SPOTIFY
396
397
398def _speaker_reporting_paused_spotify() -> tuple[SonosPlayer, MagicMock]:
399 """Create a connected player whose speaker reports the Spotify Connect state we captured."""
400 mass = MagicMock()
401 mass.closing = False
402 player, client = _bind_player(mass)
403 player.connected = True
404 player._attr_source_list = []
405 player._attr_group_members = []
406 player._attr_can_group_with = set()
407 player._provider = MagicMock(instance_id="sonos")
408 player._external_pause_since = None
409 client.player.is_coordinator = True
410 client.player.group_members = ["sonos_player"]
411 group = client.player.group
412 group.playback_state = SonosPlayBackState.PLAYBACK_STATE_PAUSED
413 group.position = 42.0
414 group.container_type = "spotify.connect"
415 group.active_service = MusicService.SPOTIFY
416 group.playback_metadata = {
417 "container": {"name": "Spotify", "service": {"name": "Spotify"}},
418 "currentItem": {"id": "1", "track": {"name": "Shout"}},
419 }
420 return player, mass
421
422
423def test_update_attributes_reports_a_long_paused_source_as_idle() -> None:
424 """Test the whole path, from what the speaker reports to the state we hand to MA."""
425 player, _ = _speaker_reporting_paused_spotify()
426
427 player.update_attributes()
428 while_paused = (player._attr_playback_state, player._attr_active_source)
429 assert while_paused == (PlaybackState.PAUSED, SOURCE_SPOTIFY)
430
431 # the speaker keeps reporting the very same paused source once the session goes stale
432 player._external_pause_since = time.time() - EXTERNAL_PAUSE_IDLE_TIMEOUT - 1
433 player.update_attributes()
434
435 assert player._attr_playback_state is PlaybackState.IDLE
436 assert player._attr_active_source is None
437 assert player._attr_current_media is None
438
439
440def test_the_pause_check_stays_armed_until_the_grace_period_is_over() -> None:
441 """Test an update that arrives meanwhile cannot consume the only pending check."""
442 player, mass = _speaker_reporting_paused_spotify()
443
444 player.update_attributes()
445 player.update_attributes()
446
447 assert mass.call_later.call_count == 2
448 delay, callback, event = mass.call_later.call_args.args
449 assert delay > EXTERNAL_PAUSE_IDLE_TIMEOUT
450 assert callback == player.on_player_event
451 assert event is None
452