/
/
/
1"""Unit tests for the Sonos S1 player."""
2
3from __future__ import annotations
4
5import asyncio
6import threading
7from functools import partial
8from typing import TYPE_CHECKING, cast
9from unittest.mock import AsyncMock, MagicMock, patch
10
11import pytest
12from music_assistant_models.enums import MediaType, PlaybackState
13from music_assistant_models.errors import PlayerCommandFailed
14from soco.exceptions import SoCoException
15
16from music_assistant.mass import MusicAssistant
17from music_assistant.models.player import PlayerMedia
18from music_assistant.providers.sonos_s1 import player as player_module
19from music_assistant.providers.sonos_s1.constants import (
20 POLL_INTERVAL,
21 SUBSCRIPTION_SERVICES,
22 TRANSITION_POLL_INTERVAL,
23)
24from music_assistant.providers.sonos_s1.player import SonosPlayer
25
26if TYPE_CHECKING:
27 from collections.abc import AsyncGenerator
28
29STREAM_URL = "http://192.168.1.2:8097/single/sessionabc/queue1/item1/player1.flac"
30
31
32def _make_soco(uid: str = "RINCON_000E58AAAAAA01400", name: str = "Test Sonos") -> MagicMock:
33 """Create a mocked soco device."""
34 soco = MagicMock()
35 soco.uid = uid
36 soco.household_id = "Sonos_household"
37 soco.player_name = name
38 soco._player_name = name
39 soco.ip_address = "127.0.0.1"
40 soco.speaker_info = {"model_name": "Sonos Play:1"}
41 return soco
42
43
44@pytest.fixture
45def sonos_player() -> SonosPlayer:
46 """Create a SonosPlayer with a mocked soco device and provider."""
47 provider = MagicMock()
48 provider.mass.streams.resolve_stream_url = AsyncMock(return_value=STREAM_URL)
49 return SonosPlayer(provider=provider, soco=_make_soco())
50
51
52@pytest.fixture
53async def timer_mass() -> AsyncGenerator[MusicAssistant]:
54 """Create a bare MusicAssistant exposing the real call_later/cancel_timer machinery."""
55 mass = object.__new__(MusicAssistant)
56 mass.loop = asyncio.get_running_loop()
57 mass.loop_thread_id = threading.get_ident()
58 mass._tracked_timers = {}
59 mass._tracked_tasks = {}
60 mass.config = MagicMock()
61 mass.players = MagicMock()
62 mass.players.all_players.return_value = []
63 yield mass
64 for handle in mass._tracked_timers.values():
65 handle.cancel()
66 for task in mass._tracked_tasks.values():
67 task.cancel()
68
69
70def _make_player(mass: MusicAssistant, uid: str, name: str) -> SonosPlayer:
71 """Create a SonosPlayer bound to the given MusicAssistant."""
72 provider = MagicMock()
73 provider.mass = mass
74 provider.topology_condition = asyncio.Condition()
75 return SonosPlayer(provider=provider, soco=_make_soco(uid, name))
76
77
78def _poll_id(player: SonosPlayer) -> str:
79 """Return the task id that debounces the follow-up poll of the given speaker."""
80 return f"sonos_poll_{player.player_id}"
81
82
83def _pending_polls(mass: MusicAssistant) -> list[str]:
84 """Return the task ids of all pending speaker polls."""
85 return sorted(task_id for task_id in mass._tracked_timers if task_id.startswith("sonos_poll_"))
86
87
88def _make_media() -> PlayerMedia:
89 """Return PlayerMedia as built by the queue controller, with an MA media uri."""
90 return PlayerMedia(
91 uri="library://track/123",
92 media_type=MediaType.TRACK,
93 title="Test Track",
94 artist="Test Artist",
95 album="Test Album",
96 duration=180,
97 source_id="queue1",
98 queue_item_id="item1",
99 )
100
101
102async def test_enqueue_next_media_builds_didl_from_stream_url(
103 sonos_player: SonosPlayer,
104) -> None:
105 """The enqueue metadata res element must contain the stream url, not the MA media uri."""
106 await sonos_player.enqueue_next_media(_make_media())
107 call_args = sonos_player.soco.avTransport.SetNextAVTransportURI.call_args
108 args = dict(call_args.args[0])
109 assert args["NextURI"] == STREAM_URL
110 assert STREAM_URL in args["NextURIMetaData"]
111 assert "library://track/123" not in args["NextURIMetaData"]
112
113
114async def test_play_media_builds_didl_from_stream_url(sonos_player: SonosPlayer) -> None:
115 """The play metadata res element must contain the stream url, not the MA media uri."""
116 await sonos_player.play_media(_make_media())
117 call_args = sonos_player.soco.play_uri.call_args
118 assert call_args.args[0] == STREAM_URL
119 assert STREAM_URL in call_args.kwargs["meta"]
120 assert "library://track/123" not in call_args.kwargs["meta"]
121
122
123def _set_transport_state(sonos_player: SonosPlayer, state: str) -> None:
124 """Make the mocked speaker report the given transport state."""
125 sonos_player.soco.get_current_transport_info.return_value = {"current_transport_state": state}
126
127
128def test_transitional_state_shortens_the_poll_interval(sonos_player: SonosPlayer) -> None:
129 """A transitional transport state keeps the last known state and is watched closely."""
130 sonos_player._attr_playback_state = PlaybackState.IDLE
131 _set_transport_state(sonos_player, "TRANSITIONING")
132
133 sonos_player.poll_media()
134
135 assert sonos_player._attr_playback_state == PlaybackState.IDLE
136 assert sonos_player.poll_interval == TRANSITION_POLL_INTERVAL
137
138
139def test_settled_state_restores_the_poll_interval(sonos_player: SonosPlayer) -> None:
140 """A usable transport state returns the speaker to the regular poll interval."""
141 sonos_player._attr_poll_interval = TRANSITION_POLL_INTERVAL
142
143 _set_transport_state(sonos_player, "PLAYING")
144 with (
145 patch.object(sonos_player, "_set_basic_track_info"),
146 patch.object(sonos_player, "update_player"),
147 ):
148 sonos_player.poll_media()
149
150 assert sonos_player.poll_interval == POLL_INTERVAL
151
152
153def test_transitional_event_shortens_the_poll_interval(sonos_player: SonosPlayer) -> None:
154 """A transitional state delivered by subscription event is watched closely too."""
155 event = MagicMock()
156 event.variables = {"transport_state": "TRANSITIONING"}
157
158 sonos_player._handle_avtransport_event(event)
159
160 assert sonos_player.poll_interval == TRANSITION_POLL_INTERVAL
161
162
163async def test_repeated_commands_collapse_to_one_pending_poll(
164 timer_mass: MusicAssistant,
165) -> None:
166 """A burst of commands leaves a single pending poll instead of one poll per command."""
167 player = _make_player(timer_mass, "RINCON_000E58AAAAAA01400", "Kitchen")
168
169 await player.volume_set(30)
170 first_handle = timer_mass._tracked_timers[_poll_id(player)]
171 await player.volume_set(40)
172 await player.volume_mute(True)
173 await player.play()
174
175 assert _pending_polls(timer_mass) == [_poll_id(player)]
176 assert first_handle.cancelled()
177
178
179async def test_each_speaker_keeps_its_own_pending_poll(timer_mass: MusicAssistant) -> None:
180 """Commands to different speakers are polled independently."""
181 kitchen = _make_player(timer_mass, "RINCON_000E58AAAAAA01400", "Kitchen")
182 study = _make_player(timer_mass, "RINCON_000E58BBBBBB01400", "Study")
183
184 await kitchen.volume_set(30)
185 await study.volume_set(40)
186
187 assert _pending_polls(timer_mass) == sorted([_poll_id(kitchen), _poll_id(study)])
188
189
190async def test_set_members_polls_the_speakers_it_regrouped(timer_mass: MusicAssistant) -> None:
191 """Grouping polls each speaker that was joined or unjoined, not the coordinator."""
192 kitchen = _make_player(timer_mass, "RINCON_000E58AAAAAA01400", "Kitchen")
193 study = _make_player(timer_mass, "RINCON_000E58BBBBBB01400", "Study")
194 hallway = _make_player(timer_mass, "RINCON_000E58CCCCCC01400", "Hallway")
195 cast("MagicMock", timer_mass.players).get_player.side_effect = {
196 study.player_id: study,
197 hallway.player_id: hallway,
198 }.get
199
200 await kitchen.set_members(player_ids_to_add=[study.player_id, hallway.player_id])
201
202 assert _pending_polls(timer_mass) == sorted([_poll_id(study), _poll_id(hallway)])
203
204
205async def test_grouping_failure_is_reported_as_a_player_command_failure(
206 timer_mass: MusicAssistant,
207) -> None:
208 """A speaker that refuses to join surfaces as a typed player command failure."""
209 kitchen = _make_player(timer_mass, "RINCON_000E58AAAAAA01400", "Kitchen")
210 study = _make_player(timer_mass, "RINCON_000E58BBBBBB01400", "Study")
211 cast("MagicMock", timer_mass.players).get_player.side_effect = {study.player_id: study}.get
212 study.soco.join.side_effect = SoCoException("the speaker refused to join")
213
214 with pytest.raises(PlayerCommandFailed, match="Kitchen"):
215 await kitchen.set_members(player_ids_to_add=[study.player_id])
216
217
218async def test_unload_cancels_the_pending_poll(timer_mass: MusicAssistant) -> None:
219 """An unloaded speaker is not polled by a command that was still in flight."""
220 player = _make_player(timer_mass, "RINCON_000E58AAAAAA01400", "Kitchen")
221 await player.volume_set(30)
222 handle = timer_mass._tracked_timers[_poll_id(player)]
223
224 await player.on_unload()
225
226 assert _pending_polls(timer_mass) == []
227 assert handle.cancelled()
228
229
230async def test_unload_cancels_a_poll_that_already_started(
231 timer_mass: MusicAssistant, monkeypatch: pytest.MonkeyPatch
232) -> None:
233 """A poll that started just before the speaker was unloaded is aborted."""
234 monkeypatch.setattr(player_module, "COMMAND_POLL_DELAY", 0)
235 player = _make_player(timer_mass, "RINCON_000E58AAAAAA01400", "Kitchen")
236 polling = asyncio.Event()
237
238 async def _slow_poll() -> None:
239 polling.set()
240 await asyncio.sleep(5)
241
242 monkeypatch.setattr(player, "poll", _slow_poll)
243 player.schedule_poll()
244 await polling.wait()
245 task = timer_mass._tracked_tasks[_poll_id(player)]
246
247 await player.on_unload()
248
249 with pytest.raises(asyncio.CancelledError):
250 await task
251
252
253async def test_unloaded_player_ignores_results_from_a_running_poll(
254 timer_mass: MusicAssistant,
255) -> None:
256 """A poll left running in its worker thread cannot update an unloaded speaker."""
257 player = _make_player(timer_mass, "RINCON_000E58AAAAAA01400", "Kitchen")
258 await player.on_unload()
259
260 with patch.object(player, "_update_attributes") as update_attributes:
261 player.update_player()
262
263 update_attributes.assert_not_called()
264
265
266async def test_unloaded_player_ignores_group_topology_updates(
267 timer_mass: MusicAssistant,
268) -> None:
269 """A group update raised by a running poll cannot update an unloaded speaker."""
270 player = _make_player(timer_mass, "RINCON_000E58AAAAAA01400", "Kitchen")
271 # let the speaker report itself as coordinator of a two-speaker group, so a
272 # topology update would regroup and signal the change if it were not unloaded
273 member = MagicMock(uid="RINCON_000E58BBBBBB01400", is_visible=True)
274 player.soco.group.coordinator.uid = player.player_id
275 player.soco.group.members = [player.soco.group.coordinator, member]
276 await player.on_unload()
277
278 with patch.object(player, "update_state") as update_state:
279 await player.create_update_groups_coro()
280 await asyncio.sleep(0)
281
282 assert player.group_members == []
283 update_state.assert_not_called()
284
285
286async def test_on_unload_unsubscribes_from_soco_events(timer_mass: MusicAssistant) -> None:
287 """Unloading a speaker also tears down its soco event subscriptions."""
288 player = _make_player(timer_mass, "RINCON_000E58AAAAAA01400", "Kitchen")
289 subscription = MagicMock()
290 subscription.unsubscribe = AsyncMock()
291 player._subscriptions = [subscription]
292
293 await player.on_unload()
294
295 subscription.unsubscribe.assert_awaited_once()
296 assert player._subscriptions == []
297
298
299async def test_on_unload_unsubscribes_even_when_already_unavailable(
300 timer_mass: MusicAssistant,
301) -> None:
302 """Unlike offline(), on_unload() must still tear down an unavailable speaker's events."""
303 player = _make_player(timer_mass, "RINCON_000E58AAAAAA01400", "Kitchen")
304 subscription = MagicMock()
305 subscription.unsubscribe = AsyncMock()
306 player._subscriptions = [subscription]
307 player._attr_available = False
308
309 await player.on_unload()
310
311 subscription.unsubscribe.assert_awaited_once()
312 assert player._subscriptions == []
313
314
315async def test_unloaded_player_is_not_resubscribed_by_a_late_poll(
316 timer_mass: MusicAssistant,
317) -> None:
318 """A poll that reaches an unloaded speaker must not subscribe it to events again."""
319 player = _make_player(timer_mass, "RINCON_000E58AAAAAA01400", "Kitchen")
320 # the speaker was marked unavailable earlier, so it carries no subscriptions and
321 # unloading it leaves that unavailable state behind for a late poll to act on
322 player._attr_available = False
323 await player.on_unload()
324
325 with patch.object(player, "subscribe", AsyncMock()) as subscribe:
326 await player.poll()
327
328 subscribe.assert_not_called()
329 assert player._attr_available is False
330
331
332async def test_speaker_answering_a_poll_again_is_resubscribed(
333 timer_mass: MusicAssistant,
334) -> None:
335 """A speaker that answers a poll after being unavailable is subscribed to events again."""
336 player = _make_player(timer_mass, "RINCON_000E58AAAAAA01400", "Kitchen")
337 player.soco.group.coordinator.uid = player.player_id
338 player.soco.group.members = [player.soco.group.coordinator]
339 player._attr_available = False
340
341 with patch.object(player, "subscribe", AsyncMock()) as subscribe:
342 await player.poll()
343
344 subscribe.assert_called_once()
345 assert player._attr_available is True
346
347
348async def test_unloading_mid_subscribe_keeps_no_subscriptions(
349 timer_mass: MusicAssistant,
350) -> None:
351 """A speaker unloaded while subscribing must not keep the subscriptions it created."""
352 player = _make_player(timer_mass, "RINCON_000E58AAAAAA01400", "Kitchen")
353 subscribing = asyncio.Event()
354 speaker_responds = asyncio.Event()
355
356 async def _slow_subscribe_target(_target: object, _callback: object) -> None:
357 subscribing.set()
358 await speaker_responds.wait()
359 player._subscriptions.append(MagicMock(unsubscribe=AsyncMock()))
360
361 with patch.object(player, "_subscribe_target", _slow_subscribe_target):
362 subscribe_task = asyncio.create_task(player.subscribe())
363 await subscribing.wait()
364
365 unload_task = asyncio.create_task(player.on_unload())
366 await asyncio.sleep(0)
367 assert not unload_task.done()
368
369 speaker_responds.set()
370 async with asyncio.timeout(5):
371 await asyncio.gather(subscribe_task, unload_task)
372
373 assert player._subscriptions == []
374
375
376async def test_unsubscribe_drops_subscriptions_even_when_cancelled() -> None:
377 """A cancelled unsubscribe must not leave stale entries that block resubscribing."""
378 provider = MagicMock()
379 player = SonosPlayer(provider=provider, soco=_make_soco())
380 subscription = MagicMock()
381 subscription.unsubscribe = AsyncMock(side_effect=partial(asyncio.sleep, 5))
382 player._subscriptions = [subscription]
383
384 task = asyncio.create_task(player.unsubscribe())
385 await asyncio.sleep(0)
386 task.cancel()
387 with pytest.raises(asyncio.CancelledError):
388 await task
389
390 assert player._subscriptions == []
391 assert player.missing_subscriptions == SUBSCRIPTION_SERVICES
392
393
394async def _subscribe_with_failing_speaker(player: SonosPlayer) -> None:
395 """Let the given player attempt to subscribe to a speaker that cannot be reached."""
396 with (
397 patch.object(player, "_subscribe_target", AsyncMock(side_effect=OSError("unreachable"))),
398 patch.object(player, "update_state"),
399 ):
400 async with asyncio.timeout(5):
401 await player.subscribe()
402
403
404async def test_failed_subscribe_marks_the_speaker_offline() -> None:
405 """A failed subscription must take the speaker offline and release the lock."""
406 player = SonosPlayer(provider=MagicMock(), soco=_make_soco())
407
408 await _subscribe_with_failing_speaker(player)
409
410 assert player.available is False
411 assert not player._subscription_lock.locked()
412
413
414async def test_speaker_can_resubscribe_after_a_failed_subscribe() -> None:
415 """A speaker that failed to subscribe must still be able to subscribe later."""
416 player = SonosPlayer(provider=MagicMock(), soco=_make_soco())
417 await _subscribe_with_failing_speaker(player)
418
419 with patch.object(player, "_subscribe_target", AsyncMock()) as subscribe_target:
420 async with asyncio.timeout(5):
421 await player.subscribe()
422
423 assert subscribe_target.await_count == len(SUBSCRIPTION_SERVICES)
424
425
426async def test_speaker_taken_offline_mid_subscribe_keeps_no_subscriptions() -> None:
427 """A speaker that goes offline while subscribing must not keep the subscriptions it created."""
428 player = SonosPlayer(provider=MagicMock(), soco=_make_soco())
429 subscribing = asyncio.Event()
430 speaker_responds = asyncio.Event()
431
432 async def _slow_subscribe_target(_target: object, _callback: object) -> None:
433 subscribing.set()
434 await speaker_responds.wait()
435 player._subscriptions.append(MagicMock(unsubscribe=AsyncMock()))
436
437 with (
438 patch.object(player, "_subscribe_target", _slow_subscribe_target),
439 patch.object(player, "update_state"),
440 ):
441 subscribe_task = asyncio.create_task(player.subscribe())
442 await subscribing.wait()
443
444 offline_task = asyncio.create_task(player.offline())
445 await asyncio.sleep(0)
446 assert not offline_task.done()
447
448 speaker_responds.set()
449 async with asyncio.timeout(5):
450 await asyncio.gather(subscribe_task, offline_task)
451
452 assert player.available is False
453 assert player._subscriptions == []
454 assert player.missing_subscriptions == SUBSCRIPTION_SERVICES
455
456
457async def test_speaker_going_offline_is_not_resubscribed_halfway() -> None:
458 """No new subscriptions may be created while a speaker is still going offline."""
459 player = SonosPlayer(provider=MagicMock(), soco=_make_soco())
460 unsubscribing = asyncio.Event()
461 speaker_responds = asyncio.Event()
462
463 async def _slow_unsubscribe() -> None:
464 unsubscribing.set()
465 await speaker_responds.wait()
466
467 player._subscriptions = [MagicMock(unsubscribe=_slow_unsubscribe)]
468
469 with (
470 patch.object(player, "_subscribe_target", AsyncMock()) as subscribe_target,
471 patch.object(player, "update_state"),
472 ):
473 offline_task = asyncio.create_task(player.offline())
474 await unsubscribing.wait()
475
476 subscribe_task = asyncio.create_task(player.subscribe())
477 await asyncio.sleep(0)
478 subscribe_target.assert_not_called()
479
480 speaker_responds.set()
481 async with asyncio.timeout(5):
482 await asyncio.gather(offline_task, subscribe_task)
483
484 assert subscribe_target.await_count == len(SUBSCRIPTION_SERVICES)
485