/
/
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.base import (
22 AUDIO_QUALITY_HIGH,
23 AUDIO_QUALITY_LOSSLESS,
24)
25from music_assistant.providers.spotify_connect.go_librespot.backend import (
26 API_PORT_RANGE_END,
27 API_PORT_RANGE_START,
28 GoLibrespotBackend,
29)
30from music_assistant.providers.spotify_connect.go_librespot.client import GoLibrespotClient
31from music_assistant.providers.spotify_connect.models import BackendEvent, BackendEventType
32from music_assistant.providers.spotify_connect.provider import (
33 AUDIO_SOURCE_ID,
34 CONF_AUDIO_QUALITY,
35 CONF_LOUDNESS_NORMALIZATION,
36)
37from music_assistant.providers.spotify_connect.soloist.backend import (
38 VOLUME_MODE_SYNC_SPOTIFY,
39 SoloistBackend,
40)
41
42
43async def test_backend_start_probes_api_port_on_ipv4_loopback() -> None:
44 """The daemon API port is selected on the address go-librespot binds."""
45 backend = object.__new__(GoLibrespotBackend)
46 backend.mass = MagicMock()
47 backend.logger = MagicMock()
48 backend.mass.create_task.side_effect = lambda coroutine: coroutine.close()
49
50 with (
51 patch(
52 "music_assistant.providers.spotify_connect.go_librespot.backend"
53 ".get_go_librespot_binary",
54 return_value="/usr/bin/go-librespot",
55 ),
56 patch(
57 "music_assistant.providers.spotify_connect.go_librespot.backend.select_free_port",
58 new=AsyncMock(return_value=38801),
59 ) as select_port,
60 ):
61 await backend.start()
62
63 select_port.assert_awaited_once_with(API_PORT_RANGE_START, API_PORT_RANGE_END, host="127.0.0.1")
64 assert backend._client is not None
65 assert backend._client.base_url == "http://127.0.0.1:38801"
66
67
68async def test_daemon_runner_reselects_api_port_when_taken(tmp_path: Path) -> None:
69 """An API port taken while the daemon was down is replaced before (re)starting."""
70 backend = object.__new__(GoLibrespotBackend)
71 backend.mass = MagicMock()
72 backend.mass.streams.get_source_ip = AsyncMock(return_value="192.168.1.5")
73 backend.logger = MagicMock()
74 backend.name = "Spotify Test"
75 backend.cache_dir = str(tmp_path)
76 backend._binary = "/usr/bin/go-librespot"
77 backend._api_port = 38800
78 backend._client = GoLibrespotClient(backend.mass, "http://127.0.0.1:38800", backend.logger)
79 backend._event_callback = AsyncMock()
80 # exit the supervisor loop after a single iteration
81 backend._stop_called = True
82 backend._restart_error_count = 0
83
84 async def _no_stderr() -> AsyncGenerator[str]:
85 return
86 yield
87
88 proc = MagicMock()
89 proc.start = AsyncMock()
90 proc.close = AsyncMock()
91 proc.iter_stderr = _no_stderr
92
93 with (
94 patch(
95 "music_assistant.providers.spotify_connect.go_librespot.backend.is_port_in_use",
96 new=AsyncMock(return_value=True),
97 ) as port_probe,
98 patch(
99 "music_assistant.providers.spotify_connect.go_librespot.backend.select_free_port",
100 new=AsyncMock(return_value=38801),
101 ),
102 patch(
103 "music_assistant.providers.spotify_connect.go_librespot.backend.AsyncProcess",
104 return_value=proc,
105 ),
106 patch.object(GoLibrespotBackend, "_write_config") as write_config,
107 ):
108 await backend._daemon_runner()
109
110 port_probe.assert_awaited_once_with(38800, host="127.0.0.1")
111 assert backend._api_port == 38801
112 assert backend._client.base_url == "http://127.0.0.1:38801"
113 # the daemon config pins the advertisement to the player-facing interface
114 write_config.assert_called_once_with("192.168.1.5")
115
116
117def _volume_sync_provider(volume_level: int | None) -> tuple[SpotifyConnectProvider, AsyncMock]:
118 """Build a minimal provider whose linked player reports the given volume."""
119 provider = object.__new__(SpotifyConnectProvider)
120 provider.mass = MagicMock()
121 provider.logger = MagicMock()
122 provider._last_volume_sent = None
123 backend = MagicMock()
124 set_volume = AsyncMock()
125 backend.set_volume = set_volume
126 provider._backend = backend
127 player = MagicMock()
128 player.state.volume_level = volume_level
129 provider.mass.players.get_player.return_value = player
130 return provider, set_volume
131
132
133async def test_sync_player_volume_pushes_player_volume_to_backend() -> None:
134 """The player's volume is pushed to the backend and cached for echo dedupe."""
135 provider, set_volume = _volume_sync_provider(50)
136
137 await provider._sync_player_volume_to_spotify("player1")
138
139 set_volume.assert_awaited_once_with(50)
140 assert provider._last_volume_sent == 50
141
142
143async def test_sync_player_volume_pushes_when_cache_matches() -> None:
144 """The push is unconditional: the backend's volume resets between sessions."""
145 provider, set_volume = _volume_sync_provider(50)
146 provider._last_volume_sent = 50
147
148 await provider._sync_player_volume_to_spotify("player1")
149
150 set_volume.assert_awaited_once_with(50)
151
152
153async def test_sync_player_volume_skips_when_volume_unknown() -> None:
154 """No push happens when the player does not expose a volume level."""
155 provider, set_volume = _volume_sync_provider(None)
156
157 await provider._sync_player_volume_to_spotify("player1")
158
159 set_volume.assert_not_awaited()
160 assert provider._last_volume_sent is None
161
162
163async def test_sync_player_volume_restores_cache_on_failure() -> None:
164 """A failed push restores the dedupe cache so a retry is not wrongly deduped."""
165 provider, set_volume = _volume_sync_provider(50)
166 set_volume.side_effect = OSError("daemon gone")
167
168 await provider._sync_player_volume_to_spotify("player1")
169
170 assert provider._last_volume_sent is None
171
172
173def _tethered_provider() -> tuple[SpotifyConnectProvider, AsyncMock]:
174 """Build a provider tethered to queue 'player1' with an active (paused) Spotify session."""
175 provider = object.__new__(SpotifyConnectProvider)
176 provider.mass = MagicMock()
177 provider.logger = MagicMock()
178 backend = MagicMock()
179 deactivate = AsyncMock()
180 backend.deactivate = deactivate
181 provider._backend = backend
182 provider._active_player_id = "player1"
183 provider._in_use_by_player = None
184 provider._active_session_id = None
185 provider._spotify_session_active = True
186 provider._playing = False
187 provider._pending_pause_stop_task = None
188 provider._pending_play_media_task = None
189 return provider, deactivate
190
191
192async def test_releasing_a_player_releases_a_paused_spotify_session() -> None:
193 """Letting the player go releases the session the paused stream's teardown left behind."""
194 provider, deactivate = _tethered_provider()
195
196 await provider.on_source_released(AUDIO_SOURCE_ID, "player1")
197
198 deactivate.assert_awaited_once()
199
200
201async def test_release_while_the_stream_is_winding_down_still_releases() -> None:
202 """
203 A release landing before the paused stream finished tearing down still releases.
204
205 The teardown itself releases nothing for a paused source, so waiting for it to hand the
206 claim back would leave the Spotify app tethered for good.
207 """
208 provider, deactivate = _tethered_provider()
209 provider._in_use_by_player = "player1"
210
211 await provider.on_source_released(AUDIO_SOURCE_ID, "player1")
212
213 deactivate.assert_awaited_once()
214
215
216async def test_clearing_another_queue_leaves_the_session_alone() -> None:
217 """Only the queue the source is tethered to may release it."""
218 provider, deactivate = _tethered_provider()
219
220 await provider.on_source_released(AUDIO_SOURCE_ID, "player2")
221
222 deactivate.assert_not_awaited()
223
224
225async def test_queue_clear_without_an_active_session_does_nothing() -> None:
226 """There is nothing to release when MA is not the active Spotify device."""
227 provider, deactivate = _tethered_provider()
228 provider._spotify_session_active = False
229
230 await provider.on_source_released(AUDIO_SOURCE_ID, "player1")
231
232 deactivate.assert_not_awaited()
233
234
235async def _session_inactive(provider: SpotifyConnectProvider) -> list[str]:
236 """Run the backend's 'session inactive' answer and return the players it wanted stopped."""
237 stopped: list[str] = []
238 provider._schedule_pause_stop = lambda player_id: stopped.append(player_id) # type: ignore[method-assign]
239 with patch.object(SpotifyConnectProvider, "name", "Spotify Test"):
240 await provider._handle_backend_event(BackendEvent(type=BackendEventType.SESSION_INACTIVE))
241 return stopped
242
243
244async def test_releasing_the_session_leaves_the_new_playback_alone() -> None:
245 """
246 Releasing must not stop the player that took the source's place.
247
248 The backend answers a release with the same "session inactive" it sends when the user picks
249 another device in the Spotify app - and that one does stop the player. By then this player is
250 playing whatever replaced the source, so stopping it would cut the music the user just started.
251 """
252 provider, _ = _tethered_provider()
253
254 with patch.object(SpotifyConnectProvider, "name", "Spotify Test"):
255 await provider.on_source_released(AUDIO_SOURCE_ID, "player1")
256
257 assert await _session_inactive(provider) == []
258
259
260async def test_a_spotify_side_deselect_still_stops_the_player() -> None:
261 """Picking another device in the Spotify app does stop what MA was playing from it."""
262 provider, _ = _tethered_provider()
263
264 assert await _session_inactive(provider) == ["player1"]
265
266
267async def test_queue_clear_survives_a_failing_release() -> None:
268 """A backend that cannot be reached must not break clearing the queue."""
269 provider, deactivate = _tethered_provider()
270 deactivate.side_effect = OSError("daemon gone")
271
272 await provider.on_source_released(AUDIO_SOURCE_ID, "player1")
273
274 deactivate.assert_awaited_once()
275
276
277def _provider_with_stored_config(
278 setup_data: dict[str, Any], tmp_path: Path
279) -> SpotifyConnectProvider:
280 """Build a provider whose stored setup_data resolves through the real accessors."""
281 provider = object.__new__(SpotifyConnectProvider)
282 provider.mass = MagicMock()
283 provider.mass.storage_path = str(tmp_path / "storage")
284 provider.mass.cache_path = str(tmp_path / "cache")
285 provider.mass.config.get.return_value = setup_data
286 provider.mass.config.decrypt_string.side_effect = lambda value: value
287 provider.logger = MagicMock()
288 provider._publish_name = "Test Speaker"
289 provider.config = ProviderConfig(
290 values={},
291 type=ProviderType.PLUGIN,
292 domain="spotify_connect",
293 instance_id="spotify_connect--test",
294 name="Spotify Connect",
295 )
296 return provider
297
298
299def test_config_without_backend_choice_loads_go_librespot(tmp_path: Path) -> None:
300 """A config from before the backend choice existed loads go-librespot unchanged."""
301 provider = _provider_with_stored_config({}, tmp_path)
302
303 assert isinstance(provider._create_backend(), GoLibrespotBackend)
304
305
306def test_soloist_setup_data_loads_soloist_backend(tmp_path: Path) -> None:
307 """A flow-configured soloist instance loads the soloist backend with its stored values."""
308 provider = _provider_with_stored_config(
309 {
310 CONF_BACKEND: BACKEND_SOLOIST,
311 CONF_API_KEY: "soloist-api-key-0123456789abcdef",
312 CONF_SOLOIST_CONSENT: True,
313 },
314 tmp_path,
315 )
316 # the volume mode lives in the provider options, not in the setup data
317 provider.config.values[CONF_VOLUME_MODE] = ConfigEntry(
318 key=CONF_VOLUME_MODE,
319 type=ConfigEntryType.STRING,
320 value=VOLUME_MODE_SYNC_SPOTIFY,
321 )
322
323 provider.config.values[CONF_CROSSFADE_DURATION] = ConfigEntry(
324 key=CONF_CROSSFADE_DURATION,
325 type=ConfigEntryType.INTEGER,
326 value=8,
327 )
328 provider.config.values[CONF_LOUDNESS_NORMALIZATION] = ConfigEntry(
329 key=CONF_LOUDNESS_NORMALIZATION,
330 type=ConfigEntryType.BOOLEAN,
331 value=False,
332 )
333 provider.config.values[CONF_AUDIO_QUALITY] = ConfigEntry(
334 key=CONF_AUDIO_QUALITY,
335 type=ConfigEntryType.STRING,
336 value=AUDIO_QUALITY_HIGH,
337 )
338
339 backend = provider._create_backend()
340
341 assert isinstance(backend, SoloistBackend)
342 assert backend._api_key == "soloist-api-key-0123456789abcdef"
343 assert backend._consent is True
344 assert backend._volume_mode == VOLUME_MODE_SYNC_SPOTIFY
345 assert backend._crossfade_ms == 8000
346 assert backend._loudness_normalization is False
347 assert backend._audio_quality == AUDIO_QUALITY_HIGH
348
349
350def test_audio_behavior_defaults_reach_the_backend(tmp_path: Path) -> None:
351 """Without stored values, crossfade is off and normalization enabled."""
352 provider = _provider_with_stored_config({}, tmp_path)
353
354 backend = provider._create_backend()
355
356 assert isinstance(backend, GoLibrespotBackend)
357 assert backend._crossfade_ms == 0
358 assert backend._loudness_normalization is True
359 assert backend._audio_quality == AUDIO_QUALITY_LOSSLESS
360
361
362def test_audio_behavior_values_reach_the_backend(tmp_path: Path) -> None:
363 """The configured crossfade seconds (as ms) and normalization reach the backend."""
364 provider = _provider_with_stored_config({}, tmp_path)
365 provider.config.values[CONF_CROSSFADE_DURATION] = ConfigEntry(
366 key=CONF_CROSSFADE_DURATION,
367 type=ConfigEntryType.INTEGER,
368 value=8,
369 )
370 provider.config.values[CONF_LOUDNESS_NORMALIZATION] = ConfigEntry(
371 key=CONF_LOUDNESS_NORMALIZATION,
372 type=ConfigEntryType.BOOLEAN,
373 value=False,
374 )
375 provider.config.values[CONF_AUDIO_QUALITY] = ConfigEntry(
376 key=CONF_AUDIO_QUALITY,
377 type=ConfigEntryType.STRING,
378 value=AUDIO_QUALITY_HIGH,
379 )
380
381 backend = provider._create_backend()
382
383 assert isinstance(backend, GoLibrespotBackend)
384 assert backend._crossfade_ms == 8000
385 assert backend._loudness_normalization is False
386 assert backend._audio_quality == AUDIO_QUALITY_HIGH
387
388
389def test_write_config_carries_the_audio_behavior_keys(tmp_path: Path) -> None:
390 """The generated config.yml carries crossfade_duration (ms) and normalisation_disabled."""
391 backend = object.__new__(GoLibrespotBackend)
392 backend.mass = MagicMock()
393 backend.logger = MagicMock()
394 backend._publish_name = "Test Speaker"
395 backend._instance_id = "spotify_connect--test"
396 backend._api_port = 38800
397 backend.cache_dir = str(tmp_path)
398 backend._crossfade_ms = 8000
399 backend._loudness_normalization = False
400 backend._audio_quality = AUDIO_QUALITY_HIGH
401
402 backend._write_config(None)
403
404 config = json.loads((tmp_path / "config.yml").read_text(encoding="utf-8"))
405 assert config["crossfade_duration"] == 8000
406 assert config["normalisation_disabled"] is True
407 assert config["bitrate"] == 160
408
409
410def test_write_config_caps_lossless_at_the_engine_maximum(tmp_path: Path) -> None:
411 """go-librespot cannot do lossless, so that tier lands on its 320 kbps ceiling."""
412 backend = object.__new__(GoLibrespotBackend)
413 backend.mass = MagicMock()
414 backend.logger = MagicMock()
415 backend._publish_name = "Test Speaker"
416 backend._instance_id = "spotify_connect--test"
417 backend._api_port = 38800
418 backend.cache_dir = str(tmp_path)
419 backend._crossfade_ms = 0
420 backend._loudness_normalization = True
421 backend._audio_quality = AUDIO_QUALITY_LOSSLESS
422
423 backend._write_config(None)
424
425 config = json.loads((tmp_path / "config.yml").read_text(encoding="utf-8"))
426 assert config["bitrate"] == 320
427