/
/
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.mass.players.get_audio_source_session.return_value = MagicMock(
122 playback_session_id="playback-session"
123 )
124 provider.logger = MagicMock()
125 provider._last_volume_sent = None
126 backend = MagicMock()
127 set_volume = AsyncMock()
128 backend.set_volume = set_volume
129 provider._backend = backend
130 player = MagicMock()
131 player.state.volume_level = volume_level
132 provider.mass.players.get_player.return_value = player
133 return provider, set_volume
134
135
136async def test_sync_player_volume_pushes_player_volume_to_backend() -> None:
137 """The player's volume is pushed to the backend and cached for echo dedupe."""
138 provider, set_volume = _volume_sync_provider(50)
139
140 await provider._sync_player_volume_to_spotify("player1")
141
142 set_volume.assert_awaited_once_with(50)
143 assert provider._last_volume_sent == 50
144
145
146async def test_sync_player_volume_pushes_when_cache_matches() -> None:
147 """The push is unconditional: the backend's volume resets between sessions."""
148 provider, set_volume = _volume_sync_provider(50)
149 provider._last_volume_sent = 50
150
151 await provider._sync_player_volume_to_spotify("player1")
152
153 set_volume.assert_awaited_once_with(50)
154
155
156async def test_sync_player_volume_skips_when_volume_unknown() -> None:
157 """No push happens when the player does not expose a volume level."""
158 provider, set_volume = _volume_sync_provider(None)
159
160 await provider._sync_player_volume_to_spotify("player1")
161
162 set_volume.assert_not_awaited()
163 assert provider._last_volume_sent is None
164
165
166async def test_sync_player_volume_restores_cache_on_failure() -> None:
167 """A failed push restores the dedupe cache so a retry is not wrongly deduped."""
168 provider, set_volume = _volume_sync_provider(50)
169 set_volume.side_effect = OSError("daemon gone")
170
171 await provider._sync_player_volume_to_spotify("player1")
172
173 assert provider._last_volume_sent is None
174
175
176def _tethered_provider() -> tuple[SpotifyConnectProvider, AsyncMock]:
177 """Build a provider tethered to queue 'player1' with an active (paused) Spotify session."""
178 provider = object.__new__(SpotifyConnectProvider)
179 provider.mass = MagicMock()
180 provider.logger = MagicMock()
181 provider.config = ProviderConfig(
182 values={},
183 type=ProviderType.PLUGIN,
184 domain="spotify_connect",
185 instance_id="spotify_connect--test",
186 name="Spotify Connect",
187 )
188 backend = MagicMock()
189 deactivate = AsyncMock()
190 backend.deactivate = deactivate
191 provider._backend = backend
192 provider._active_player_id = "player1"
193 provider._in_use_by_player = None
194 provider._active_session_id = None
195 provider._spotify_session_active = True
196 provider._playing = False
197 provider._pending_pause_stop_task = None
198 provider._pending_play_media_task = None
199 return provider, deactivate
200
201
202async def test_releasing_a_player_releases_a_paused_spotify_session() -> None:
203 """Letting the player go releases the session the paused stream's teardown left behind."""
204 provider, deactivate = _tethered_provider()
205
206 await provider.on_source_released(AUDIO_SOURCE_ID, "player1")
207
208 deactivate.assert_awaited_once()
209
210
211async def test_release_while_the_stream_is_winding_down_still_releases() -> None:
212 """
213 A release landing before the paused stream finished tearing down still releases.
214
215 The teardown itself releases nothing for a paused source, so waiting for it to hand the
216 claim back would leave the Spotify app tethered for good.
217 """
218 provider, deactivate = _tethered_provider()
219 provider._in_use_by_player = "player1"
220
221 await provider.on_source_released(AUDIO_SOURCE_ID, "player1")
222
223 deactivate.assert_awaited_once()
224
225
226async def test_clearing_another_queue_leaves_the_session_alone() -> None:
227 """Only the queue the source is tethered to may release it."""
228 provider, deactivate = _tethered_provider()
229
230 await provider.on_source_released(AUDIO_SOURCE_ID, "player2")
231
232 deactivate.assert_not_awaited()
233
234
235async def test_queue_clear_without_an_active_session_does_nothing() -> None:
236 """There is nothing to release when MA is not the active Spotify device."""
237 provider, deactivate = _tethered_provider()
238 provider._spotify_session_active = False
239
240 await provider.on_source_released(AUDIO_SOURCE_ID, "player1")
241
242 deactivate.assert_not_awaited()
243
244
245async def _session_inactive(provider: SpotifyConnectProvider) -> list[str]:
246 """Run the backend's 'session inactive' answer and return the players it wanted stopped."""
247 stopped: list[str] = []
248 provider._schedule_pause_stop = lambda player_id: stopped.append(player_id) # type: ignore[method-assign]
249 with patch.object(SpotifyConnectProvider, "name", "Spotify Test"):
250 await provider._handle_backend_event(BackendEvent(type=BackendEventType.SESSION_INACTIVE))
251 return stopped
252
253
254async def test_releasing_the_session_leaves_the_new_playback_alone() -> None:
255 """
256 Releasing must not stop the player that took the source's place.
257
258 The backend answers a release with the same "session inactive" it sends when the user picks
259 another device in the Spotify app - and that one does stop the player. By then this player is
260 playing whatever replaced the source, so stopping it would cut the music the user just started.
261 """
262 provider, _ = _tethered_provider()
263
264 with patch.object(SpotifyConnectProvider, "name", "Spotify Test"):
265 await provider.on_source_released(AUDIO_SOURCE_ID, "player1")
266
267 assert await _session_inactive(provider) == []
268
269
270async def test_a_spotify_side_deselect_still_stops_the_player() -> None:
271 """Picking another device in the Spotify app does stop what MA was playing from it."""
272 provider, _ = _tethered_provider()
273
274 assert await _session_inactive(provider) == ["player1"]
275
276
277async def test_queue_clear_survives_a_failing_release() -> None:
278 """A backend that cannot be reached must not break clearing the queue."""
279 provider, deactivate = _tethered_provider()
280 deactivate.side_effect = OSError("daemon gone")
281
282 await provider.on_source_released(AUDIO_SOURCE_ID, "player1")
283
284 deactivate.assert_awaited_once()
285
286
287def _provider_with_stored_config(
288 setup_data: dict[str, Any], tmp_path: Path
289) -> SpotifyConnectProvider:
290 """Build a provider whose stored setup_data resolves through the real accessors."""
291 provider = object.__new__(SpotifyConnectProvider)
292 provider.mass = MagicMock()
293 provider.mass.storage_path = str(tmp_path / "storage")
294 provider.mass.cache_path = str(tmp_path / "cache")
295 provider.mass.config.get.return_value = setup_data
296 provider.mass.config.decrypt_string.side_effect = lambda value: value
297 provider.logger = MagicMock()
298 provider._publish_name = "Test Speaker"
299 provider.config = ProviderConfig(
300 values={},
301 type=ProviderType.PLUGIN,
302 domain="spotify_connect",
303 instance_id="spotify_connect--test",
304 name="Spotify Connect",
305 )
306 return provider
307
308
309def test_config_without_backend_choice_loads_go_librespot(tmp_path: Path) -> None:
310 """A config from before the backend choice existed loads go-librespot unchanged."""
311 provider = _provider_with_stored_config({}, tmp_path)
312
313 assert isinstance(provider._create_backend(), GoLibrespotBackend)
314
315
316def test_soloist_setup_data_loads_soloist_backend(tmp_path: Path) -> None:
317 """A flow-configured soloist instance loads the soloist backend with its stored values."""
318 provider = _provider_with_stored_config(
319 {
320 CONF_BACKEND: BACKEND_SOLOIST,
321 CONF_API_KEY: "soloist-api-key-0123456789abcdef",
322 CONF_SOLOIST_CONSENT: True,
323 },
324 tmp_path,
325 )
326 # the volume mode lives in the provider options, not in the setup data
327 provider.config.values[CONF_VOLUME_MODE] = ConfigEntry(
328 key=CONF_VOLUME_MODE,
329 type=ConfigEntryType.STRING,
330 value=VOLUME_MODE_SYNC_SPOTIFY,
331 )
332
333 provider.config.values[CONF_CROSSFADE_DURATION] = ConfigEntry(
334 key=CONF_CROSSFADE_DURATION,
335 type=ConfigEntryType.INTEGER,
336 value=8,
337 )
338 provider.config.values[CONF_LOUDNESS_NORMALIZATION] = ConfigEntry(
339 key=CONF_LOUDNESS_NORMALIZATION,
340 type=ConfigEntryType.BOOLEAN,
341 value=False,
342 )
343 provider.config.values[CONF_AUDIO_QUALITY] = ConfigEntry(
344 key=CONF_AUDIO_QUALITY,
345 type=ConfigEntryType.STRING,
346 value=AUDIO_QUALITY_HIGH,
347 )
348
349 backend = provider._create_backend()
350
351 assert isinstance(backend, SoloistBackend)
352 assert backend._api_key == "soloist-api-key-0123456789abcdef"
353 assert backend._consent is True
354 assert backend._volume_mode == VOLUME_MODE_SYNC_SPOTIFY
355 assert backend._crossfade_ms == 8000
356 assert backend._loudness_normalization is False
357 assert backend._audio_quality == AUDIO_QUALITY_HIGH
358
359
360def test_audio_behavior_defaults_reach_the_backend(tmp_path: Path) -> None:
361 """Without stored values, crossfade is off and normalization enabled."""
362 provider = _provider_with_stored_config({}, tmp_path)
363
364 backend = provider._create_backend()
365
366 assert isinstance(backend, GoLibrespotBackend)
367 assert backend._crossfade_ms == 0
368 assert backend._loudness_normalization is True
369 assert backend._audio_quality == AUDIO_QUALITY_LOSSLESS
370
371
372def test_audio_behavior_values_reach_the_backend(tmp_path: Path) -> None:
373 """The configured crossfade seconds (as ms) and normalization reach the backend."""
374 provider = _provider_with_stored_config({}, tmp_path)
375 provider.config.values[CONF_CROSSFADE_DURATION] = ConfigEntry(
376 key=CONF_CROSSFADE_DURATION,
377 type=ConfigEntryType.INTEGER,
378 value=8,
379 )
380 provider.config.values[CONF_LOUDNESS_NORMALIZATION] = ConfigEntry(
381 key=CONF_LOUDNESS_NORMALIZATION,
382 type=ConfigEntryType.BOOLEAN,
383 value=False,
384 )
385 provider.config.values[CONF_AUDIO_QUALITY] = ConfigEntry(
386 key=CONF_AUDIO_QUALITY,
387 type=ConfigEntryType.STRING,
388 value=AUDIO_QUALITY_HIGH,
389 )
390
391 backend = provider._create_backend()
392
393 assert isinstance(backend, GoLibrespotBackend)
394 assert backend._crossfade_ms == 8000
395 assert backend._loudness_normalization is False
396 assert backend._audio_quality == AUDIO_QUALITY_HIGH
397
398
399def test_write_config_carries_the_audio_behavior_keys(tmp_path: Path) -> None:
400 """The generated config.yml carries crossfade_duration (ms) and normalisation_disabled."""
401 backend = object.__new__(GoLibrespotBackend)
402 backend.mass = MagicMock()
403 backend.logger = MagicMock()
404 backend._publish_name = "Test Speaker"
405 backend._instance_id = "spotify_connect--test"
406 backend._api_port = 38800
407 backend.cache_dir = str(tmp_path)
408 backend._crossfade_ms = 8000
409 backend._loudness_normalization = False
410 backend._audio_quality = AUDIO_QUALITY_HIGH
411
412 backend._write_config(None)
413
414 config = json.loads((tmp_path / "config.yml").read_text(encoding="utf-8"))
415 assert config["crossfade_duration"] == 8000
416 assert config["normalisation_disabled"] is True
417 assert config["bitrate"] == 160
418
419
420def test_write_config_caps_lossless_at_the_engine_maximum(tmp_path: Path) -> None:
421 """go-librespot cannot do lossless, so that tier lands on its 320 kbps ceiling."""
422 backend = object.__new__(GoLibrespotBackend)
423 backend.mass = MagicMock()
424 backend.logger = MagicMock()
425 backend._publish_name = "Test Speaker"
426 backend._instance_id = "spotify_connect--test"
427 backend._api_port = 38800
428 backend.cache_dir = str(tmp_path)
429 backend._crossfade_ms = 0
430 backend._loudness_normalization = True
431 backend._audio_quality = AUDIO_QUALITY_LOSSLESS
432
433 backend._write_config(None)
434
435 config = json.loads((tmp_path / "config.yml").read_text(encoding="utf-8"))
436 assert config["bitrate"] == 320
437