/
/
/
1"""
2Tests for bridge players being told about an explicit stop (support#6195).
3
4A bridge player buffers seconds of audio on its downstream protocol and keeps
5that transport warm across stream ends, so seeks and track changes can reuse
6it. At the Sendspin server level a user stop is indistinguishable from those:
7the truthful signal lives in the player commands, so SendspinPlayer.stop() and
8set_members() notify the bridge roles, which is what lets a bridge silence its
9device at once instead of playing out its buffer.
10"""
11
12from __future__ import annotations
13
14from unittest.mock import AsyncMock, MagicMock
15
16import pytest
17
18from music_assistant.providers.sendspin.bridge_role import BridgePlayerRole
19from music_assistant.providers.sendspin.player import SendspinPlayer
20
21
22def _player_mock() -> MagicMock:
23 """Create a mock to bind the real methods under test to."""
24 mock = MagicMock()
25 mock.playback_session.cancel = AsyncMock()
26 mock.api.group.stop = AsyncMock()
27 mock.api.group.remove_client = AsyncMock()
28 return mock
29
30
31def _bridge_role(on_explicit_stop: MagicMock | None) -> BridgePlayerRole:
32 """Build a bridge role with only the explicit-stop callback of interest."""
33 role = BridgePlayerRole(client=MagicMock())
34 role.set_callbacks(
35 on_audio_chunk=MagicMock(),
36 on_volume_change=MagicMock(),
37 on_mute_change=MagicMock(),
38 on_stream_start=MagicMock(),
39 on_stream_end=MagicMock(),
40 on_explicit_stop=on_explicit_stop,
41 )
42 return role
43
44
45async def test_stop_notifies_bridges_between_group_stop_and_session_cancel() -> None:
46 """
47 The notify lands after the stream ended and before the session teardown.
48
49 Before group.stop() the bridge would still see itself streaming and ignore
50 the signal; after the session cancel it would have spent that long playing
51 out its buffer.
52 """
53 mock = _player_mock()
54 order: list[str] = []
55 mock.api.group.stop.side_effect = lambda: order.append("group_stop")
56 mock._notify_bridges_explicit_stop = MagicMock(side_effect=lambda _: order.append("notify"))
57 mock.playback_session.cancel.side_effect = lambda _reason: order.append("cancel")
58
59 await SendspinPlayer.stop(mock)
60
61 # the trailing notify only exists for the failed-stop path; here it is a no-op
62 assert order == ["group_stop", "notify", "cancel", "notify"]
63 mock._notify_bridges_explicit_stop.assert_called_with(mock.api.group.clients)
64
65
66async def test_stop_notifies_bridges_even_when_the_group_stop_fails() -> None:
67 """
68 A failing group stop must not leave a bridge playing out its buffer.
69
70 A group stop that raises before ending the stream leaves the bridges still
71 streaming, so the notify preceding the cancel does not reach them. The
72 session cancel is what ends the stream on that path, and the notify that
73 matters is the one delivered after it.
74 """
75 mock = _player_mock()
76 order: list[str] = []
77 mock.api.group.stop = AsyncMock(side_effect=RuntimeError("transport gone"))
78 mock._notify_bridges_explicit_stop = MagicMock(side_effect=lambda _: order.append("notify"))
79 mock.playback_session.cancel.side_effect = lambda _reason: order.append("cancel")
80
81 with pytest.raises(RuntimeError):
82 await SendspinPlayer.stop(mock)
83
84 assert order == ["notify", "cancel", "notify"]
85
86
87async def test_removing_a_member_notifies_its_bridge_after_the_removal() -> None:
88 """
89 An ungrouped member is told to stop once its stream has been ended.
90
91 The removal is what fires the stream end for the member's roles; notifying
92 before it would find the bridge still streaming and be ignored.
93 """
94 mock = _player_mock()
95 member = MagicMock()
96 mock.mass.players.get_player.return_value = member
97 order: list[str] = []
98 mock.api.group.remove_client.side_effect = lambda _client: order.append("remove")
99 mock._notify_bridges_explicit_stop = MagicMock(side_effect=lambda _: order.append("notify"))
100
101 await SendspinPlayer.set_members(mock, player_ids_to_remove=["member1"])
102
103 assert order == ["remove", "notify"]
104 mock._notify_bridges_explicit_stop.assert_called_once_with([member.api])
105
106
107def test_notify_reaches_only_bridge_roles() -> None:
108 """Native player roles have their own stream/end handling and are left alone."""
109 mock = _player_mock()
110 on_explicit_stop = MagicMock()
111 native_role = MagicMock()
112 client = MagicMock()
113 client.roles_by_family.return_value = [native_role, _bridge_role(on_explicit_stop)]
114
115 SendspinPlayer._notify_bridges_explicit_stop(mock, [client])
116
117 on_explicit_stop.assert_called_once_with()
118 native_role.notify_explicit_stop.assert_not_called()
119
120
121def test_a_failing_bridge_does_not_keep_the_stop_from_the_others() -> None:
122 """One bridge raising must not leave the next member playing out its buffer."""
123 mock = _player_mock()
124 failing_client = MagicMock()
125 failing_client.roles_by_family.return_value = [
126 _bridge_role(MagicMock(side_effect=RuntimeError("bridge broke")))
127 ]
128 on_explicit_stop = MagicMock()
129 healthy_client = MagicMock()
130 healthy_client.roles_by_family.return_value = [_bridge_role(on_explicit_stop)]
131
132 SendspinPlayer._notify_bridges_explicit_stop(mock, [failing_client, healthy_client])
133
134 on_explicit_stop.assert_called_once_with()
135 mock.logger.exception.assert_called_once()
136
137
138def test_a_role_without_the_callback_ignores_the_notify() -> None:
139 """A bridge that did not wire the callback (the Cast bridge) is unaffected."""
140 role = _bridge_role(None)
141
142 role.notify_explicit_stop()
143