/
/
/
1"""Tests for the shared playback session helper."""
2
3from __future__ import annotations
4
5import asyncio
6from collections.abc import Callable, Coroutine
7from typing import TYPE_CHECKING, cast
8from unittest.mock import AsyncMock, MagicMock, patch
9
10import pytest
11from music_assistant_models.enums import PlayerFeature
12from music_assistant_models.errors import SetupFailedError, UnsupportedFeaturedException
13
14from music_assistant.helpers.shared_playback import SharedPlaybackMode, SharedPlaybackSession
15from tests.common import collect_loop_errors
16
17if TYPE_CHECKING:
18 from music_assistant.mass import MusicAssistant
19 from music_assistant.providers.sendspin.provider import SendspinProvider
20
21
22def _create_mock_mass(venue_player: MagicMock | None) -> MagicMock:
23 """Create a mock MusicAssistant with a mocked players controller."""
24 mass = MagicMock()
25 mass.players.get_player.return_value = venue_player
26 mass.players.cmd_set_members = AsyncMock()
27 return mass
28
29
30def _create_mock_remote_mass(
31 sendspin: MagicMock,
32) -> tuple[MagicMock, list[asyncio.Task[object]]]:
33 """Create a mock MusicAssistant that tracks created tasks."""
34 mass = MagicMock()
35 tasks: list[asyncio.Task[object]] = []
36
37 def _create_task(
38 target: Coroutine[object, object, object],
39 **_kwargs: object,
40 ) -> asyncio.Task[object]:
41 task = asyncio.create_task(target)
42 tasks.append(task)
43 return task
44
45 mass.get_provider.return_value = sendspin
46 mass.create_task.side_effect = _create_task
47 return mass, tasks
48
49
50def _create_venue_player(
51 *,
52 can_group_with: set[str] | None = None,
53 group_members: list[str] | None = None,
54 supports_set_members: bool = True,
55) -> MagicMock:
56 """Create a mock venue player with the given grouping capabilities."""
57 player = MagicMock()
58 player.state.available = True
59 player.state.supported_features = {PlayerFeature.SET_MEMBERS} if supports_set_members else set()
60 player.state.can_group_with = can_group_with or set()
61 player.state.group_members = group_members or []
62 return player
63
64
65async def _wait_for(condition: Callable[[], bool], timeout: float = 5.0) -> None:
66 """Wait until the given condition returns true."""
67 loop = asyncio.get_running_loop()
68 deadline = loop.time() + timeout
69 while loop.time() < deadline:
70 if condition():
71 return
72 await asyncio.sleep(0.05)
73 raise TimeoutError("Condition not met within timeout")
74
75
76# ==================== VENUE mode ====================
77
78
79async def test_create_venue_session() -> None:
80 """A venue session exposes the venue player's player/queue id."""
81 mass = _create_mock_mass(_create_venue_player())
82
83 session = await SharedPlaybackSession.create_venue(mass, "venue_player")
84
85 assert session.mode == SharedPlaybackMode.VENUE
86 assert session.player_id == "venue_player"
87 assert session.queue_id == "venue_player"
88
89
90async def test_create_venue_unknown_player() -> None:
91 """Creating a venue session for an unknown player raises."""
92 mass = _create_mock_mass(None)
93
94 with pytest.raises(SetupFailedError):
95 await SharedPlaybackSession.create_venue(mass, "unknown_player")
96
97
98async def test_venue_can_listen_in_feature_detection() -> None:
99 """Listen-in is only possible when the venue player can group with the web player."""
100 venue_player = _create_venue_player(can_group_with={"web_player_1"})
101 mass = _create_mock_mass(venue_player)
102 session = await SharedPlaybackSession.create_venue(mass, "venue_player")
103
104 assert session.can_listen_in("web_player_1") is True
105 assert session.can_listen_in("web_player_2") is False
106
107
108async def test_venue_can_listen_in_already_grouped() -> None:
109 """A web player that is already a group member can (still) listen in."""
110 venue_player = _create_venue_player(group_members=["web_player_1"])
111 mass = _create_mock_mass(venue_player)
112 session = await SharedPlaybackSession.create_venue(mass, "venue_player")
113
114 assert session.can_listen_in("web_player_1") is True
115
116
117async def test_venue_can_listen_in_no_set_members() -> None:
118 """Listen-in is not possible when the venue player does not support grouping."""
119 venue_player = _create_venue_player(can_group_with={"web_player_1"}, supports_set_members=False)
120 mass = _create_mock_mass(venue_player)
121 session = await SharedPlaybackSession.create_venue(mass, "venue_player")
122
123 assert session.can_listen_in("web_player_1") is False
124
125
126async def test_venue_add_and_remove_guest_listener() -> None:
127 """Guest listeners are attached/detached via the players controller."""
128 venue_player = _create_venue_player(can_group_with={"web_player_1"})
129 mass = _create_mock_mass(venue_player)
130 session = await SharedPlaybackSession.create_venue(mass, "venue_player")
131
132 await session.add_guest_listener("web_player_1")
133 mass.players.cmd_set_members.assert_awaited_with(
134 "venue_player", player_ids_to_add=["web_player_1"]
135 )
136
137 await session.remove_guest_listener("web_player_1")
138 mass.players.cmd_set_members.assert_awaited_with(
139 "venue_player", player_ids_to_remove=["web_player_1"]
140 )
141
142
143async def test_venue_restores_remembered_listener_only_when_missing() -> None:
144 """Restore a tracked listener after its actual group membership disappears."""
145 venue_player = _create_venue_player(can_group_with={"web_player_1"})
146 guest_player = MagicMock()
147 mass = _create_mock_mass(venue_player)
148 mass.players.get_player.side_effect = lambda player_id: (
149 venue_player if player_id == "venue_player" else guest_player
150 )
151 session = await SharedPlaybackSession.create_venue(mass, "venue_player")
152 await session.add_guest_listener("web_player_1")
153 venue_player.state.group_members = ["venue_player", "web_player_1"]
154 mass.players.cmd_set_members.reset_mock()
155
156 await session.restore_guest_listeners()
157 mass.players.cmd_set_members.assert_not_awaited()
158
159 venue_player.state.group_members = ["venue_player"]
160 await session.restore_guest_listeners()
161
162 mass.players.cmd_set_members.assert_awaited_once_with(
163 "venue_player", player_ids_to_add=["web_player_1"]
164 )
165 assert session._guest_listeners == {"web_player_1"}
166
167
168async def test_venue_restore_skips_disappeared_listener() -> None:
169 """Keep a missing listener remembered without blocking playback restoration."""
170 venue_player = _create_venue_player(can_group_with={"web_player_1"})
171 guest_player = MagicMock()
172 mass = _create_mock_mass(venue_player)
173 players = {
174 "venue_player": venue_player,
175 "web_player_1": guest_player,
176 }
177 mass.players.get_player.side_effect = players.get
178 session = await SharedPlaybackSession.create_venue(mass, "venue_player")
179 await session.add_guest_listener("web_player_1")
180 mass.players.cmd_set_members.reset_mock()
181 players.pop("web_player_1")
182
183 await session.restore_guest_listeners()
184
185 mass.players.cmd_set_members.assert_not_awaited()
186 assert session._guest_listeners == {"web_player_1"}
187
188
189async def test_venue_restore_failure_does_not_block_playback() -> None:
190 """Keep listener restoration best-effort when grouping temporarily fails."""
191 venue_player = _create_venue_player(can_group_with={"web_player_1"})
192 guest_player = MagicMock()
193 mass = _create_mock_mass(venue_player)
194 mass.players.get_player.side_effect = lambda player_id: (
195 venue_player if player_id == "venue_player" else guest_player
196 )
197 session = await SharedPlaybackSession.create_venue(mass, "venue_player")
198 await session.add_guest_listener("web_player_1")
199 mass.players.cmd_set_members.reset_mock()
200 mass.players.cmd_set_members.side_effect = UnsupportedFeaturedException(
201 "Grouping temporarily unavailable"
202 )
203
204 await session.restore_guest_listeners()
205
206 mass.players.cmd_set_members.assert_awaited_once_with(
207 "venue_player", player_ids_to_add=["web_player_1"]
208 )
209 assert session._guest_listeners == {"web_player_1"}
210
211
212async def test_venue_add_guest_listener_unsupported() -> None:
213 """Attaching an incompatible web player raises."""
214 venue_player = _create_venue_player(can_group_with=set())
215 mass = _create_mock_mass(venue_player)
216 session = await SharedPlaybackSession.create_venue(mass, "venue_player")
217
218 with pytest.raises(UnsupportedFeaturedException):
219 await session.add_guest_listener("web_player_1")
220 mass.players.cmd_set_members.assert_not_awaited()
221
222
223async def test_venue_close_detaches_only_tracked_listeners() -> None:
224 """Closing a venue session only detaches the guest listeners it added."""
225 venue_player = _create_venue_player(can_group_with={"web_player_1"})
226 mass = _create_mock_mass(venue_player)
227 session = await SharedPlaybackSession.create_venue(mass, "venue_player")
228 await session.add_guest_listener("web_player_1")
229
230 await session.close()
231
232 mass.players.cmd_set_members.assert_awaited_with(
233 "venue_player", player_ids_to_remove=["web_player_1"]
234 )
235
236
237async def test_venue_close_without_listeners() -> None:
238 """Closing a venue session without listeners never touches the venue player."""
239 mass = _create_mock_mass(_create_venue_player())
240 session = await SharedPlaybackSession.create_venue(mass, "venue_player")
241
242 await session.close()
243
244 mass.players.cmd_set_members.assert_not_awaited()
245
246
247# ==================== REMOTE mode ====================
248
249
250async def test_create_remote_session(mass: MusicAssistant) -> None:
251 """A remote session creates a hidden virtual player that owns a queue."""
252 sendspin = cast("SendspinProvider | None", mass.get_provider("sendspin"))
253 assert sendspin is not None
254
255 session = await SharedPlaybackSession.create_remote(
256 mass, owner_instance_id=sendspin.instance_id, display_name="Test Party"
257 )
258
259 assert session.mode == SharedPlaybackMode.REMOTE
260 player = mass.players.get_player(session.player_id)
261 assert player is not None
262 assert player.hidden_by_default is True
263 assert mass.player_queues.get(session.queue_id) is not None
264 # listen-in is only possible for real, groupable sendspin players
265 guest_player_id = await sendspin.create_virtual_player(
266 owner_instance_id=sendspin.instance_id, display_name="Guest"
267 )
268 # refresh the host player's calculated state (normally debounced)
269 player.update_state(signal_event=False)
270 assert session.can_listen_in(guest_player_id) is True
271 assert session.can_listen_in("nonexistent_player") is False
272 await sendspin.remove_virtual_player(guest_player_id)
273
274 await session.close()
275 assert mass.players.get_player(session.player_id) is None
276 # with the virtual host player gone, listen-in is no longer possible
277 assert session.can_listen_in(guest_player_id) is False
278
279
280async def test_remote_session_restores_listener_before_next_track(
281 mass: MusicAssistant,
282) -> None:
283 """Restore real Sendspin membership after stop before the next queue track."""
284 sendspin = cast("SendspinProvider | None", mass.get_provider("sendspin"))
285 assert sendspin is not None
286 await mass.config._create_provider_instance("test", {})
287 assert mass.get_provider("test") is not None
288 session = await SharedPlaybackSession.create_remote(
289 mass,
290 owner_instance_id=sendspin.instance_id,
291 display_name="Test Party",
292 )
293 guest_player_id = await sendspin.create_virtual_player(
294 owner_instance_id=sendspin.instance_id,
295 display_name="Guest",
296 )
297 host_player = mass.players.get_player(session.player_id)
298 assert host_player is not None
299 host_player.update_state(signal_event=False)
300
301 try:
302 await session.add_guest_listener(guest_player_id)
303 await _wait_for(lambda: guest_player_id in host_player.state.group_members)
304 await mass.player_queues.play_media(session.queue_id, "test://track/0_0_0")
305 await mass.player_queues.stop(session.queue_id)
306 await mass.players.cmd_set_members(
307 session.player_id,
308 player_ids_to_remove=[guest_player_id],
309 )
310 await _wait_for(lambda: guest_player_id not in host_player.state.group_members)
311
312 await session.restore_guest_listeners()
313 await _wait_for(lambda: guest_player_id in host_player.state.group_members)
314 await mass.player_queues.play_media(session.queue_id, "test://track/0_0_1")
315
316 queue = mass.player_queues.get(session.queue_id)
317 assert queue is not None
318 assert queue.current_item is not None
319 assert queue.current_item.uri == "test://track/0_0_1"
320 assert session._guest_listeners == {guest_player_id}
321 finally:
322 await session.close()
323 await sendspin.remove_virtual_player(guest_player_id)
324
325
326async def test_create_remote_session_deterministic_id(mass: MusicAssistant) -> None:
327 """Re-creating a remote session with the same session_id yields the same player."""
328 sendspin = mass.get_provider("sendspin")
329 assert sendspin is not None
330
331 session = await SharedPlaybackSession.create_remote(
332 mass,
333 owner_instance_id=sendspin.instance_id,
334 display_name="Test Party",
335 session_id="my_party",
336 )
337 player_id = session.player_id
338 await session.close()
339
340 session = await SharedPlaybackSession.create_remote(
341 mass,
342 owner_instance_id=sendspin.instance_id,
343 display_name="Test Party",
344 session_id="my_party",
345 )
346 assert session.player_id == player_id
347 await session.close()
348
349
350async def test_create_remote_session_no_sendspin() -> None:
351 """Creating a remote session without the Sendspin provider raises."""
352 mass = MagicMock()
353 mass.get_provider.return_value = None
354
355 with pytest.raises(SetupFailedError):
356 await SharedPlaybackSession.create_remote(
357 mass, owner_instance_id="some_plugin", display_name="Test Party"
358 )
359
360
361async def test_cancelled_remote_creation_cleans_up_virtual_player() -> None:
362 """Cancel promptly and remove the virtual player after creation finishes."""
363 sendspin = MagicMock()
364 mass, tasks = _create_mock_remote_mass(sendspin)
365 creation_started = asyncio.Event()
366 allow_creation = asyncio.Event()
367 cleanup_finished = asyncio.Event()
368
369 async def _create_virtual_player(**_kwargs: object) -> str:
370 creation_started.set()
371 await allow_creation.wait()
372 return "virtual-player"
373
374 async def _remove_virtual_player(_player_id: str, **_kwargs: object) -> None:
375 cleanup_finished.set()
376
377 sendspin.create_virtual_player = AsyncMock(side_effect=_create_virtual_player)
378 sendspin.is_virtual_player.return_value = True
379 sendspin.remove_virtual_player = AsyncMock(side_effect=_remove_virtual_player)
380
381 session_task = asyncio.create_task(
382 SharedPlaybackSession.create_remote(
383 mass,
384 owner_instance_id="plugin--test",
385 display_name="Test Party",
386 session_id="test",
387 )
388 )
389 await creation_started.wait()
390 session_task.cancel()
391
392 with pytest.raises(asyncio.CancelledError):
393 await session_task
394 assert not cleanup_finished.is_set()
395
396 allow_creation.set()
397 await cleanup_finished.wait()
398 await asyncio.gather(*tasks)
399
400 sendspin.remove_virtual_player.assert_awaited_once_with("virtual-player")
401 assert all(task.done() for task in tasks)
402
403
404async def test_cancelled_remote_creation_failure_is_observed() -> None:
405 """Observe a creation error after the caller has already been cancelled."""
406 sendspin = MagicMock()
407 mass, tasks = _create_mock_remote_mass(sendspin)
408 creation_started = asyncio.Event()
409 allow_creation = asyncio.Event()
410
411 async def _create_virtual_player(**_kwargs: object) -> str:
412 creation_started.set()
413 await allow_creation.wait()
414 raise RuntimeError("creation failed")
415
416 sendspin.create_virtual_player = AsyncMock(side_effect=_create_virtual_player)
417 sendspin.remove_virtual_player = AsyncMock()
418
419 with patch("music_assistant.helpers.shared_playback.LOGGER") as logger:
420 session_task = asyncio.create_task(
421 SharedPlaybackSession.create_remote(
422 mass,
423 owner_instance_id="plugin--test",
424 display_name="Test Party",
425 )
426 )
427 await creation_started.wait()
428 session_task.cancel()
429 with pytest.raises(asyncio.CancelledError):
430 await session_task
431
432 allow_creation.set()
433 await tasks[1]
434
435 logger.debug.assert_called_once()
436 sendspin.remove_virtual_player.assert_not_awaited()
437 assert all(task.done() for task in tasks)
438
439
440async def test_cancelled_remote_creation_failure_logs_no_loop_error() -> None:
441 """A creation failure observed after the caller was cancelled is not reported to the loop."""
442 sendspin = MagicMock()
443 mass, tasks = _create_mock_remote_mass(sendspin)
444 creation_started = asyncio.Event()
445 allow_creation = asyncio.Event()
446
447 async def _create_virtual_player(**_kwargs: object) -> str:
448 creation_started.set()
449 await allow_creation.wait()
450 raise RuntimeError("creation failed")
451
452 sendspin.create_virtual_player = AsyncMock(side_effect=_create_virtual_player)
453 sendspin.remove_virtual_player = AsyncMock()
454
455 with (
456 collect_loop_errors() as reported,
457 patch("music_assistant.helpers.shared_playback.LOGGER") as logger,
458 ):
459 session_task = asyncio.create_task(
460 SharedPlaybackSession.create_remote(
461 mass,
462 owner_instance_id="plugin--test",
463 display_name="Test Party",
464 )
465 )
466 await creation_started.wait()
467 session_task.cancel()
468 # the cancellation must be fully processed before the gate is released, so the
469 # creation failure reliably lands after the caller has already given up -- releasing
470 # the gate first would let the failure race the cancellation and pass even on the
471 # buggy shield-based code
472 with pytest.raises(asyncio.CancelledError):
473 await session_task
474
475 allow_creation.set()
476 await tasks[1]
477
478 logger.debug.assert_called_once() # the cleanup task still observes and logs the failure
479 sendspin.remove_virtual_player.assert_not_awaited()
480 assert all(task.done() for task in tasks)
481 assert reported == []
482
483
484async def test_cancelled_remote_creation_timeout_cancels_task() -> None:
485 """Bound cleanup when virtual-player creation does not finish."""
486 sendspin = MagicMock()
487 mass, tasks = _create_mock_remote_mass(sendspin)
488 creation_started = asyncio.Event()
489 creation_cancelled = asyncio.Event()
490 never_finish = asyncio.Event()
491
492 async def _create_virtual_player(**_kwargs: object) -> str:
493 creation_started.set()
494 try:
495 await never_finish.wait()
496 finally:
497 creation_cancelled.set()
498 return "virtual-player"
499
500 sendspin.create_virtual_player = AsyncMock(side_effect=_create_virtual_player)
501
502 with patch(
503 "music_assistant.helpers.shared_playback.REMOTE_CREATION_CLEANUP_TIMEOUT",
504 0.01,
505 ):
506 session_task = asyncio.create_task(
507 SharedPlaybackSession.create_remote(
508 mass,
509 owner_instance_id="plugin--test",
510 display_name="Test Party",
511 )
512 )
513 await creation_started.wait()
514 session_task.cancel()
515 with pytest.raises(asyncio.CancelledError):
516 await session_task
517
518 await creation_cancelled.wait()
519 await tasks[1]
520
521 assert tasks[0].cancelled()
522 assert all(task.done() for task in tasks)
523
524
525async def test_cancelled_remote_creation_cleans_up_late_success() -> None:
526 """Remove a virtual player returned after bounded creation cleanup ends."""
527 sendspin = MagicMock()
528 mass, tasks = _create_mock_remote_mass(sendspin)
529 creation_started = asyncio.Event()
530 cancellation_received = asyncio.Event()
531 allow_late_success = asyncio.Event()
532 cleanup_finished = asyncio.Event()
533 never_finish = asyncio.Event()
534
535 async def _create_virtual_player(**_kwargs: object) -> str:
536 creation_started.set()
537 try:
538 await never_finish.wait()
539 except asyncio.CancelledError:
540 cancellation_received.set()
541 await allow_late_success.wait()
542 return "virtual-player"
543
544 async def _remove_virtual_player(_player_id: str, **_kwargs: object) -> None:
545 cleanup_finished.set()
546
547 sendspin.create_virtual_player = AsyncMock(side_effect=_create_virtual_player)
548 sendspin.is_virtual_player.return_value = True
549 sendspin.remove_virtual_player = AsyncMock(side_effect=_remove_virtual_player)
550
551 with patch(
552 "music_assistant.helpers.shared_playback.REMOTE_CREATION_CLEANUP_TIMEOUT",
553 0.01,
554 ):
555 session_task = asyncio.create_task(
556 SharedPlaybackSession.create_remote(
557 mass,
558 owner_instance_id="plugin--test",
559 display_name="Test Party",
560 )
561 )
562 await creation_started.wait()
563 session_task.cancel()
564 with pytest.raises(asyncio.CancelledError):
565 await session_task
566
567 await cancellation_received.wait()
568 await tasks[1]
569 assert not cleanup_finished.is_set()
570 allow_late_success.set()
571 await cleanup_finished.wait()
572 await asyncio.gather(*tasks)
573
574 sendspin.remove_virtual_player.assert_awaited_once_with("virtual-player")
575 assert all(task.done() for task in tasks)
576
577
578async def test_cancelled_remote_creation_retries_cleanup() -> None:
579 """Retry cleanup when removing the created virtual player fails temporarily."""
580 sendspin = MagicMock()
581 mass = MagicMock()
582 sendspin.is_virtual_player.return_value = True
583 sendspin.remove_virtual_player = AsyncMock(
584 side_effect=[RuntimeError("temporary failure"), None]
585 )
586 cleanup_required = asyncio.get_running_loop().create_future()
587 cleanup_required.set_result(True)
588
589 async def _created_player() -> str:
590 return "virtual-player"
591
592 creation_task = asyncio.create_task(_created_player())
593 with patch(
594 "music_assistant.helpers.shared_playback.REMOTE_REMOVAL_CLEANUP_DELAYS",
595 (0.0, 0.0),
596 ):
597 await SharedPlaybackSession._cleanup_cancelled_remote_creation(
598 mass,
599 sendspin,
600 creation_task,
601 cleanup_required,
602 )
603
604 assert sendspin.remove_virtual_player.await_count == 2
605
606
607async def test_cancelled_remote_creation_awaits_a_slow_removal() -> None:
608 """Await a slow virtual-player removal to completion instead of abandoning it."""
609 sendspin = MagicMock()
610 mass = MagicMock()
611 removal_completed = asyncio.Event()
612
613 async def _remove_virtual_player(_player_id: str, **_kwargs: object) -> None:
614 await asyncio.sleep(0.1)
615 removal_completed.set()
616
617 sendspin.is_virtual_player.return_value = True
618 sendspin.remove_virtual_player = AsyncMock(side_effect=_remove_virtual_player)
619 cleanup_required = asyncio.get_running_loop().create_future()
620 cleanup_required.set_result(True)
621
622 async def _created_player() -> str:
623 return "virtual-player"
624
625 creation_task = asyncio.create_task(_created_player())
626 with (
627 patch(
628 "music_assistant.helpers.shared_playback.REMOTE_REMOVAL_CLEANUP_DELAYS",
629 (0.0,),
630 ),
631 # the bound this path used to carry, patched back in so the removal is
632 # cut short - and this test fails - if it ever returns
633 patch(
634 "music_assistant.helpers.shared_playback.REMOTE_REMOVAL_CLEANUP_TIMEOUT",
635 0.01,
636 create=True,
637 ),
638 patch("music_assistant.helpers.shared_playback.LOGGER") as logger,
639 ):
640 await SharedPlaybackSession._cleanup_cancelled_remote_creation(
641 mass,
642 sendspin,
643 creation_task,
644 cleanup_required,
645 )
646
647 assert removal_completed.is_set()
648 assert sendspin.remove_virtual_player.await_count == 1
649 logger.warning.assert_not_called()
650
651
652async def test_remote_close_is_idempotent(mass: MusicAssistant) -> None:
653 """Closing a remote session twice does not raise."""
654 sendspin = mass.get_provider("sendspin")
655 assert sendspin is not None
656
657 session = await SharedPlaybackSession.create_remote(
658 mass, owner_instance_id=sendspin.instance_id, display_name="Test Party"
659 )
660 await session.close()
661 await session.close()
662