/
/
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._reconcile_lock = asyncio.Lock()
589 prov._unload_called = False
590 prov._unsubscribe = None
591 prov._assigned_player_ids = assigned
592 prov.get_config_value = MagicMock(return_value="player_mass") # type: ignore[method-assign]
593
594 def get_player(player_id: str) -> MagicMock | None:
595 if player_id not in registered:
596 return None
597 player = MagicMock()
598 player.player_id = player_id
599 player.display_name = registered[player_id]
600 return player
601
602 mass.players.get_player.side_effect = get_player
603
604 async def start_daemon(player: MagicMock, publish_name: str) -> None:
605 prov._daemons[player.player_id] = MagicMock(
606 player_id=player.player_id, publish_name=publish_name
607 )
608
609 start_mock = AsyncMock(side_effect=start_daemon)
610 stop_mock = AsyncMock()
611 prov._start_daemon = start_mock # type: ignore[method-assign]
612 prov._stop_daemon = stop_mock # type: ignore[method-assign]
613 return prov, _ReconcileMocks(mass=mass, start_daemon=start_mock, stop_daemon=stop_mock)
614
615
616async def test_reconcile_starts_daemon_when_assigned_player_registers() -> None:
617 """A daemon starts only once its connected player has actually registered."""
618 registered: dict[str, str] = {}
619 prov, mocks = _reconcile_provider(("p1",), registered)
620
621 # cold boot: the player has not registered yet, so nothing starts
622 await prov._reconcile()
623 mocks.start_daemon.assert_not_awaited()
624
625 registered["p1"] = "Kitchen"
626 await prov._reconcile()
627 mocks.start_daemon.assert_awaited_once()
628 assert mocks.start_daemon.call_args.args[1] == "Kitchen | Music Assistant"
629 assert "p1" in prov._daemons
630
631
632async def test_reconcile_restarts_daemon_on_advertised_name_drift() -> None:
633 """A renamed player gets its daemon restarted with the new advertised name."""
634 registered = {"p1": "Kitchen"}
635 prov, mocks = _reconcile_provider(("p1",), registered)
636 await prov._reconcile()
637 old_daemon = prov._daemons["p1"]
638
639 # a second pass without changes is a no-op
640 await prov._reconcile()
641 mocks.stop_daemon.assert_not_awaited()
642 assert mocks.start_daemon.await_count == 1
643
644 # a live session on the old daemon is released before the daemon is replaced
645 old_daemon.active_player_id = "consumer"
646 registered["p1"] = "Cellar"
647 await prov._reconcile()
648 mocks.stop_daemon.assert_awaited_once_with(old_daemon)
649 assert prov._daemons["p1"].publish_name == "Cellar | Music Assistant"
650 mocks.mass.players.deselect_source.assert_called_once()
651 assert mocks.mass.players.deselect_source.call_args.args[0] == "consumer"
652
653
654async def test_reconcile_keeps_daemon_for_temporarily_unavailable_player() -> None:
655 """A temporarily unregistered player keeps its running daemon (stable identity)."""
656 registered = {"p1": "Kitchen"}
657 prov, mocks = _reconcile_provider(("p1",), registered)
658 await prov._reconcile()
659 daemon = prov._daemons["p1"]
660
661 registered.clear()
662 await prov._reconcile()
663 mocks.stop_daemon.assert_not_awaited()
664 assert prov._daemons["p1"] is daemon
665
666
667async def test_player_removed_event_stops_daemon() -> None:
668 """A permanently removed player gets its daemon stopped and dropped."""
669 registered = {"p1": "Kitchen"}
670 prov, mocks = _reconcile_provider(("p1",), registered)
671 await prov._reconcile()
672 daemon = prov._daemons["p1"]
673
674 await prov._on_player_event(MassEvent(event=EventType.PLAYER_REMOVED, object_id="p1"))
675 mocks.stop_daemon.assert_awaited_once_with(daemon)
676 assert not prov._daemons
677
678
679async def test_player_added_event_triggers_reconcile() -> None:
680 """A player registering (cold boot path) starts its daemon via the event handler."""
681 prov, mocks = _reconcile_provider(("p1",), {"p1": "Kitchen"})
682
683 await prov._on_player_event(MassEvent(event=EventType.PLAYER_ADDED, object_id="p1"))
684 mocks.start_daemon.assert_awaited_once()
685
686
687async def test_loaded_in_mass_with_empty_connected_players_is_idle() -> None:
688 """An empty connected-players selection loads the provider fully idle."""
689 prov, mocks = _reconcile_provider((), {})
690
691 await prov.loaded_in_mass()
692 mocks.mass.subscribe.assert_not_called()
693 mocks.start_daemon.assert_not_awaited()
694 assert not prov._daemons
695
696
697async def test_loaded_in_mass_subscribes_to_assigned_players_only() -> None:
698 """Player events are only watched for the connected players."""
699 prov, mocks = _reconcile_provider(("p1", "p2"), {})
700
701 await prov.loaded_in_mass()
702 mocks.mass.subscribe.assert_called_once()
703 assert mocks.mass.subscribe.call_args.kwargs["id_filter"] == ("p1", "p2")
704
705
706async def test_unload_stops_all_daemons() -> None:
707 """Unload stops every running daemon and stops watching player events."""
708 registered = {"p1": "Kitchen", "p2": "Garage"}
709 prov, mocks = _reconcile_provider(("p1", "p2"), registered)
710 await prov._reconcile()
711 unsubscribe = MagicMock()
712 prov._unsubscribe = unsubscribe
713
714 await prov.unload()
715 unsubscribe.assert_called_once()
716 assert mocks.stop_daemon.await_count == 2
717 assert not prov._daemons
718