/
/
1"""Tests for the Spotify Connect provider."""
2
3import json
4from collections.abc import AsyncGenerator
5from pathlib import Path
6from typing import Any
7from unittest.mock import AsyncMock, MagicMock, patch
8
9from music_assistant_models.config_entries import ConfigEntry, ProviderConfig
10from music_assistant_models.enums import ConfigEntryType, ProviderType
11
12from music_assistant.constants import CONF_CROSSFADE_DURATION
13from music_assistant.providers.spotify_connect import (
14 BACKEND_SOLOIST,
15 CONF_API_KEY,
16 CONF_BACKEND,
17 CONF_SOLOIST_CONSENT,
18 CONF_VOLUME_MODE,
19 SpotifyConnectProvider,
20)
21from music_assistant.providers.spotify_connect.go_librespot.backend import (
22 API_PORT_RANGE_END,
23 API_PORT_RANGE_START,
24 GoLibrespotBackend,
25)
26from music_assistant.providers.spotify_connect.go_librespot.client import GoLibrespotClient
27from music_assistant.providers.spotify_connect.provider import CONF_LOUDNESS_NORMALIZATION
28from music_assistant.providers.spotify_connect.soloist.backend import (
29 VOLUME_MODE_SYNC_SPOTIFY,
30 SoloistBackend,
31)
32
33
34async def test_backend_start_probes_api_port_on_ipv4_loopback() -> None:
35 """The daemon API port is selected on the address go-librespot binds."""
36 backend = object.__new__(GoLibrespotBackend)
37 backend.mass = MagicMock()
38 backend.logger = MagicMock()
39 backend.mass.create_task.side_effect = lambda coroutine: coroutine.close()
40
41 with (
42 patch(
43 "music_assistant.providers.spotify_connect.go_librespot.backend"
44 ".get_go_librespot_binary",
45 return_value="/usr/bin/go-librespot",
46 ),
47 patch(
48 "music_assistant.providers.spotify_connect.go_librespot.backend.select_free_port",
49 new=AsyncMock(return_value=38801),
50 ) as select_port,
51 ):
52 await backend.start()
53
54 select_port.assert_awaited_once_with(API_PORT_RANGE_START, API_PORT_RANGE_END, host="127.0.0.1")
55 assert backend._client is not None
56 assert backend._client.base_url == "http://127.0.0.1:38801"
57
58
59async def test_daemon_runner_reselects_api_port_when_taken(tmp_path: Path) -> None:
60 """An API port taken while the daemon was down is replaced before (re)starting."""
61 backend = object.__new__(GoLibrespotBackend)
62 backend.mass = MagicMock()
63 backend.mass.streams.get_source_ip = AsyncMock(return_value="192.168.1.5")
64 backend.logger = MagicMock()
65 backend.name = "Spotify Test"
66 backend.cache_dir = str(tmp_path)
67 backend._binary = "/usr/bin/go-librespot"
68 backend._api_port = 38800
69 backend._client = GoLibrespotClient(backend.mass, "http://127.0.0.1:38800", backend.logger)
70 backend._event_callback = AsyncMock()
71 # exit the supervisor loop after a single iteration
72 backend._stop_called = True
73 backend._restart_error_count = 0
74
75 async def _no_stderr() -> AsyncGenerator[str]:
76 return
77 yield
78
79 proc = MagicMock()
80 proc.start = AsyncMock()
81 proc.close = AsyncMock()
82 proc.iter_stderr = _no_stderr
83
84 with (
85 patch(
86 "music_assistant.providers.spotify_connect.go_librespot.backend.is_port_in_use",
87 new=AsyncMock(return_value=True),
88 ) as port_probe,
89 patch(
90 "music_assistant.providers.spotify_connect.go_librespot.backend.select_free_port",
91 new=AsyncMock(return_value=38801),
92 ),
93 patch(
94 "music_assistant.providers.spotify_connect.go_librespot.backend.AsyncProcess",
95 return_value=proc,
96 ),
97 patch.object(GoLibrespotBackend, "_write_config") as write_config,
98 ):
99 await backend._daemon_runner()
100
101 port_probe.assert_awaited_once_with(38800, host="127.0.0.1")
102 assert backend._api_port == 38801
103 assert backend._client.base_url == "http://127.0.0.1:38801"
104 # the daemon config pins the advertisement to the player-facing interface
105 write_config.assert_called_once_with("192.168.1.5")
106
107
108def _volume_sync_provider(volume_level: int | None) -> tuple[SpotifyConnectProvider, AsyncMock]:
109 """Build a minimal provider whose linked player reports the given volume."""
110 provider = object.__new__(SpotifyConnectProvider)
111 provider.mass = MagicMock()
112 provider.logger = MagicMock()
113 provider._last_volume_sent = None
114 backend = MagicMock()
115 set_volume = AsyncMock()
116 backend.set_volume = set_volume
117 provider._backend = backend
118 player = MagicMock()
119 player.state.volume_level = volume_level
120 provider.mass.players.get_player.return_value = player
121 return provider, set_volume
122
123
124async def test_sync_player_volume_pushes_player_volume_to_backend() -> None:
125 """The player's volume is pushed to the backend and cached for echo dedupe."""
126 provider, set_volume = _volume_sync_provider(50)
127
128 await provider._sync_player_volume_to_spotify("player1")
129
130 set_volume.assert_awaited_once_with(50)
131 assert provider._last_volume_sent == 50
132
133
134async def test_sync_player_volume_pushes_when_cache_matches() -> None:
135 """The push is unconditional: the backend's volume resets between sessions."""
136 provider, set_volume = _volume_sync_provider(50)
137 provider._last_volume_sent = 50
138
139 await provider._sync_player_volume_to_spotify("player1")
140
141 set_volume.assert_awaited_once_with(50)
142
143
144async def test_sync_player_volume_skips_when_volume_unknown() -> None:
145 """No push happens when the player does not expose a volume level."""
146 provider, set_volume = _volume_sync_provider(None)
147
148 await provider._sync_player_volume_to_spotify("player1")
149
150 set_volume.assert_not_awaited()
151 assert provider._last_volume_sent is None
152
153
154async def test_sync_player_volume_restores_cache_on_failure() -> None:
155 """A failed push restores the dedupe cache so a retry is not wrongly deduped."""
156 provider, set_volume = _volume_sync_provider(50)
157 set_volume.side_effect = OSError("daemon gone")
158
159 await provider._sync_player_volume_to_spotify("player1")
160
161 assert provider._last_volume_sent is None
162
163
164def _provider_with_stored_config(
165 setup_data: dict[str, Any], tmp_path: Path
166) -> SpotifyConnectProvider:
167 """Build a provider whose stored setup_data resolves through the real accessors."""
168 provider = object.__new__(SpotifyConnectProvider)
169 provider.mass = MagicMock()
170 provider.mass.storage_path = str(tmp_path / "storage")
171 provider.mass.cache_path = str(tmp_path / "cache")
172 provider.mass.config.get.return_value = setup_data
173 provider.mass.config.decrypt_string.side_effect = lambda value: value
174 provider.logger = MagicMock()
175 provider._publish_name = "Test Speaker"
176 provider.config = ProviderConfig(
177 values={},
178 type=ProviderType.PLUGIN,
179 domain="spotify_connect",
180 instance_id="spotify_connect--test",
181 name="Spotify Connect",
182 )
183 return provider
184
185
186def test_config_without_backend_choice_loads_go_librespot(tmp_path: Path) -> None:
187 """A config from before the backend choice existed loads go-librespot unchanged."""
188 provider = _provider_with_stored_config({}, tmp_path)
189
190 assert isinstance(provider._create_backend(), GoLibrespotBackend)
191
192
193def test_soloist_setup_data_loads_soloist_backend(tmp_path: Path) -> None:
194 """A flow-configured soloist instance loads the soloist backend with its stored values."""
195 provider = _provider_with_stored_config(
196 {
197 CONF_BACKEND: BACKEND_SOLOIST,
198 CONF_API_KEY: "soloist-api-key-0123456789abcdef",
199 CONF_SOLOIST_CONSENT: True,
200 },
201 tmp_path,
202 )
203 # the volume mode lives in the provider options, not in the setup data
204 provider.config.values[CONF_VOLUME_MODE] = ConfigEntry(
205 key=CONF_VOLUME_MODE,
206 type=ConfigEntryType.STRING,
207 value=VOLUME_MODE_SYNC_SPOTIFY,
208 )
209
210 provider.config.values[CONF_CROSSFADE_DURATION] = ConfigEntry(
211 key=CONF_CROSSFADE_DURATION,
212 type=ConfigEntryType.INTEGER,
213 value=8,
214 )
215 provider.config.values[CONF_LOUDNESS_NORMALIZATION] = ConfigEntry(
216 key=CONF_LOUDNESS_NORMALIZATION,
217 type=ConfigEntryType.BOOLEAN,
218 value=False,
219 )
220
221 backend = provider._create_backend()
222
223 assert isinstance(backend, SoloistBackend)
224 assert backend._api_key == "soloist-api-key-0123456789abcdef"
225 assert backend._consent is True
226 assert backend._volume_mode == VOLUME_MODE_SYNC_SPOTIFY
227 assert backend._crossfade_ms == 8000
228 assert backend._loudness_normalization is False
229
230
231def test_audio_behavior_defaults_reach_the_backend(tmp_path: Path) -> None:
232 """Without stored values, crossfade is off and normalization enabled."""
233 provider = _provider_with_stored_config({}, tmp_path)
234
235 backend = provider._create_backend()
236
237 assert isinstance(backend, GoLibrespotBackend)
238 assert backend._crossfade_ms == 0
239 assert backend._loudness_normalization is True
240
241
242def test_audio_behavior_values_reach_the_backend(tmp_path: Path) -> None:
243 """The configured crossfade seconds (as ms) and normalization reach the backend."""
244 provider = _provider_with_stored_config({}, tmp_path)
245 provider.config.values[CONF_CROSSFADE_DURATION] = ConfigEntry(
246 key=CONF_CROSSFADE_DURATION,
247 type=ConfigEntryType.INTEGER,
248 value=8,
249 )
250 provider.config.values[CONF_LOUDNESS_NORMALIZATION] = ConfigEntry(
251 key=CONF_LOUDNESS_NORMALIZATION,
252 type=ConfigEntryType.BOOLEAN,
253 value=False,
254 )
255
256 backend = provider._create_backend()
257
258 assert isinstance(backend, GoLibrespotBackend)
259 assert backend._crossfade_ms == 8000
260 assert backend._loudness_normalization is False
261
262
263def test_write_config_carries_the_audio_behavior_keys(tmp_path: Path) -> None:
264 """The generated config.yml carries crossfade_duration (ms) and normalisation_disabled."""
265 backend = object.__new__(GoLibrespotBackend)
266 backend.mass = MagicMock()
267 backend.logger = MagicMock()
268 backend._publish_name = "Test Speaker"
269 backend._instance_id = "spotify_connect--test"
270 backend._api_port = 38800
271 backend.cache_dir = str(tmp_path)
272 backend._crossfade_ms = 8000
273 backend._loudness_normalization = False
274
275 backend._write_config(None)
276
277 config = json.loads((tmp_path / "config.yml").read_text(encoding="utf-8"))
278 assert config["crossfade_duration"] == 8000
279 assert config["normalisation_disabled"] is True
280