/
/
/
1"""Tests for the per-queue config layer (migration + parsing)."""
2
3from collections.abc import AsyncIterator
4from types import SimpleNamespace
5from typing import Any, cast
6from unittest.mock import MagicMock
7
8from music_assistant_models.config_entries import ConfigEntry, PlayerQueueConfig
9from music_assistant_models.enums import ConfigEntryType
10
11from music_assistant.constants import (
12 CONF_CORE,
13 CONF_CROSSFADE_DURATION,
14 CONF_ENTRY_CROSSFADE_DURATION,
15 CONF_PLAYER_QUEUES,
16 CONF_VALUE_DISABLED,
17 CONF_VALUE_ENABLED,
18 CONF_VOLUME_NORMALIZATION,
19)
20from music_assistant.controllers.config import ConfigController
21from music_assistant.controllers.config.migrations import (
22 _migrate_global_queue_settings,
23 _migrate_player_queue_settings,
24)
25from music_assistant.controllers.player_queues.constants import (
26 CONF_AUTOPLAY_PLAYLIST,
27 CONF_SMART_SHUFFLE_ENABLED,
28 CONF_SMART_SHUFFLE_SONG_RECENCY,
29)
30
31
32def _migrate(data: dict[str, Any]) -> bool:
33 """Run the (settings.json) queue-settings migration against a raw data dict."""
34 return _migrate_player_queue_settings(data)
35
36
37def test_migrate_player_queue_settings_moves_only_queue_keys() -> None:
38 """Queue-scoped values move to the per-queue config, player-scoped ones stay put."""
39 data: dict[str, Any] = {
40 "players": {
41 "p1": {
42 "player_id": "p1",
43 "values": {
44 "crossfade_duration": 12,
45 "volume_normalization": False,
46 "tts_pre_announce": False, # player-scoped -> stays on the player
47 "smart_fades_mode": "disabled", # legacy off -> consumed, nothing carried over
48 },
49 },
50 "p2": {"player_id": "p2", "values": {}}, # nothing to move
51 }
52 }
53 assert _migrate(data) is True
54 # moved keys (and the consumed legacy smart_fades_mode) are gone from the player config
55 assert data["players"]["p1"]["values"] == {"tts_pre_announce": False}
56 # ...and now live under the per-queue config (queue_id == player_id)
57 assert data["player_queues"]["p1"]["values"] == {
58 "crossfade_duration": 12,
59 "volume_normalization": False,
60 }
61 # players without queue-scoped values don't get a queue config entry
62 assert "p2" not in data.get("player_queues", {})
63
64
65def test_migrate_player_queue_settings_noop_when_nothing_to_move() -> None:
66 """Migration reports no change when there are no queue-scoped values to move."""
67 data: dict[str, Any] = {
68 "players": {"p1": {"player_id": "p1", "values": {"tts_pre_announce": False}}}
69 }
70 assert _migrate(data) is False
71 assert "player_queues" not in data
72
73
74def test_migrate_player_queue_settings_maps_legacy_smart_fades_mode() -> None:
75 """smart_fades_mode is consumed and standard/smart carry over to crossfade_mode."""
76 data: dict[str, Any] = {
77 "players": {
78 "p1": {"player_id": "p1", "values": {"smart_fades_mode": "standard_crossfade"}},
79 "p2": {"player_id": "p2", "values": {"smart_fades_mode": "smart_crossfade"}},
80 "p3": {"player_id": "p3", "values": {"smart_fades_mode": "disabled"}},
81 }
82 }
83 assert _migrate(data) is True
84 # the legacy key is removed from every player
85 assert all(cfg["values"] == {} for cfg in data["players"].values())
86 # standard/smart carry over to the new crossfade_mode select
87 assert data["player_queues"]["p1"]["values"] == {"crossfade_mode": "standard_crossfade"}
88 assert data["player_queues"]["p2"]["values"] == {"crossfade_mode": "smart_crossfade"}
89 # disabled just means crossfade is off -> nothing written
90 assert "p3" not in data.get("player_queues", {})
91
92
93def test_migrate_player_queue_settings_keeps_existing_queue_value() -> None:
94 """An already-stored queue value is not clobbered by the migration."""
95 data: dict[str, Any] = {
96 "players": {"p1": {"player_id": "p1", "values": {"crossfade_duration": 12}}},
97 "player_queues": {"p1": {"queue_id": "p1", "values": {"crossfade_duration": 5}}},
98 }
99 assert _migrate(data) is True
100 assert data["player_queues"]["p1"]["values"]["crossfade_duration"] == 5
101 assert "crossfade_duration" not in data["players"]["p1"]["values"]
102
103
104def test_migrate_global_queue_settings_bool_to_select() -> None:
105 """The former boolean toggles become their enabled/disabled select strings (idempotently)."""
106 data: dict[str, Any] = {
107 CONF_PLAYER_QUEUES: {
108 "q1": {
109 "queue_id": "q1",
110 "values": {
111 CONF_VOLUME_NORMALIZATION: False,
112 CONF_SMART_SHUFFLE_ENABLED: True,
113 },
114 }
115 }
116 }
117 assert _migrate_global_queue_settings(data) is True
118 values = data[CONF_PLAYER_QUEUES]["q1"]["values"]
119 assert values[CONF_VOLUME_NORMALIZATION] == CONF_VALUE_DISABLED
120 assert values[CONF_SMART_SHUFFLE_ENABLED] == CONF_VALUE_ENABLED
121 # a second run is a no-op (the values are already select strings)
122 assert _migrate_global_queue_settings(data) is False
123
124
125def test_migrate_global_queue_settings_promotes_consistent_value() -> None:
126 """A crossfade duration shared by every queue is promoted to core and dropped per-queue."""
127 data: dict[str, Any] = {
128 CONF_PLAYER_QUEUES: {
129 "q1": {"queue_id": "q1", "values": {CONF_CROSSFADE_DURATION: 12}},
130 "q2": {"queue_id": "q2", "values": {CONF_CROSSFADE_DURATION: 12}},
131 }
132 }
133 assert _migrate_global_queue_settings(data) is True
134 assert data[CONF_CORE][CONF_PLAYER_QUEUES]["values"][CONF_CROSSFADE_DURATION] == 12
135 assert CONF_CROSSFADE_DURATION not in data[CONF_PLAYER_QUEUES]["q1"]["values"]
136 assert CONF_CROSSFADE_DURATION not in data[CONF_PLAYER_QUEUES]["q2"]["values"]
137
138
139def test_migrate_global_queue_settings_mixed_values_not_promoted() -> None:
140 """When queues disagree the value is not promoted, but the per-queue copies are still dropped."""
141 data: dict[str, Any] = {
142 CONF_PLAYER_QUEUES: {
143 "q1": {"queue_id": "q1", "values": {CONF_SMART_SHUFFLE_SONG_RECENCY: "3600"}},
144 "q2": {"queue_id": "q2", "values": {CONF_SMART_SHUFFLE_SONG_RECENCY: "86400"}},
145 }
146 }
147 assert _migrate_global_queue_settings(data) is True
148 core_values = data.get(CONF_CORE, {}).get(CONF_PLAYER_QUEUES, {}).get("values", {})
149 assert CONF_SMART_SHUFFLE_SONG_RECENCY not in core_values
150 assert CONF_SMART_SHUFFLE_SONG_RECENCY not in data[CONF_PLAYER_QUEUES]["q1"]["values"]
151 assert CONF_SMART_SHUFFLE_SONG_RECENCY not in data[CONF_PLAYER_QUEUES]["q2"]["values"]
152
153
154def test_migrate_global_queue_settings_noop_when_empty() -> None:
155 """Nothing to migrate reports no change."""
156 assert _migrate_global_queue_settings({}) is False
157 assert _migrate_global_queue_settings({CONF_PLAYER_QUEUES: {}}) is False
158
159
160def test_player_queue_config_parse_roundtrip() -> None:
161 """A stored queue config parses its values back via the current entries."""
162 config = cast(
163 "PlayerQueueConfig",
164 PlayerQueueConfig.parse(
165 [CONF_ENTRY_CROSSFADE_DURATION],
166 {"queue_id": "q1", "values": {"crossfade_duration": 9}},
167 ),
168 )
169 assert config.queue_id == "q1"
170 assert config.get_value("crossfade_duration") == 9
171
172
173async def test_get_player_queue_config_for_api_populates_playlist_options() -> None:
174 """The 'get' command resolves the autoplay playlist dropdown from the library playlists."""
175 entry = ConfigEntry(
176 key=CONF_AUTOPLAY_PLAYLIST, type=ConfigEntryType.STRING, options=[], required=False
177 )
178 config = cast(
179 "PlayerQueueConfig",
180 PlayerQueueConfig.parse([entry], {"queue_id": "q1", "values": {}}),
181 )
182
183 async def _playlists() -> AsyncIterator[SimpleNamespace]:
184 yield SimpleNamespace(uri="library://playlist/1", name="Chill")
185 yield SimpleNamespace(uri="library://playlist/2", name="Party")
186
187 fake = MagicMock()
188 fake.get_player_queue_config = MagicMock(return_value=config)
189 fake.mass.music.playlists.iter_library_items = lambda: _playlists()
190 # bind the real helper so the command exercises the actual option-building logic
191 fake._library_playlist_options = lambda: ConfigController._library_playlist_options(fake)
192
193 result = await ConfigController.get_player_queue_config_for_api(fake, "q1")
194
195 options = result.values[CONF_AUTOPLAY_PLAYLIST].options
196 assert [(opt.title, opt.value) for opt in options] == [
197 ("Chill", "library://playlist/1"),
198 ("Party", "library://playlist/2"),
199 ]
200