/
/
/
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_"
49
50SEARCHABLE_MEDIA_TYPES = (
51 MediaType.ARTIST,
52 MediaType.ALBUM,
53 MediaType.TRACK,
54 MediaType.PLAYLIST,
55 MediaType.RADIO,
56 MediaType.AUDIOBOOK,
57 MediaType.PODCAST,
58 MediaType.GENRE,
59)
60DEFAULT_MEDIA_TYPE = MediaType.PLAYLIST
61MEDIA_TYPE_OPTIONS = [
62 ConfigValueOption(title=media_type.value.replace("_", " ").title(), value=media_type.value)
63 for media_type in SEARCHABLE_MEDIA_TYPES
64]
65
66
67def preset_media_key(preset_id: int) -> str:
68 """Return the config key holding the media URI for the given preset."""
69 return f"{PRESET_KEY_PREFIX}{preset_id}_media"
70
71
72def preset_media_type_key(preset_id: int) -> str:
73 """Return the config key holding the media type for the given preset."""
74 return f"{PRESET_KEY_PREFIX}{preset_id}_media_type"
75
76
77def preset_search_key(preset_id: int) -> str:
78 """Return the config key holding the search query for the given preset."""
79 return f"{PRESET_KEY_PREFIX}{preset_id}_search"
80
81
82def preset_selected_media_key(preset_id: int) -> str:
83 """Return the config key holding the selected search result for the given preset."""
84 return f"{PRESET_KEY_PREFIX}{preset_id}_selected_media"
85
86
87def parse_preset_action(action: str) -> tuple[int | None, bool]:
88 """
89 Parse a preset action id into its ``(preset_id, is_select)`` parts.
90
91 Returns ``(None, False)`` for actions that are not preset search/select buttons.
92
93 :param action: The action id of the pressed button.
94 """
95 for preset_id in PRESET_IDS:
96 if action == _preset_search_action(preset_id):
97 return preset_id, False
98 if action == _preset_select_action(preset_id):
99 return preset_id, True
100 return None, False
101
102
103async def build_preset_config_entries(
104 provider: BoseSoundTouchProvider,
105 *,
106 refresh_preset_id: int | None = None,
107) -> list[ConfigEntry]:
108 """
109 Return the preset config entries for the SoundTouch provider.
110
111 Field values are read from the provider's stored config (the frontend persists them);
112 the media search is only executed for ``refresh_preset_id`` (the preset whose
113 search/select button was just pressed) so a plain render never runs six searches.
114
115 :param provider: The SoundTouch provider whose stored config the entries are built from.
116 :param refresh_preset_id: Preset id whose search results should be (re)fetched, or None.
117 """
118 entries: list[ConfigEntry] = []
119 for preset_id in PRESET_IDS:
120 media_type_key = preset_media_type_key(preset_id)
121 search_key = preset_search_key(preset_id)
122 selected_key = preset_selected_media_key(preset_id)
123 media_key = preset_media_key(preset_id)
124 select_action = _preset_select_action(preset_id)
125
126 media_type = _media_type_config_value(provider, media_type_key)
127 query = _string_config_value(provider, search_key).strip()
128 selected_media = _string_config_value(provider, selected_key)
129
130 media_options = await _build_preset_media_options(
131 mass=provider.mass,
132 media_type=media_type,
133 query=query,
134 selected_media=selected_media,
135 refresh_results=preset_id == refresh_preset_id,
136 )
137
138 entries.append(
139 ConfigEntry(
140 key=f"{PRESET_KEY_PREFIX}{preset_id}_header",
141 type=ConfigEntryType.DIVIDER,
142 translation_key="preset_header",
143 translation_params=[str(preset_id)],
144 required=False,
145 category="presets",
146 )
147 )
148 entries.extend(
149 (
150 ConfigEntry(
151 key=media_type_key,
152 type=ConfigEntryType.STRING,
153 translation_key="preset_media_type",
154 translation_params=[str(preset_id)],
155 required=False,
156 default_value=DEFAULT_MEDIA_TYPE.value,
157 options=MEDIA_TYPE_OPTIONS,
158 category="presets",
159 ),
160 ConfigEntry(
161 key=search_key,
162 type=ConfigEntryType.STRING,
163 translation_key="preset_search",
164 translation_params=[str(preset_id)],
165 required=False,
166 default_value="",
167 category="presets",
168 ),
169 ConfigEntry(
170 key=f"{PRESET_KEY_PREFIX}{preset_id}_do_search",
171 type=ConfigEntryType.ACTION,
172 translation_key="preset_search_action",
173 translation_params=[str(preset_id)],
174 action=_preset_search_action(preset_id),
175 category="presets",
176 ),
177 )
178 )
179
180 if media_options:
181 entries.extend(
182 (
183 ConfigEntry(
184 key=selected_key,
185 type=ConfigEntryType.STRING,
186 translation_key="preset_result_selection",
187 translation_params=[str(preset_id)],
188 required=False,
189 default_value="",
190 options=media_options,
191 category="presets",
192 ),
193 ConfigEntry(
194 key=f"{PRESET_KEY_PREFIX}{preset_id}_do_select",
195 type=ConfigEntryType.ACTION,
196 translation_key="preset_select_action",
197 translation_params=[str(preset_id)],
198 action=select_action,
199 category="presets",
200 ),
201 )
202 )
203 entries.append(
204 ConfigEntry(
205 key=media_key,
206 type=ConfigEntryType.STRING,
207 translation_key="preset_media",
208 translation_params=[str(preset_id)],
209 required=False,
210 default_value="",
211 category="presets",
212 )
213 )
214
215 return entries
216
217
218def _preset_search_action(preset_id: int) -> str:
219 """Return the action id of the given preset's search button."""
220 return f"{PRESET_KEY_PREFIX}{preset_id}_search_media"
221
222
223def _preset_select_action(preset_id: int) -> str:
224 """Return the action id of the given preset's select button."""
225 return f"{PRESET_KEY_PREFIX}{preset_id}_select_media"
226
227
228async def _build_preset_media_options(
229 mass: MusicAssistant,
230 media_type: MediaType,
231 query: str,
232 selected_media: str,
233 refresh_results: bool,
234) -> list[ConfigValueOption]:
235 """Build the result dropdown for a preset without losing the current selection."""
236 media_options = await _build_media_options(mass, media_type, query) if refresh_results else []
237 if selected_media and selected_media not in {option.value for option in media_options}:
238 media_options.append(ConfigValueOption(title=selected_media, value=selected_media))
239 return media_options
240
241
242async def _build_media_options(
243 mass: MusicAssistant,
244 media_type: MediaType,
245 query: str,
246) -> list[ConfigValueOption]:
247 """Build dropdown options for a preset media search."""
248 options_by_value: dict[str, ConfigValueOption] = {}
249 for item in await _search_media_items(mass, media_type, query):
250 value = item.uri
251 if not value or value in options_by_value:
252 continue
253 options_by_value[value] = ConfigValueOption(
254 title=f"{item.name} ({item.media_type.value}, {item.provider})",
255 value=value,
256 )
257 return sorted(options_by_value.values(), key=lambda option: (option.title or "").lower())
258
259
260async def _search_media_items(
261 mass: MusicAssistant,
262 media_type: MediaType,
263 query: str,
264) -> list[SearchResultItem]:
265 """Search MA media items for preset config options."""
266 if not query or media_type not in SEARCHABLE_MEDIA_TYPES:
267 return []
268 try:
269 search_result = await asyncio.wait_for(
270 mass.music.search(
271 search_query=query,
272 media_types=[media_type],
273 limit=SEARCH_RESULT_LIMIT,
274 library_only=False,
275 ),
276 timeout=SEARCH_TIMEOUT,
277 )
278 except MusicAssistantError, TimeoutError:
279 return []
280 return _iter_search_result_items(search_result, media_type)
281
282
283def _iter_search_result_items(
284 search_result: SearchResults,
285 media_type: MediaType,
286) -> list[SearchResultItem]:
287 """Extract media items from a typed MA search result."""
288 match media_type:
289 case MediaType.ARTIST:
290 return list(search_result.artists)
291 case MediaType.ALBUM:
292 return list(search_result.albums)
293 case MediaType.GENRE:
294 return list(search_result.genres)
295 case MediaType.TRACK:
296 return list(search_result.tracks)
297 case MediaType.PLAYLIST:
298 return list(search_result.playlists)
299 case MediaType.RADIO:
300 return list(search_result.radio)
301 case MediaType.AUDIOBOOK:
302 return list(search_result.audiobooks)
303 case MediaType.PODCAST:
304 return list(search_result.podcasts)
305 case _:
306 return []
307
308
309def _string_config_value(provider: BoseSoundTouchProvider, key: str) -> str:
310 """Return a string config value from the provider's stored config (empty if unset)."""
311 value = provider.get_config_value(key)
312 return value if isinstance(value, str) else ""
313
314
315def _media_type_config_value(provider: BoseSoundTouchProvider, key: str) -> MediaType:
316 """Return a searchable media type from the provider's stored config."""
317 media_type = MediaType(_string_config_value(provider, key) or DEFAULT_MEDIA_TYPE.value)
318 return media_type if media_type in SEARCHABLE_MEDIA_TYPES else DEFAULT_MEDIA_TYPE
319