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