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