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