music-assistant-server

24.1 KBPY
test_shared_playback.py
24.1 KB662 lines • python
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