/
/
/
1"""Tests for the Spotify Connect provider."""
2
3import asyncio
4import json
5from collections.abc import AsyncGenerator
6from dataclasses import dataclass
7from pathlib import Path
8from typing import Any, cast
9from unittest.mock import AsyncMock, MagicMock, patch
10
11from music_assistant_models.config_entries import ConfigEntry, ProviderConfig
12from music_assistant_models.enums import ConfigEntryType, EventType, ProviderType
13from music_assistant_models.event import MassEvent
14from music_assistant_models.streamdetails import StreamMetadata
15
16from music_assistant.constants import CONF_CROSSFADE_DURATION
17from music_assistant.providers.spotify_connect import (
18 BACKEND_SOLOIST,
19 CONF_API_KEY,
20 CONF_BACKEND,
21 CONF_SOLOIST_CONSENT,
22 CONF_VOLUME_MODE,
23 SpotifyConnectProvider,
24)
25from music_assistant.providers.spotify_connect.base import (
26 AUDIO_QUALITY_HIGH,
27 AUDIO_QUALITY_LOSSLESS,
28)
29from music_assistant.providers.spotify_connect.go_librespot.backend import (
30 API_PORT_RANGE_END,
31 API_PORT_RANGE_START,
32 GoLibrespotBackend,
33)
34from music_assistant.providers.spotify_connect.go_librespot.client import GoLibrespotClient
35from music_assistant.providers.spotify_connect.models import BackendEvent, BackendEventType
36from music_assistant.providers.spotify_connect.provider import (
37 CONF_AUDIO_QUALITY,
38 CONF_LOUDNESS_NORMALIZATION,
39 _PlayerDaemon,
40)
41from music_assistant.providers.spotify_connect.soloist.backend import (
42 VOLUME_MODE_SYNC_SPOTIFY,
43 SoloistBackend,
44)
45
46# the connected player the tested daemon is bound to; doubles as the source item_id
47_PLAYER_ID = "player1"
48
49
50def _make_daemon(publish_name: str = "Test Speaker") -> _PlayerDaemon:
51 """Build a bare daemon state bound to the test player."""
52 return _PlayerDaemon(
53 player_id=_PLAYER_ID,
54 safe_player_id=_PLAYER_ID,
55 publish_name=publish_name,
56 stream_metadata=StreamMetadata(title=f"Spotify Connect | {publish_name}"),
57 )
58
59
60async def test_backend_start_probes_api_port_on_ipv4_loopback() -> None:
61 """The daemon API port is selected on the address go-librespot binds."""
62 backend = object.__new__(GoLibrespotBackend)
63 backend.mass = MagicMock()
64 backend.logger = MagicMock()
65 backend.mass.create_task.side_effect = lambda coroutine: coroutine.close()
66
67 with (
68 patch(
69 "music_assistant.providers.spotify_connect.go_librespot.backend"
70 ".get_go_librespot_binary",
71 return_value="/usr/bin/go-librespot",
72 ),
73 patch(
74 "music_assistant.providers.spotify_connect.go_librespot.backend.select_free_port",
75 new=AsyncMock(return_value=38801),
76 ) as select_port,
77 ):
78 await backend.start()
79
80 select_port.assert_awaited_once_with(API_PORT_RANGE_START, API_PORT_RANGE_END, host="127.0.0.1")
81 assert backend._client is not None
82 assert backend._client.base_url == "http://127.0.0.1:38801"
83
84
85async def test_daemon_runner_reselects_api_port_when_taken(tmp_path: Path) -> None:
86 """An API port taken while the daemon was down is replaced before (re)starting."""
87 backend = object.__new__(GoLibrespotBackend)
88 backend.mass = MagicMock()
89 backend.mass.streams.get_source_ip = AsyncMock(return_value="192.168.1.5")
90 backend.logger = MagicMock()
91 backend.name = "Spotify Test"
92 backend.cache_dir = str(tmp_path)
93 backend._binary = "/usr/bin/go-librespot"
94 backend._api_port = 38800
95 backend._client = GoLibrespotClient(backend.mass, "http://127.0.0.1:38800", backend.logger)
96 backend._event_callback = AsyncMock()
97 # exit the supervisor loop after a single iteration
98 backend._stop_called = True
99 backend._restart_error_count = 0
100
101 async def _no_stderr() -> AsyncGenerator[str]:
102 return
103 yield
104
105 proc = MagicMock()
106 proc.start = AsyncMock()
107 proc.close = AsyncMock()
108 proc.iter_stderr = _no_stderr
109
110 with (
111 patch(
112 "music_assistant.providers.spotify_connect.go_librespot.backend.is_port_in_use",
113 new=AsyncMock(return_value=True),
114 ) as port_probe,
115 patch(
116 "music_assistant.providers.spotify_connect.go_librespot.backend.select_free_port",
117 new=AsyncMock(return_value=38801),
118 ),
119 patch(
120 "music_assistant.providers.spotify_connect.go_librespot.backend.AsyncProcess",
121 return_value=proc,
122 ),
123 patch.object(GoLibrespotBackend, "_write_config") as write_config,
124 ):
125 await backend._daemon_runner()
126
127 port_probe.assert_awaited_once_with(38800, host="127.0.0.1")
128 assert backend._api_port == 38801
129 assert backend._client.base_url == "http://127.0.0.1:38801"
130 # the daemon config pins the advertisement to the player-facing interface
131 write_config.assert_called_once_with("192.168.1.5")
132
133
134def _volume_sync_provider(
135 volume_level: int | None,
136 group_volume: int | None = None,
137) -> tuple[SpotifyConnectProvider, _PlayerDaemon, AsyncMock]:
138 """
139 Build a minimal provider whose linked player reports the given volume.
140
141 :param volume_level: The player's own volume level, None for a group.
142 :param group_volume: The group volume, defaulting to the player's own level as it
143 does for anything that is not a group.
144 """
145 provider = object.__new__(SpotifyConnectProvider)
146 provider.mass = MagicMock()
147 provider.mass.players.get_audio_source_session.return_value = MagicMock(
148 playback_session_id="playback-session"
149 )
150 provider.logger = MagicMock()
151 daemon = _make_daemon()
152 backend = MagicMock()
153 set_volume = AsyncMock()
154 backend.set_volume = set_volume
155 daemon.backend = backend
156 provider._daemons = {_PLAYER_ID: daemon}
157 player = MagicMock()
158 player.state.volume_level = volume_level
159 player.state.group_volume = volume_level if group_volume is None else group_volume
160 provider.mass.players.get_player.return_value = player
161 return provider, daemon, set_volume
162
163
164async def test_sync_player_volume_pushes_player_volume_to_backend() -> None:
165 """The player's volume is pushed to the backend and cached for echo dedupe."""
166 provider, daemon, set_volume = _volume_sync_provider(50)
167
168 await provider._sync_player_volume_to_spotify(daemon, "player1")
169
170 set_volume.assert_awaited_once_with(50)
171 assert daemon.last_volume_sent == 50
172
173
174async def test_sync_player_volume_pushes_when_cache_matches() -> None:
175 """The push is unconditional: the backend's volume resets between sessions."""
176 provider, daemon, set_volume = _volume_sync_provider(50)
177 daemon.last_volume_sent = 50
178
179 await provider._sync_player_volume_to_spotify(daemon, "player1")
180
181 set_volume.assert_awaited_once_with(50)
182
183
184async def test_sync_player_volume_skips_when_volume_unknown() -> None:
185 """No push happens when the player does not expose a volume level."""
186 provider, daemon, set_volume = _volume_sync_provider(None)
187
188 await provider._sync_player_volume_to_spotify(daemon, "player1")
189
190 set_volume.assert_not_awaited()
191 assert daemon.last_volume_sent is None
192
193
194async def test_sync_player_volume_pushes_group_volume_for_a_group() -> None:
195 """A group has no volume of its own, so its group volume is what gets pushed."""
196 provider, daemon, set_volume = _volume_sync_provider(None, group_volume=44)
197
198 await provider._sync_player_volume_to_spotify(daemon, "player1")
199
200 # the backend otherwise keeps its 100% default and the first press blasts the group
201 # (music-assistant/support#6218)
202 set_volume.assert_awaited_once_with(44)
203
204
205async def test_sync_player_volume_prefers_the_players_own_level_over_the_group() -> None:
206 """A sync leader is commanded on its own, so its own level is what the app must show."""
207 provider, daemon, set_volume = _volume_sync_provider(30, group_volume=60)
208
209 await provider._sync_player_volume_to_spotify(daemon, "player1")
210
211 # cmd_volume_set only redirects a PlayerType.GROUP, so a leader is commanded on its
212 # own and pushing its group's 60 would send the first press to ~62
213 set_volume.assert_awaited_once_with(30)
214
215
216async def test_sync_player_volume_skips_a_group_with_no_powered_members() -> None:
217 """group_volume reads powered members only, so it can be unknown before playback."""
218 provider, daemon, set_volume = _volume_sync_provider(None)
219
220 await provider._sync_player_volume_to_spotify(daemon, "player1")
221
222 set_volume.assert_not_awaited()
223
224
225async def test_sync_player_volume_restores_cache_on_failure() -> None:
226 """A failed push restores the dedupe cache so a retry is not wrongly deduped."""
227 provider, daemon, set_volume = _volume_sync_provider(50)
228 set_volume.side_effect = OSError("daemon gone")
229
230 await provider._sync_player_volume_to_spotify(daemon, "player1")
231
232 assert daemon.last_volume_sent is None
233
234
235def _tethered_provider() -> tuple[SpotifyConnectProvider, _PlayerDaemon, AsyncMock]:
236 """Build a provider tethered to queue 'player1' with an active (paused) Spotify session."""
237 provider = object.__new__(SpotifyConnectProvider)
238 provider.mass = MagicMock()
239 provider.logger = MagicMock()
240 provider.config = ProviderConfig(
241 values={},
242 type=ProviderType.PLUGIN,
243 domain="spotify_connect",
244 instance_id="spotify_connect--test",
245 name="Spotify Connect",
246 )
247 daemon = _make_daemon()
248 backend = MagicMock()
249 deactivate = AsyncMock()
250 backend.deactivate = deactivate
251 daemon.backend = backend
252 daemon.active_player_id = "player1"
253 daemon.spotify_session_active = True
254 provider._daemons = {_PLAYER_ID: daemon}
255 return provider, daemon, deactivate
256
257
258def test_get_player_audio_sources_scopes_to_the_daemon_player() -> None:
259 """Each daemon's source is bound to its own connected player only."""
260 provider = object.__new__(SpotifyConnectProvider)
261 daemon = _make_daemon()
262 daemon.audio_source = MagicMock()
263 provider._daemons = {_PLAYER_ID: daemon}
264
265 assert provider.get_player_audio_sources(_PLAYER_ID) == [daemon.audio_source]
266 assert provider.get_player_audio_sources("other_player") == []
267
268
269async def test_releasing_a_player_releases_a_paused_spotify_session() -> None:
270 """Letting the player go releases the session the paused stream's teardown left behind."""
271 provider, _daemon, deactivate = _tethered_provider()
272
273 await provider.on_source_released(_PLAYER_ID, "player1")
274
275 deactivate.assert_awaited_once()
276
277
278async def test_release_while_the_stream_is_winding_down_still_releases() -> None:
279 """
280 A release landing before the paused stream finished tearing down still releases.
281
282 The teardown itself releases nothing for a paused source, so waiting for it to hand the
283 claim back would leave the Spotify app tethered for good.
284 """
285 provider, daemon, deactivate = _tethered_provider()
286 daemon.in_use_by_player = "player1"
287
288 await provider.on_source_released(_PLAYER_ID, "player1")
289
290 deactivate.assert_awaited_once()
291
292
293async def test_clearing_another_queue_leaves_the_session_alone() -> None:
294 """Only the queue the source is tethered to may release it."""
295 provider, _daemon, deactivate = _tethered_provider()
296
297 await provider.on_source_released(_PLAYER_ID, "player2")
298
299 deactivate.assert_not_awaited()
300
301
302async def test_queue_clear_without_an_active_session_does_nothing() -> None:
303 """There is nothing to release when MA is not the active Spotify device."""
304 provider, daemon, deactivate = _tethered_provider()
305 daemon.spotify_session_active = False
306
307 await provider.on_source_released(_PLAYER_ID, "player1")
308
309 deactivate.assert_not_awaited()
310
311
312async def _session_inactive(provider: SpotifyConnectProvider, daemon: _PlayerDaemon) -> list[str]:
313 """Run the backend's 'session inactive' answer and return the players it wanted stopped."""
314 stopped: list[str] = []
315
316 def _record_stop(daemon: _PlayerDaemon, player_id: str) -> None:
317 del daemon
318 stopped.append(player_id)
319
320 provider._schedule_pause_stop = _record_stop # type: ignore[method-assign]
321 await provider._handle_backend_event(
322 daemon, BackendEvent(type=BackendEventType.SESSION_INACTIVE)
323 )
324 return stopped
325
326
327async def test_releasing_the_session_leaves_the_new_playback_alone() -> None:
328 """
329 Releasing must not stop the player that took the source's place.
330
331 The backend answers a release with the same "session inactive" it sends when the user picks
332 another device in the Spotify app - and that one does stop the player. By then this player is
333 playing whatever replaced the source, so stopping it would cut the music the user just started.
334 """
335 provider, daemon, _ = _tethered_provider()
336
337 await provider.on_source_released(_PLAYER_ID, "player1")
338
339 assert await _session_inactive(provider, daemon) == []
340
341
342async def test_a_spotify_side_deselect_still_stops_the_player() -> None:
343 """Picking another device in the Spotify app does stop what MA was playing from it."""
344 provider, daemon, _ = _tethered_provider()
345
346 assert await _session_inactive(provider, daemon) == ["player1"]
347
348
349async def test_queue_clear_survives_a_failing_release() -> None:
350 """A backend that cannot be reached must not break clearing the queue."""
351 provider, _daemon, deactivate = _tethered_provider()
352 deactivate.side_effect = OSError("daemon gone")
353
354 await provider.on_source_released(_PLAYER_ID, "player1")
355
356 deactivate.assert_awaited_once()
357
358
359async def test_a_slow_stop_after_pause_is_reported() -> None:
360 """A stop that takes its time is reported, and still runs to completion."""
361 stopped = asyncio.Event()
362
363 async def _slow_stop(_player_id: str) -> None:
364 await asyncio.sleep(0.05)
365 stopped.set()
366
367 provider, _daemon, _ = _tethered_provider()
368 mass = cast("Any", provider.mass)
369 mass.loop = asyncio.get_running_loop()
370 mass.players.cmd_stop = AsyncMock(side_effect=_slow_stop)
371 logger = cast("MagicMock", provider.logger)
372
373 with patch("music_assistant.providers.spotify_connect.provider.SLOW_STOP_WARN_S", 0.01):
374 await provider._stop_paused_player("player1")
375
376 assert stopped.is_set()
377 logger.warning.assert_called_once()
378
379
380async def test_a_prompt_stop_after_pause_is_not_reported() -> None:
381 """A stop that finishes promptly is not reported as slow."""
382 provider, _daemon, _ = _tethered_provider()
383 mass = cast("Any", provider.mass)
384 mass.loop = asyncio.get_running_loop()
385 mass.players.cmd_stop = AsyncMock()
386 logger = cast("MagicMock", provider.logger)
387
388 await provider._stop_paused_player("player1")
389
390 mass.players.cmd_stop.assert_awaited_once_with("player1")
391 logger.warning.assert_not_called()
392
393
394def _provider_with_stored_config(
395 setup_data: dict[str, Any], tmp_path: Path
396) -> SpotifyConnectProvider:
397 """Build a provider whose stored setup_data resolves through the real accessors."""
398 provider = object.__new__(SpotifyConnectProvider)
399 provider.mass = MagicMock()
400 provider.mass.storage_path = str(tmp_path / "storage")
401 provider.mass.cache_path = str(tmp_path / "cache")
402 provider.mass.config.get.return_value = setup_data
403 provider.mass.config.decrypt_string.side_effect = lambda value: value
404 provider.logger = MagicMock()
405 provider.config = ProviderConfig(
406 values={},
407 type=ProviderType.PLUGIN,
408 domain="spotify_connect",
409 instance_id="spotify_connect",
410 name="Spotify Connect",
411 )
412 return provider
413
414
415def test_config_without_backend_choice_loads_go_librespot(tmp_path: Path) -> None:
416 """A config from before the backend choice existed loads go-librespot unchanged."""
417 provider = _provider_with_stored_config({}, tmp_path)
418
419 backend = provider._create_backend(_make_daemon(), "Player 1")
420
421 assert isinstance(backend, GoLibrespotBackend)
422
423
424def test_soloist_setup_data_loads_soloist_backend(tmp_path: Path) -> None:
425 """A flow-configured soloist instance loads the soloist backend with its stored values."""
426 provider = _provider_with_stored_config(
427 {
428 CONF_BACKEND: BACKEND_SOLOIST,
429 CONF_API_KEY: "soloist-api-key-0123456789abcdef",
430 CONF_SOLOIST_CONSENT: True,
431 },
432 tmp_path,
433 )
434 # the volume mode lives in the provider options, not in the setup data
435 provider.config.values[CONF_VOLUME_MODE] = ConfigEntry(
436 key=CONF_VOLUME_MODE,
437 type=ConfigEntryType.STRING,
438 value=VOLUME_MODE_SYNC_SPOTIFY,
439 )
440
441 provider.config.values[CONF_CROSSFADE_DURATION] = ConfigEntry(
442 key=CONF_CROSSFADE_DURATION,
443 type=ConfigEntryType.INTEGER,
444 value=8,
445 )
446 provider.config.values[CONF_LOUDNESS_NORMALIZATION] = ConfigEntry(
447 key=CONF_LOUDNESS_NORMALIZATION,
448 type=ConfigEntryType.BOOLEAN,
449 value=False,
450 )
451 provider.config.values[CONF_AUDIO_QUALITY] = ConfigEntry(
452 key=CONF_AUDIO_QUALITY,
453 type=ConfigEntryType.STRING,
454 value=AUDIO_QUALITY_HIGH,
455 )
456
457 backend = provider._create_backend(_make_daemon(), "Player 1")
458
459 assert isinstance(backend, SoloistBackend)
460 assert backend._api_key == "soloist-api-key-0123456789abcdef"
461 assert backend._consent is True
462 assert backend._volume_mode == VOLUME_MODE_SYNC_SPOTIFY
463 assert backend._crossfade_ms == 8000
464 assert backend._loudness_normalization is False
465 assert backend._audio_quality == AUDIO_QUALITY_HIGH
466
467
468def test_audio_behavior_defaults_reach_the_backend(tmp_path: Path) -> None:
469 """Without stored values, crossfade is off and normalization enabled."""
470 provider = _provider_with_stored_config({}, tmp_path)
471
472 backend = provider._create_backend(_make_daemon(), "Player 1")
473
474 assert isinstance(backend, GoLibrespotBackend)
475 assert backend._crossfade_ms == 0
476 assert backend._loudness_normalization is True
477 assert backend._audio_quality == AUDIO_QUALITY_LOSSLESS
478
479
480def test_audio_behavior_values_reach_the_backend(tmp_path: Path) -> None:
481 """The configured crossfade seconds (as ms) and normalization reach the backend."""
482 provider = _provider_with_stored_config({}, tmp_path)
483 provider.config.values[CONF_CROSSFADE_DURATION] = ConfigEntry(
484 key=CONF_CROSSFADE_DURATION,
485 type=ConfigEntryType.INTEGER,
486 value=8,
487 )
488 provider.config.values[CONF_LOUDNESS_NORMALIZATION] = ConfigEntry(
489 key=CONF_LOUDNESS_NORMALIZATION,
490 type=ConfigEntryType.BOOLEAN,
491 value=False,
492 )
493 provider.config.values[CONF_AUDIO_QUALITY] = ConfigEntry(
494 key=CONF_AUDIO_QUALITY,
495 type=ConfigEntryType.STRING,
496 value=AUDIO_QUALITY_HIGH,
497 )
498
499 backend = provider._create_backend(_make_daemon(), "Player 1")
500
501 assert isinstance(backend, GoLibrespotBackend)
502 assert backend._crossfade_ms == 8000
503 assert backend._loudness_normalization is False
504 assert backend._audio_quality == AUDIO_QUALITY_HIGH
505
506
507def test_source_processing_defaults_are_reported(tmp_path: Path) -> None:
508 """Spotify reports its default source processing as normalization only."""
509 provider = _provider_with_stored_config({}, tmp_path)
510
511 assert provider.delivers_crossfaded_audio(MagicMock()) is False
512 assert provider.delivers_normalized_audio(MagicMock()) is True
513
514
515def test_source_processing_config_is_reported(tmp_path: Path) -> None:
516 """Spotify reports the source processing configured for its backend."""
517 provider = _provider_with_stored_config({}, tmp_path)
518 provider.config.values[CONF_CROSSFADE_DURATION] = ConfigEntry(
519 key=CONF_CROSSFADE_DURATION,
520 type=ConfigEntryType.INTEGER,
521 value=8,
522 )
523 provider.config.values[CONF_LOUDNESS_NORMALIZATION] = ConfigEntry(
524 key=CONF_LOUDNESS_NORMALIZATION,
525 type=ConfigEntryType.BOOLEAN,
526 value=False,
527 )
528
529 assert provider.delivers_crossfaded_audio(MagicMock()) is True
530 assert provider.delivers_normalized_audio(MagicMock()) is False
531
532
533def test_write_config_carries_the_audio_behavior_keys(tmp_path: Path) -> None:
534 """The generated config.yml carries crossfade_duration (ms) and normalisation_disabled."""
535 backend = object.__new__(GoLibrespotBackend)
536 backend.mass = MagicMock()
537 backend.logger = MagicMock()
538 backend._publish_name = "Test Speaker"
539 backend._identity_key = "spotify_connect_player1"
540 backend._api_port = 38800
541 backend.cache_dir = str(tmp_path)
542 backend._crossfade_ms = 8000
543 backend._loudness_normalization = False
544 backend._audio_quality = AUDIO_QUALITY_HIGH
545
546 backend._write_config(None)
547
548 config = json.loads((tmp_path / "config.yml").read_text(encoding="utf-8"))
549 assert config["crossfade_duration"] == 8000
550 assert config["normalisation_disabled"] is True
551 assert config["bitrate"] == 160
552
553
554def test_write_config_caps_lossless_at_the_engine_maximum(tmp_path: Path) -> None:
555 """go-librespot cannot do lossless, so that tier lands on its 320 kbps ceiling."""
556 backend = object.__new__(GoLibrespotBackend)
557 backend.mass = MagicMock()
558 backend.logger = MagicMock()
559 backend._publish_name = "Test Speaker"
560 backend._identity_key = "spotify_connect_player1"
561 backend._api_port = 38800
562 backend.cache_dir = str(tmp_path)
563 backend._crossfade_ms = 0
564 backend._loudness_normalization = True
565 backend._audio_quality = AUDIO_QUALITY_LOSSLESS
566
567 backend._write_config(None)
568
569 config = json.loads((tmp_path / "config.yml").read_text(encoding="utf-8"))
570 assert config["bitrate"] == 320
571
572
573async def test_soloist_data_dir_matches_the_migration_target(tmp_path: Path) -> None:
574 """The per-player soloist data dir is exactly where the migration moves old data to."""
575 provider = _provider_with_stored_config(
576 {
577 CONF_BACKEND: BACKEND_SOLOIST,
578 CONF_API_KEY: "soloist-api-key-0123456789abcdef",
579 CONF_SOLOIST_CONSENT: True,
580 },
581 tmp_path,
582 )
583 provider.manifest = MagicMock()
584 provider.manifest.domain = "spotify_connect"
585 provider._daemons = {}
586 player = MagicMock()
587 player.player_id = "player one!"
588 player.display_name = "Player One"
589
590 with patch.object(SoloistBackend, "start", new=AsyncMock()):
591 await provider._start_daemon(player, "Player One | Music Assistant")
592
593 daemon = provider._daemons["player one!"]
594 backend = cast("SoloistBackend", daemon.backend)
595 assert backend._data_dir == (
596 tmp_path / "storage" / "spotify_connect" / "spotify_connect_player_one_" / "soloist-data"
597 )
598
599
600# --- Daemon reconciliation -----------------------------------------------------
601
602
603@dataclass
604class _ReconcileMocks:
605 """The mocked collaborators of a reconcile-test provider."""
606
607 mass: MagicMock
608 start_daemon: AsyncMock
609 stop_daemon: AsyncMock
610
611
612def _reconcile_provider(
613 assigned: tuple[str, ...],
614 registered: dict[str, str],
615) -> tuple[SpotifyConnectProvider, _ReconcileMocks]:
616 """
617 Build a bare provider with the real reconcile logic and mocked daemon control.
618
619 :param assigned: The connected player ids the provider was loaded with.
620 :param registered: Currently registered player ids mapped to their display name.
621 """
622 prov = SpotifyConnectProvider.__new__(SpotifyConnectProvider)
623 prov.logger = MagicMock()
624 prov.config = MagicMock()
625 prov.mass = mass = MagicMock()
626 prov._daemons = {}
627 prov._failed_player_ids = set()
628 prov._reconcile_lock = asyncio.Lock()
629 prov._unload_called = False
630 prov._unsubscribe = None
631 prov._assigned_player_ids = assigned
632 prov.get_config_value = MagicMock(return_value="player_mass") # type: ignore[method-assign]
633
634 def get_player(player_id: str) -> MagicMock | None:
635 if player_id not in registered:
636 return None
637 player = MagicMock()
638 player.player_id = player_id
639 player.display_name = registered[player_id]
640 return player
641
642 mass.players.get_player.side_effect = get_player
643
644 async def start_daemon(player: MagicMock, publish_name: str) -> None:
645 # stop_called / active_player_id are spelled out: a bare MagicMock attribute is
646 # truthy, which would trip the stopped-daemon guard and the deselect path
647 prov._daemons[player.player_id] = MagicMock(
648 player_id=player.player_id,
649 publish_name=publish_name,
650 stop_called=False,
651 active_player_id=None,
652 )
653
654 start_mock = AsyncMock(side_effect=start_daemon)
655 stop_mock = AsyncMock()
656 prov._start_daemon = start_mock # type: ignore[method-assign]
657 prov._stop_daemon = stop_mock # type: ignore[method-assign]
658 return prov, _ReconcileMocks(mass=mass, start_daemon=start_mock, stop_daemon=stop_mock)
659
660
661async def test_reconcile_starts_daemon_when_assigned_player_registers() -> None:
662 """A daemon starts only once its connected player has actually registered."""
663 registered: dict[str, str] = {}
664 prov, mocks = _reconcile_provider(("p1",), registered)
665
666 # cold boot: the player has not registered yet, so nothing starts
667 await prov._reconcile()
668 mocks.start_daemon.assert_not_awaited()
669
670 registered["p1"] = "Kitchen"
671 await prov._reconcile()
672 mocks.start_daemon.assert_awaited_once()
673 assert mocks.start_daemon.call_args.args[1] == "Kitchen | Music Assistant"
674 assert "p1" in prov._daemons
675
676
677async def test_reconcile_restarts_daemon_on_advertised_name_drift() -> None:
678 """A renamed player gets its daemon restarted with the new advertised name."""
679 registered = {"p1": "Kitchen"}
680 prov, mocks = _reconcile_provider(("p1",), registered)
681 await prov._reconcile()
682 old_daemon = prov._daemons["p1"]
683
684 # a second pass without changes is a no-op
685 await prov._reconcile()
686 mocks.stop_daemon.assert_not_awaited()
687 assert mocks.start_daemon.await_count == 1
688
689 # a live session on the old daemon is released before the daemon is replaced
690 old_daemon.active_player_id = "consumer"
691 registered["p1"] = "Cellar"
692 await prov._reconcile()
693 mocks.stop_daemon.assert_awaited_once_with(old_daemon)
694 assert prov._daemons["p1"].publish_name == "Cellar | Music Assistant"
695 mocks.mass.players.deselect_source.assert_called_once()
696 assert mocks.mass.players.deselect_source.call_args.args[0] == "consumer"
697
698
699async def test_reconcile_keeps_daemon_for_temporarily_unavailable_player() -> None:
700 """A temporarily unregistered player keeps its running daemon (stable identity)."""
701 registered = {"p1": "Kitchen"}
702 prov, mocks = _reconcile_provider(("p1",), registered)
703 await prov._reconcile()
704 daemon = prov._daemons["p1"]
705
706 registered.clear()
707 await prov._reconcile()
708 mocks.stop_daemon.assert_not_awaited()
709 assert prov._daemons["p1"] is daemon
710
711
712async def test_player_removed_event_stops_daemon() -> None:
713 """A permanently removed player gets its daemon stopped and dropped."""
714 registered = {"p1": "Kitchen"}
715 prov, mocks = _reconcile_provider(("p1",), registered)
716 await prov._reconcile()
717 daemon = prov._daemons["p1"]
718
719 await prov._on_player_event(MassEvent(event=EventType.PLAYER_REMOVED, object_id="p1"))
720 mocks.stop_daemon.assert_awaited_once_with(daemon)
721 assert not prov._daemons
722
723
724async def test_player_added_event_triggers_reconcile() -> None:
725 """A player registering (cold boot path) starts its daemon via the event handler."""
726 prov, mocks = _reconcile_provider(("p1",), {"p1": "Kitchen"})
727
728 await prov._on_player_event(MassEvent(event=EventType.PLAYER_ADDED, object_id="p1"))
729 mocks.start_daemon.assert_awaited_once()
730
731
732async def test_loaded_in_mass_with_empty_connected_players_is_idle() -> None:
733 """An empty connected-players selection loads the provider fully idle."""
734 prov, mocks = _reconcile_provider((), {})
735
736 await prov.loaded_in_mass()
737 mocks.mass.subscribe.assert_not_called()
738 mocks.start_daemon.assert_not_awaited()
739 assert not prov._daemons
740
741
742async def test_loaded_in_mass_subscribes_to_assigned_players_only() -> None:
743 """Player events are only watched for the connected players."""
744 prov, mocks = _reconcile_provider(("p1", "p2"), {})
745
746 await prov.loaded_in_mass()
747 mocks.mass.subscribe.assert_called_once()
748 assert mocks.mass.subscribe.call_args.kwargs["id_filter"] == ("p1", "p2")
749
750
751async def test_unload_stops_all_daemons() -> None:
752 """Unload stops every running daemon and stops watching player events."""
753 registered = {"p1": "Kitchen", "p2": "Garage"}
754 prov, mocks = _reconcile_provider(("p1", "p2"), registered)
755 await prov._reconcile()
756 unsubscribe = MagicMock()
757 prov._unsubscribe = unsubscribe
758
759 await prov.unload()
760 unsubscribe.assert_called_once()
761 assert mocks.stop_daemon.await_count == 2
762 assert not prov._daemons
763
764
765async def test_fatal_backend_error_gives_up_only_the_failed_daemon() -> None:
766 """A permanently failed backend drops its own daemon and leaves the others running."""
767 prov, mocks = _reconcile_provider(("p1", "p2"), {"p1": "Kitchen", "p2": "Garage"})
768 unload_with_error = MagicMock()
769 prov.unload_with_error = unload_with_error # type: ignore[method-assign]
770 await prov._reconcile()
771 daemon = prov._daemons["p1"]
772 daemon.active_player_id = "consumer"
773
774 await prov._handle_backend_event(
775 daemon, BackendEvent(type=BackendEventType.FATAL_ERROR, error="boom")
776 )
777 # the give-up is a deferred task so it does not stop the runner task it is called from
778 assert mocks.mass.create_task.call_args.kwargs == {"eager_start": False}
779 give_up = mocks.mass.create_task.call_args.args[0]
780 await give_up
781
782 assert "p1" not in prov._daemons
783 assert "p2" in prov._daemons
784 mocks.stop_daemon.assert_awaited_once_with(daemon)
785 assert prov._failed_player_ids == {"p1"}
786 mocks.mass.players.trigger_player_update.assert_called_with("p1")
787 mocks.mass.players.deselect_source.assert_called_once()
788 cast("MagicMock", prov.logger).warning.assert_called_once()
789 unload_with_error.assert_not_called()
790
791
792async def test_provider_wide_fatal_error_unloads_the_provider() -> None:
793 """An engine-level failure keeps taking the whole provider down."""
794 prov, mocks = _reconcile_provider(("p1",), {"p1": "Kitchen"})
795 unload_with_error = MagicMock()
796 prov.unload_with_error = unload_with_error # type: ignore[method-assign]
797 await prov._reconcile()
798 daemon = prov._daemons["p1"]
799
800 await prov._handle_backend_event(
801 daemon,
802 BackendEvent(
803 type=BackendEventType.FATAL_ERROR, error="api key revoked", provider_wide=True
804 ),
805 )
806
807 unload_with_error.assert_called_once_with("api key revoked")
808 mocks.mass.create_task.assert_not_called()
809 assert "p1" in prov._daemons
810
811
812async def test_reconcile_skips_a_given_up_daemon() -> None:
813 """A daemon that gave up permanently is not relaunched by an ordinary reconcile."""
814 prov, mocks = _reconcile_provider(("p1",), {"p1": "Kitchen"})
815 prov._failed_player_ids = {"p1"}
816
817 await prov._reconcile()
818
819 mocks.start_daemon.assert_not_awaited()
820 assert "p1" not in prov._daemons
821
822
823async def test_player_added_gives_a_failed_daemon_a_fresh_start() -> None:
824 """A player re-registering lifts the block and starts its daemon again."""
825 prov, mocks = _reconcile_provider(("p1",), {"p1": "Kitchen"})
826 prov._failed_player_ids = {"p1"}
827
828 await prov._on_player_event(MassEvent(event=EventType.PLAYER_ADDED, object_id="p1"))
829
830 assert "p1" not in prov._failed_player_ids
831 mocks.start_daemon.assert_awaited_once()
832 assert "p1" in prov._daemons
833
834
835async def test_give_up_on_a_replaced_daemon_is_a_noop() -> None:
836 """A give-up landing after the daemon was replaced leaves the replacement running."""
837 prov, mocks = _reconcile_provider(("p1",), {"p1": "Kitchen"})
838 await prov._reconcile()
839 old_daemon = prov._daemons["p1"]
840 replacement = MagicMock(player_id="p1", publish_name="Kitchen | Music Assistant")
841 prov._daemons["p1"] = replacement
842
843 await prov._give_up_daemon(old_daemon, "boom")
844
845 mocks.stop_daemon.assert_not_awaited()
846 assert not prov._failed_player_ids
847 assert prov._daemons["p1"] is replacement
848