/
/
/
1"""
2Preset configuration for the Bose SoundTouch provider.
3
4Builds the config entries shown in the provider settings, allowing the user to map
5every physical preset button (1-6) to a Music Assistant media item via a
6search-and-select flow. The mapping is provider wide: pressing button 4 plays the
7same content on every SoundTouch speaker of this provider instance.
8"""
9
10from __future__ import annotations
11
12import asyncio
13from typing import TYPE_CHECKING
14
15from music_assistant_models.config_entries import ConfigEntry, ConfigValueOption
16from music_assistant_models.enums import ConfigEntryType, MediaType
17from music_assistant_models.errors import MusicAssistantError
18from music_assistant_models.media_items import (
19 Album,
20 Artist,
21 Audiobook,
22 Genre,
23 ItemMapping,
24 Playlist,
25 Podcast,
26 Radio,
27 Track,
28)
29
30from .const import PRESET_IDS
31
32if TYPE_CHECKING:
33 from music_assistant_models.media_items import SearchResults
34
35 from music_assistant.mass import MusicAssistant
36
37 from .provider import BoseSoundTouchProvider
38
39SearchResultItem = (
40 Artist | Album | Track | Radio | Playlist | Audiobook | Podcast | Genre | ItemMapping
41)
42
43SEARCH_RESULT_LIMIT = 25
44SEARCH_TIMEOUT = 10
45
46# shared prefix of every preset config key, used to tell a preset-only config
47# change apart from a change that needs a provider reload
48PRESET_KEY_PREFIX = "preset_"
49CONF_SEARCH_MEDIA_TYPE = "preset_search_media_type"
50CONF_SEARCH_QUERY = "preset_search_query"
51CONF_SEARCH_RESULT = "preset_search_result"
52CONF_SEARCH_TARGET = "preset_search_target"
53ACTION_SEARCH = "preset_search"
54ACTION_ASSIGN = "preset_assign"
55SEARCH_CATEGORY = "preset_search"
56PRESET_CATEGORY = "presets"
57
58SEARCHABLE_MEDIA_TYPES = (
59 MediaType.ARTIST,
60 MediaType.ALBUM,
61 MediaType.TRACK,
62 MediaType.PLAYLIST,
63 MediaType.RADIO,
64 MediaType.AUDIOBOOK,
65 MediaType.PODCAST,
66 MediaType.GENRE,
67)
68DEFAULT_MEDIA_TYPE = MediaType.PLAYLIST
69MEDIA_TYPE_OPTIONS = [
70 ConfigValueOption(title=media_type.value.lower(), value=media_type.value)
71 for media_type in SEARCHABLE_MEDIA_TYPES
72]
73PRESET_TARGET_OPTIONS = [
74 ConfigValueOption(
75 value="",
76 disabled=True,
77 translation_key="select",
78 ),
79 *[ConfigValueOption(value=str(preset_id)) for preset_id in PRESET_IDS],
80]
81
82
83def preset_media_key(preset_id: int) -> str:
84 """Return the config key holding the media URI for the given preset."""
85 return f"{PRESET_KEY_PREFIX}{preset_id}_media"
86
87
88async def build_preset_config_entries(
89 provider: BoseSoundTouchProvider,
90 *,
91 refresh_results: bool = False,
92) -> list[ConfigEntry]:
93 """
94 Return the preset config entries for the SoundTouch provider.
95
96 A single search flow assigns its selected result to any of the six physical
97 preset buttons. Existing preset mappings remain editable independently below it.
98
99 :param provider: The SoundTouch provider whose stored config the entries are built from.
100 :param refresh_results: Whether to run the media search for this render.
101 """
102 media_type = _media_type_config_value(provider, CONF_SEARCH_MEDIA_TYPE)
103 query = _string_config_value(provider, CONF_SEARCH_QUERY).strip()
104 selected_media = _string_config_value(provider, CONF_SEARCH_RESULT)
105 selected_target = _string_config_value(provider, CONF_SEARCH_TARGET)
106 media_options = await _build_search_result_options(
107 mass=provider.mass,
108 media_type=media_type,
109 query=query,
110 selected_media=selected_media,
111 refresh_results=refresh_results,
112 )
113
114 entries = [
115 ConfigEntry(
116 key=CONF_SEARCH_MEDIA_TYPE,
117 type=ConfigEntryType.STRING,
118 translation_key="preset_search_media_type",
119 required=False,
120 default_value=DEFAULT_MEDIA_TYPE.value,
121 value=media_type.value,
122 options=MEDIA_TYPE_OPTIONS,
123 category=SEARCH_CATEGORY,
124 immediate_apply=True,
125 ),
126 ConfigEntry(
127 key=CONF_SEARCH_QUERY,
128 type=ConfigEntryType.STRING,
129 translation_key="preset_search_query",
130 required=False,
131 default_value="",
132 value=query,
133 category=SEARCH_CATEGORY,
134 immediate_apply=True,
135 ),
136 ConfigEntry(
137 key="preset_do_search",
138 type=ConfigEntryType.ACTION,
139 translation_key="preset_search_action",
140 action=ACTION_SEARCH,
141 category=SEARCH_CATEGORY,
142 ),
143 ConfigEntry(
144 key=CONF_SEARCH_RESULT,
145 type=ConfigEntryType.STRING,
146 translation_key="preset_search_result",
147 required=False,
148 default_value="",
149 value=selected_media,
150 options=media_options,
151 category=SEARCH_CATEGORY,
152 immediate_apply=True,
153 hidden=not media_options,
154 ),
155 ConfigEntry(
156 key=CONF_SEARCH_TARGET,
157 type=ConfigEntryType.STRING,
158 translation_key="preset_search_target",
159 required=False,
160 default_value="",
161 value=selected_target,
162 options=PRESET_TARGET_OPTIONS,
163 category=SEARCH_CATEGORY,
164 immediate_apply=True,
165 hidden=not media_options,
166 ),
167 ]
168 can_assign = bool(
169 media_options
170 and selected_media
171 and selected_target in {str(preset_id) for preset_id in PRESET_IDS}
172 )
173 entries.append(
174 ConfigEntry(
175 key="preset_do_assign",
176 type=ConfigEntryType.ACTION,
177 translation_key="preset_assign_action",
178 translation_params=[selected_target],
179 action=ACTION_ASSIGN,
180 category=SEARCH_CATEGORY,
181 immediate_apply=True,
182 hidden=not can_assign,
183 )
184 )
185
186 for preset_id in PRESET_IDS:
187 entries.append(
188 ConfigEntry(
189 key=preset_media_key(preset_id),
190 type=ConfigEntryType.STRING,
191 translation_key="preset_media",
192 translation_params=[str(preset_id)],
193 required=False,
194 default_value="",
195 value=_string_config_value(provider, preset_media_key(preset_id)),
196 category=PRESET_CATEGORY,
197 )
198 )
199 return entries
200
201
202async def _build_search_result_options(
203 mass: MusicAssistant,
204 media_type: MediaType,
205 query: str,
206 selected_media: str,
207 refresh_results: bool,
208) -> list[ConfigValueOption]:
209 """Build the result dropdown for a preset without losing the current selection."""
210 media_options = await _build_media_options(mass, media_type, query) if refresh_results else []
211 if selected_media and selected_media not in {option.value for option in media_options}:
212 media_options.append(ConfigValueOption(title=selected_media, value=selected_media))
213 return media_options
214
215
216async def _build_media_options(
217 mass: MusicAssistant,
218 media_type: MediaType,
219 query: str,
220) -> list[ConfigValueOption]:
221 """Build dropdown options for a preset media search."""
222 options_by_value: dict[str, ConfigValueOption] = {}
223 for item in await _search_media_items(mass, media_type, query):
224 value = item.uri
225 if not value or value in options_by_value:
226 continue
227 options_by_value[value] = ConfigValueOption(
228 title=f"{item.name} ({item.media_type.value}, {item.provider})",
229 value=value,
230 )
231 return sorted(options_by_value.values(), key=lambda option: (option.title or "").lower())
232
233
234async def _search_media_items(
235 mass: MusicAssistant,
236 media_type: MediaType,
237 query: str,
238) -> list[SearchResultItem]:
239 """Search MA media items for preset config options."""
240 if not query or media_type not in SEARCHABLE_MEDIA_TYPES:
241 return []
242 try:
243 search_result = await asyncio.wait_for(
244 mass.music.search(
245 search_query=query,
246 media_types=[media_type],
247 limit=SEARCH_RESULT_LIMIT,
248 library_only=False,
249 ),
250 timeout=SEARCH_TIMEOUT,
251 )
252 except MusicAssistantError, TimeoutError:
253 return []
254 return _iter_search_result_items(search_result, media_type)
255
256
257def _iter_search_result_items(
258 search_result: SearchResults,
259 media_type: MediaType,
260) -> list[SearchResultItem]:
261 """Extract media items from a typed MA search result."""
262 match media_type:
263 case MediaType.ARTIST:
264 return list(search_result.artists)
265 case MediaType.ALBUM:
266 return list(search_result.albums)
267 case MediaType.GENRE:
268 return list(search_result.genres)
269 case MediaType.TRACK:
270 return list(search_result.tracks)
271 case MediaType.PLAYLIST:
272 return list(search_result.playlists)
273 case MediaType.RADIO:
274 return list(search_result.radio)
275 case MediaType.AUDIOBOOK:
276 return list(search_result.audiobooks)
277 case MediaType.PODCAST:
278 return list(search_result.podcasts)
279 case _:
280 return []
281
282
283def _string_config_value(provider: BoseSoundTouchProvider, key: str) -> str:
284 """Return a string config value from the provider's stored config (empty if unset)."""
285 value = provider.get_config_value(key)
286 return value if isinstance(value, str) else ""
287
288
289def _media_type_config_value(provider: BoseSoundTouchProvider, key: str) -> MediaType:
290 """Return a searchable media type from the provider's stored config."""
291 media_type = MediaType(_string_config_value(provider, key) or DEFAULT_MEDIA_TYPE.value)
292 return media_type if media_type in SEARCHABLE_MEDIA_TYPES else DEFAULT_MEDIA_TYPE
293