/
/
/
1"""Logic to handle storage of persistent (configuration) settings."""
2
3from __future__ import annotations
4
5import asyncio
6import base64
7import contextlib
8import logging
9import os
10from copy import deepcopy
11from typing import TYPE_CHECKING, Any, Literal, TypeVar, cast, overload
12from uuid import uuid4
13
14import aiofiles
15import shortuuid
16from aiofiles.os import wrap
17from cryptography.fernet import Fernet, InvalidToken
18from music_assistant_models import config_entries
19from music_assistant_models.config_entries import (
20 MULTI_VALUE_SPLITTER,
21 ConfigEntry,
22 ConfigValueOption,
23 ConfigValueType,
24 CoreConfig,
25 PlayerConfig,
26 ProviderConfig,
27)
28from music_assistant_models.constants import (
29 PLAYER_CONTROL_FAKE,
30 PLAYER_CONTROL_NATIVE,
31 PLAYER_CONTROL_NONE,
32)
33from music_assistant_models.dsp import DSPConfig, DSPConfigPreset
34from music_assistant_models.enums import (
35 ConfigEntryType,
36 EventType,
37 PlayerFeature,
38 PlayerType,
39 ProviderFeature,
40 ProviderType,
41)
42from music_assistant_models.errors import (
43 ActionUnavailable,
44 InvalidDataError,
45 UnsupportedFeaturedException,
46)
47
48from music_assistant.constants import (
49 CONF_CORE,
50 CONF_ENTRY_ANNOUNCE_VOLUME,
51 CONF_ENTRY_ANNOUNCE_VOLUME_MAX,
52 CONF_ENTRY_ANNOUNCE_VOLUME_MIN,
53 CONF_ENTRY_ANNOUNCE_VOLUME_STRATEGY,
54 CONF_ENTRY_AUTO_PLAY,
55 CONF_ENTRY_CROSSFADE_DURATION,
56 CONF_ENTRY_ENABLE_ICY_METADATA,
57 CONF_ENTRY_FLOW_MODE,
58 CONF_ENTRY_HTTP_PROFILE,
59 CONF_ENTRY_LIBRARY_SYNC_ALBUM_TRACKS,
60 CONF_ENTRY_LIBRARY_SYNC_ALBUMS,
61 CONF_ENTRY_LIBRARY_SYNC_ARTISTS,
62 CONF_ENTRY_LIBRARY_SYNC_AUDIOBOOKS,
63 CONF_ENTRY_LIBRARY_SYNC_BACK,
64 CONF_ENTRY_LIBRARY_SYNC_PLAYLIST_TRACKS,
65 CONF_ENTRY_LIBRARY_SYNC_PLAYLISTS,
66 CONF_ENTRY_LIBRARY_SYNC_PODCASTS,
67 CONF_ENTRY_LIBRARY_SYNC_RADIOS,
68 CONF_ENTRY_LIBRARY_SYNC_TRACKS,
69 CONF_ENTRY_OUTPUT_CHANNELS,
70 CONF_ENTRY_OUTPUT_CODEC,
71 CONF_ENTRY_OUTPUT_LIMITER,
72 CONF_ENTRY_PLAYER_ICON,
73 CONF_ENTRY_PLAYER_ICON_GROUP,
74 CONF_ENTRY_PROVIDER_SYNC_INTERVAL_ALBUMS,
75 CONF_ENTRY_PROVIDER_SYNC_INTERVAL_ARTISTS,
76 CONF_ENTRY_PROVIDER_SYNC_INTERVAL_AUDIOBOOKS,
77 CONF_ENTRY_PROVIDER_SYNC_INTERVAL_PLAYLISTS,
78 CONF_ENTRY_PROVIDER_SYNC_INTERVAL_PODCASTS,
79 CONF_ENTRY_PROVIDER_SYNC_INTERVAL_RADIOS,
80 CONF_ENTRY_PROVIDER_SYNC_INTERVAL_TRACKS,
81 CONF_ENTRY_SAMPLE_RATES,
82 CONF_ENTRY_SMART_FADES_MODE,
83 CONF_ENTRY_TTS_PRE_ANNOUNCE,
84 CONF_ENTRY_VOLUME_NORMALIZATION,
85 CONF_ENTRY_VOLUME_NORMALIZATION_TARGET,
86 CONF_EXPOSE_PLAYER_TO_HA,
87 CONF_HIDE_IN_UI,
88 CONF_MUTE_CONTROL,
89 CONF_ONBOARD_DONE,
90 CONF_PLAYER_DSP,
91 CONF_PLAYER_DSP_PRESETS,
92 CONF_PLAYERS,
93 CONF_POWER_CONTROL,
94 CONF_PRE_ANNOUNCE_CHIME_URL,
95 CONF_PROVIDERS,
96 CONF_SERVER_ID,
97 CONF_SMART_FADES_MODE,
98 CONF_VOLUME_CONTROL,
99 CONFIGURABLE_CORE_CONTROLLERS,
100 DEFAULT_CORE_CONFIG_ENTRIES,
101 DEFAULT_PROVIDER_CONFIG_ENTRIES,
102 ENCRYPT_SUFFIX,
103 NON_HTTP_PROVIDERS,
104)
105from music_assistant.controllers.players.sync_groups import SyncGroupPlayer
106from music_assistant.helpers.api import api_command
107from music_assistant.helpers.json import JSON_DECODE_EXCEPTIONS, async_json_dumps, async_json_loads
108from music_assistant.helpers.util import load_provider_module, validate_announcement_chime_url
109from music_assistant.models import ProviderModuleType
110from music_assistant.models.music_provider import MusicProvider
111
112if TYPE_CHECKING:
113 from music_assistant import MusicAssistant
114 from music_assistant.models.core_controller import CoreController
115 from music_assistant.models.player import Player
116
117LOGGER = logging.getLogger(__name__)
118DEFAULT_SAVE_DELAY = 5
119
120BASE_KEYS = ("enabled", "name", "available", "default_name", "provider", "type")
121
122# TypeVar for config value type inference
123_ConfigValueT = TypeVar("_ConfigValueT", bound=ConfigValueType)
124
125isfile = wrap(os.path.isfile)
126remove = wrap(os.remove)
127rename = wrap(os.rename)
128
129
130class ConfigController:
131 """Controller that handles storage of persistent configuration settings."""
132
133 _fernet: Fernet | None = None
134
135 def __init__(self, mass: MusicAssistant) -> None:
136 """Initialize storage controller."""
137 self.mass = mass
138 self.initialized = False
139 self._data: dict[str, Any] = {}
140 self.filename = os.path.join(self.mass.storage_path, "settings.json")
141 self._timer_handle: asyncio.TimerHandle | None = None
142 self._value_cache: dict[str, ConfigValueType] = {}
143
144 async def setup(self) -> None:
145 """Async initialize of controller."""
146 await self._load()
147 self.initialized = True
148 # create default server ID if needed (also used for encrypting passwords)
149 self.set_default(CONF_SERVER_ID, uuid4().hex)
150 server_id: str = self.get(CONF_SERVER_ID)
151 assert server_id
152 fernet_key = base64.urlsafe_b64encode(server_id.encode()[:32])
153 self._fernet = Fernet(fernet_key)
154 config_entries.ENCRYPT_CALLBACK = self.encrypt_string
155 config_entries.DECRYPT_CALLBACK = self.decrypt_string
156 if not self.onboard_done:
157 self.mass.register_api_command(
158 "config/onboard_complete",
159 self.set_onboard_complete,
160 authenticated=True,
161 alias=True, # hide from public API docs
162 )
163 LOGGER.debug("Started.")
164
165 @property
166 def onboard_done(self) -> bool:
167 """Return True if onboarding is done."""
168 return bool(self.get(CONF_ONBOARD_DONE, False))
169
170 async def set_onboard_complete(self) -> None:
171 """
172 Mark onboarding as complete.
173
174 This is called by the frontend after the user has completed the onboarding wizard.
175 Only available when onboarding is not yet complete.
176 """
177 if self.onboard_done:
178 msg = "Onboarding already completed"
179 raise InvalidDataError(msg)
180
181 self.set(CONF_ONBOARD_DONE, True)
182 self.save(immediate=True)
183 LOGGER.info("Onboarding completed")
184
185 async def close(self) -> None:
186 """Handle logic on server stop."""
187 if not self._timer_handle:
188 # no point in forcing a save when there are no changes pending
189 return
190 await self._async_save()
191 LOGGER.debug("Stopped.")
192
193 def get(self, key: str, default: Any = None) -> Any:
194 """Get value(s) for a specific key/path in persistent storage."""
195 assert self.initialized, "Not yet (async) initialized"
196 # we support a multi level hierarchy by providing the key as path,
197 # with a slash (/) as splitter. Sort that out here.
198 parent = self._data
199 subkeys = key.split("/")
200 for index, subkey in enumerate(subkeys):
201 if index == (len(subkeys) - 1):
202 value = parent.get(subkey, default)
203 if value is None:
204 # replace None with default
205 return default
206 return value
207 if subkey not in parent:
208 # requesting subkey from a non existing parent
209 return default
210 parent = parent[subkey]
211 return default
212
213 def set(self, key: str, value: Any) -> None:
214 """Set value(s) for a specific key/path in persistent storage."""
215 assert self.initialized, "Not yet (async) initialized"
216 # we support a multi level hierarchy by providing the key as path,
217 # with a slash (/) as splitter.
218 parent = self._data
219 subkeys = key.split("/")
220 for index, subkey in enumerate(subkeys):
221 if index == (len(subkeys) - 1):
222 parent[subkey] = value
223 else:
224 parent.setdefault(subkey, {})
225 parent = parent[subkey]
226 self.save()
227
228 def set_default(self, key: str, default_value: Any) -> None:
229 """Set default value(s) for a specific key/path in persistent storage."""
230 assert self.initialized, "Not yet (async) initialized"
231 cur_value = self.get(key, "__MISSING__")
232 if cur_value == "__MISSING__":
233 self.set(key, default_value)
234
235 def remove(
236 self,
237 key: str,
238 ) -> None:
239 """Remove value(s) for a specific key/path in persistent storage."""
240 assert self.initialized, "Not yet (async) initialized"
241 parent = self._data
242 subkeys = key.split("/")
243 for index, subkey in enumerate(subkeys):
244 if subkey not in parent:
245 return
246 if index == (len(subkeys) - 1):
247 parent.pop(subkey)
248 else:
249 parent.setdefault(subkey, {})
250 parent = parent[subkey]
251
252 self.save()
253
254 @api_command("config/providers")
255 async def get_provider_configs(
256 self,
257 provider_type: ProviderType | None = None,
258 provider_domain: str | None = None,
259 include_values: bool = False,
260 ) -> list[ProviderConfig]:
261 """Return all known provider configurations, optionally filtered by ProviderType."""
262 raw_values = self.get(CONF_PROVIDERS, {})
263 prov_entries = {x.domain for x in self.mass.get_provider_manifests()}
264 return [
265 await self.get_provider_config(prov_conf["instance_id"])
266 if include_values
267 else cast("ProviderConfig", ProviderConfig.parse([], prov_conf))
268 for prov_conf in raw_values.values()
269 if (provider_type is None or prov_conf["type"] == provider_type)
270 and (provider_domain is None or prov_conf["domain"] == provider_domain)
271 # guard for deleted providers
272 and prov_conf["domain"] in prov_entries
273 ]
274
275 @api_command("config/providers/get")
276 async def get_provider_config(self, instance_id: str) -> ProviderConfig:
277 """Return configuration for a single provider."""
278 if raw_conf := self.get(f"{CONF_PROVIDERS}/{instance_id}", {}):
279 config_entries = await self.get_provider_config_entries(
280 raw_conf["domain"],
281 instance_id=instance_id,
282 values=raw_conf.get("values"),
283 )
284 for prov in self.mass.get_provider_manifests():
285 if prov.domain == raw_conf["domain"]:
286 break
287 else:
288 msg = f"Unknown provider domain: {raw_conf['domain']}"
289 raise KeyError(msg)
290 return cast("ProviderConfig", ProviderConfig.parse(config_entries, raw_conf))
291 msg = f"No config found for provider id {instance_id}"
292 raise KeyError(msg)
293
294 @overload
295 async def get_provider_config_value(
296 self,
297 instance_id: str,
298 key: str,
299 *,
300 default: _ConfigValueT,
301 return_type: type[_ConfigValueT] = ...,
302 ) -> _ConfigValueT: ...
303
304 @overload
305 async def get_provider_config_value(
306 self,
307 instance_id: str,
308 key: str,
309 *,
310 default: ConfigValueType = ...,
311 return_type: type[_ConfigValueT] = ...,
312 ) -> _ConfigValueT: ...
313
314 @overload
315 async def get_provider_config_value(
316 self,
317 instance_id: str,
318 key: str,
319 *,
320 default: ConfigValueType = ...,
321 return_type: None = ...,
322 ) -> ConfigValueType: ...
323
324 @api_command("config/providers/get_value")
325 async def get_provider_config_value(
326 self,
327 instance_id: str,
328 key: str,
329 *,
330 default: ConfigValueType = None,
331 return_type: type[_ConfigValueT | ConfigValueType] | None = None,
332 ) -> _ConfigValueT | ConfigValueType:
333 """
334 Return single configentry value for a provider.
335
336 :param instance_id: The provider instance ID.
337 :param key: The config key to retrieve.
338 :param default: Optional default value to return if key is not found.
339 :param return_type: Optional type hint for type inference (e.g., str, int, bool).
340 Note: This parameter is used purely for static type checking and does not
341 perform runtime type validation. Callers are responsible for ensuring the
342 specified type matches the actual config value type.
343 """
344 cache_key = f"prov_conf_value_{instance_id}.{key}"
345 if (cached_value := self._value_cache.get(cache_key)) is not None:
346 return cached_value
347 conf = await self.get_provider_config(instance_id)
348 if key not in conf.values:
349 if default is not None:
350 return default
351 msg = f"Config key {key} not found for provider {instance_id}"
352 raise KeyError(msg)
353 val = (
354 conf.values[key].value
355 if conf.values[key].value is not None
356 else conf.values[key].default_value
357 )
358 # store value in cache because this method can potentially be called very often
359 self._value_cache[cache_key] = val
360 return val
361
362 @api_command("config/providers/get_entries")
363 async def get_provider_config_entries( # noqa: PLR0915
364 self,
365 provider_domain: str,
366 instance_id: str | None = None,
367 action: str | None = None,
368 values: dict[str, ConfigValueType] | None = None,
369 ) -> list[ConfigEntry]:
370 """
371 Return Config entries to setup/configure a provider.
372
373 provider_domain: (mandatory) domain of the provider.
374 instance_id: id of an existing provider instance (None for new instance setup).
375 action: [optional] action key called from config entries UI.
376 values: the (intermediate) raw values for config entries sent with the action.
377 """
378 # lookup provider manifest and module
379 prov_mod: ProviderModuleType | None
380 for manifest in self.mass.get_provider_manifests():
381 if manifest.domain == provider_domain:
382 try:
383 prov_mod = await load_provider_module(provider_domain, manifest.requirements)
384 except Exception as e:
385 msg = f"Failed to load provider module for {provider_domain}: {e}"
386 LOGGER.exception(msg)
387 return []
388 break
389 else:
390 msg = f"Unknown provider domain: {provider_domain}"
391 LOGGER.exception(msg)
392 return []
393
394 if values is None:
395 values = self.get(f"{CONF_PROVIDERS}/{instance_id}/values", {}) if instance_id else {}
396
397 # add dynamic optional config entries that depend on features
398 if instance_id and (provider := self.mass.get_provider(instance_id)):
399 supported_features = provider.supported_features
400 else:
401 provider = None
402 supported_features = getattr(prov_mod, "SUPPORTED_FEATURES", set())
403 extra_entries: list[ConfigEntry] = []
404 if manifest.type == ProviderType.MUSIC:
405 # library sync settings
406 if ProviderFeature.LIBRARY_ARTISTS in supported_features:
407 extra_entries.append(CONF_ENTRY_LIBRARY_SYNC_ARTISTS)
408 if ProviderFeature.LIBRARY_ALBUMS in supported_features:
409 extra_entries.append(CONF_ENTRY_LIBRARY_SYNC_ALBUMS)
410 if (
411 provider
412 and isinstance(provider, MusicProvider)
413 and provider.is_streaming_provider
414 ):
415 extra_entries.append(CONF_ENTRY_LIBRARY_SYNC_ALBUM_TRACKS)
416 if ProviderFeature.LIBRARY_TRACKS in supported_features:
417 extra_entries.append(CONF_ENTRY_LIBRARY_SYNC_TRACKS)
418 if ProviderFeature.LIBRARY_PLAYLISTS in supported_features:
419 extra_entries.append(CONF_ENTRY_LIBRARY_SYNC_PLAYLISTS)
420 if (
421 provider
422 and isinstance(provider, MusicProvider)
423 and provider.is_streaming_provider
424 ):
425 extra_entries.append(CONF_ENTRY_LIBRARY_SYNC_PLAYLIST_TRACKS)
426 if ProviderFeature.LIBRARY_AUDIOBOOKS in supported_features:
427 extra_entries.append(CONF_ENTRY_LIBRARY_SYNC_AUDIOBOOKS)
428 if ProviderFeature.LIBRARY_PODCASTS in supported_features:
429 extra_entries.append(CONF_ENTRY_LIBRARY_SYNC_PODCASTS)
430 if ProviderFeature.LIBRARY_RADIOS in supported_features:
431 extra_entries.append(CONF_ENTRY_LIBRARY_SYNC_RADIOS)
432 # sync interval settings
433 if ProviderFeature.LIBRARY_ARTISTS in supported_features:
434 extra_entries.append(CONF_ENTRY_PROVIDER_SYNC_INTERVAL_ARTISTS)
435 if ProviderFeature.LIBRARY_ALBUMS in supported_features:
436 extra_entries.append(CONF_ENTRY_PROVIDER_SYNC_INTERVAL_ALBUMS)
437 if ProviderFeature.LIBRARY_TRACKS in supported_features:
438 extra_entries.append(CONF_ENTRY_PROVIDER_SYNC_INTERVAL_TRACKS)
439 if ProviderFeature.LIBRARY_PLAYLISTS in supported_features:
440 extra_entries.append(CONF_ENTRY_PROVIDER_SYNC_INTERVAL_PLAYLISTS)
441 if ProviderFeature.LIBRARY_AUDIOBOOKS in supported_features:
442 extra_entries.append(CONF_ENTRY_PROVIDER_SYNC_INTERVAL_AUDIOBOOKS)
443 if ProviderFeature.LIBRARY_PODCASTS in supported_features:
444 extra_entries.append(CONF_ENTRY_PROVIDER_SYNC_INTERVAL_PODCASTS)
445 if ProviderFeature.LIBRARY_RADIOS in supported_features:
446 extra_entries.append(CONF_ENTRY_PROVIDER_SYNC_INTERVAL_RADIOS)
447 # sync export settings
448 if supported_features.intersection(
449 {
450 ProviderFeature.LIBRARY_ARTISTS_EDIT,
451 ProviderFeature.LIBRARY_ALBUMS_EDIT,
452 ProviderFeature.LIBRARY_TRACKS_EDIT,
453 ProviderFeature.LIBRARY_PLAYLISTS_EDIT,
454 ProviderFeature.LIBRARY_AUDIOBOOKS_EDIT,
455 ProviderFeature.LIBRARY_PODCASTS_EDIT,
456 ProviderFeature.LIBRARY_RADIOS_EDIT,
457 }
458 ):
459 extra_entries.append(CONF_ENTRY_LIBRARY_SYNC_BACK)
460
461 all_entries = [
462 *DEFAULT_PROVIDER_CONFIG_ENTRIES,
463 *extra_entries,
464 *await prov_mod.get_config_entries(
465 self.mass, instance_id=instance_id, action=action, values=values
466 ),
467 ]
468 # set current value from stored values
469 for entry in all_entries:
470 if entry.value is None:
471 entry.value = values.get(entry.key, None)
472 return all_entries
473
474 @api_command("config/providers/save", required_role="admin")
475 async def save_provider_config(
476 self,
477 provider_domain: str,
478 values: dict[str, ConfigValueType],
479 instance_id: str | None = None,
480 ) -> ProviderConfig:
481 """
482 Save Provider(instance) Config.
483
484 provider_domain: (mandatory) domain of the provider.
485 values: the raw values for config entries that need to be stored/updated.
486 instance_id: id of an existing provider instance (None for new instance setup).
487 """
488 if instance_id is not None:
489 config = await self._update_provider_config(instance_id, values)
490 else:
491 config = await self._add_provider_config(provider_domain, values)
492 # return full config, just in case
493 return await self.get_provider_config(config.instance_id)
494
495 @api_command("config/providers/remove", required_role="admin")
496 async def remove_provider_config(self, instance_id: str) -> None:
497 """Remove ProviderConfig."""
498 conf_key = f"{CONF_PROVIDERS}/{instance_id}"
499 existing = self.get(conf_key)
500 if not existing:
501 msg = f"Provider {instance_id} does not exist"
502 raise KeyError(msg)
503 prov_manifest = self.mass.get_provider_manifest(existing["domain"])
504 if prov_manifest.builtin:
505 msg = f"Builtin provider {prov_manifest.name} can not be removed."
506 raise RuntimeError(msg)
507 self.remove(conf_key)
508 await self.mass.unload_provider(instance_id, True)
509 if existing["type"] == "music":
510 # cleanup entries in library
511 await self.mass.music.cleanup_provider(instance_id)
512 if existing["type"] == "player":
513 # all players should already be removed by now through unload_provider
514 for player in list(self.mass.players):
515 if player.provider.instance_id != instance_id:
516 continue
517 self.mass.players.delete_player_config(player.player_id)
518 # cleanup remaining player configs
519 for player_conf in list(self.get(CONF_PLAYERS, {}).values()):
520 if player_conf["provider"] == instance_id:
521 self.remove(f"{CONF_PLAYERS}/{player_conf['player_id']}")
522
523 async def remove_provider_config_value(self, instance_id: str, key: str) -> None:
524 """Remove/reset single Provider config value."""
525 conf_key = f"{CONF_PROVIDERS}/{instance_id}/values/{key}"
526 existing = self.get(conf_key)
527 if not existing:
528 return
529 self.remove(conf_key)
530
531 @api_command("config/players")
532 async def get_player_configs(
533 self,
534 provider: str | None = None,
535 include_values: bool = False,
536 include_unavailable: bool = True,
537 include_disabled: bool = True,
538 ) -> list[PlayerConfig]:
539 """Return all known player configurations, optionally filtered by provider id."""
540 result: list[PlayerConfig] = []
541 for raw_conf in list(self.get(CONF_PLAYERS, {}).values()):
542 # optional provider filter
543 if provider is not None and raw_conf["provider"] != provider:
544 continue
545 # filter out unavailable players
546 # (unless disabled, otherwise there is no way to re-enable them)
547 player = self.mass.players.get(raw_conf["player_id"], False)
548 if (
549 not include_unavailable
550 and (not player or not player.available)
551 and raw_conf.get("enabled", True)
552 ):
553 continue
554 # filter out disabled players
555 if not include_disabled and not raw_conf.get("enabled", True):
556 continue
557 if include_values:
558 result.append(await self.get_player_config(raw_conf["player_id"]))
559 else:
560 raw_conf["default_name"] = (
561 player.display_name if player else raw_conf.get("default_name")
562 )
563 raw_conf["available"] = player.available if player else False
564 result.append(cast("PlayerConfig", PlayerConfig.parse([], raw_conf)))
565 return result
566
567 @api_command("config/players/get")
568 async def get_player_config(
569 self,
570 player_id: str,
571 action: str | None = None,
572 values: dict[str, ConfigValueType] | None = None,
573 ) -> PlayerConfig:
574 """Return (full) configuration for a single player."""
575 raw_conf: dict[str, Any]
576 if raw_conf := self.get(f"{CONF_PLAYERS}/{player_id}"):
577 if player := self.mass.players.get(player_id, False):
578 raw_conf["default_name"] = player.display_name
579 raw_conf["provider"] = player.provider.instance_id
580 # pass action and values to get_config_entries
581 if values is None:
582 values = raw_conf.get("values", {})
583 conf_entries = await self.get_player_config_entries(
584 player_id, action=action, values=values
585 )
586 else:
587 # handle unavailable player and/or provider
588 conf_entries = []
589 raw_conf["available"] = False
590 raw_conf["default_name"] = raw_conf.get("default_name") or raw_conf["player_id"]
591 return cast("PlayerConfig", PlayerConfig.parse(conf_entries, raw_conf))
592 msg = f"No config found for player id {player_id}"
593 raise KeyError(msg)
594
595 @api_command("config/players/get_entries")
596 async def get_player_config_entries(
597 self,
598 player_id: str,
599 action: str | None = None,
600 values: dict[str, ConfigValueType] | None = None,
601 ) -> list[ConfigEntry]:
602 """
603 Return Config entries to configure a player.
604
605 player_id: id of an existing player instance.
606 action: [optional] action key called from config entries UI.
607 values: the (intermediate) raw values for config entries sent with the action.
608 """
609 if not (player := self.mass.players.get(player_id, False)):
610 msg = f"Player {player_id} not found"
611 raise KeyError(msg)
612
613 if values is None:
614 values = self.get(f"{CONF_PLAYERS}/{player_id}/values", {})
615
616 player_entries = await player.get_config_entries(action=action, values=values)
617 default_entries = self._get_default_player_config_entries(player)
618 player_entries_keys = {entry.key for entry in player_entries}
619 all_entries = [
620 *player_entries,
621 # ignore default entries that were overridden by the player specific ones
622 *[x for x in default_entries if x.key not in player_entries_keys],
623 ]
624 # set current value from stored values
625 for entry in all_entries:
626 if entry.value is None:
627 entry.value = values.get(entry.key, None)
628 return all_entries
629
630 @overload
631 async def get_player_config_value(
632 self,
633 player_id: str,
634 key: str,
635 unpack_splitted_values: Literal[True],
636 *,
637 default: ConfigValueType = ...,
638 return_type: type[_ConfigValueT] | None = ...,
639 ) -> tuple[str, ...] | list[tuple[str, ...]]: ...
640
641 @overload
642 async def get_player_config_value(
643 self,
644 player_id: str,
645 key: str,
646 unpack_splitted_values: Literal[False] = False,
647 *,
648 default: _ConfigValueT,
649 return_type: type[_ConfigValueT] = ...,
650 ) -> _ConfigValueT: ...
651
652 @overload
653 async def get_player_config_value(
654 self,
655 player_id: str,
656 key: str,
657 unpack_splitted_values: Literal[False] = False,
658 *,
659 default: ConfigValueType = ...,
660 return_type: type[_ConfigValueT] = ...,
661 ) -> _ConfigValueT: ...
662
663 @overload
664 async def get_player_config_value(
665 self,
666 player_id: str,
667 key: str,
668 unpack_splitted_values: Literal[False] = False,
669 *,
670 default: ConfigValueType = ...,
671 return_type: None = ...,
672 ) -> ConfigValueType: ...
673
674 @api_command("config/players/get_value")
675 async def get_player_config_value(
676 self,
677 player_id: str,
678 key: str,
679 unpack_splitted_values: bool = False,
680 *,
681 default: ConfigValueType = None,
682 return_type: type[_ConfigValueT | ConfigValueType] | None = None,
683 ) -> _ConfigValueT | ConfigValueType | tuple[str, ...] | list[tuple[str, ...]]:
684 """
685 Return single configentry value for a player.
686
687 :param player_id: The player ID.
688 :param key: The config key to retrieve.
689 :param unpack_splitted_values: Whether to unpack multi-value config entries.
690 :param default: Optional default value to return if key is not found.
691 :param return_type: Optional type hint for type inference (e.g., str, int, bool).
692 Note: This parameter is used purely for static type checking and does not
693 perform runtime type validation. Callers are responsible for ensuring the
694 specified type matches the actual config value type.
695 """
696 conf = await self.get_player_config(player_id)
697 if key not in conf.values:
698 if default is not None:
699 return default
700 msg = f"Config key {key} not found for player {player_id}"
701 raise KeyError(msg)
702 if unpack_splitted_values:
703 return conf.values[key].get_splitted_values()
704 return (
705 conf.values[key].value
706 if conf.values[key].value is not None
707 else conf.values[key].default_value
708 )
709
710 if TYPE_CHECKING:
711 # Overload for when default is provided - return type matches default type
712 @overload
713 def get_raw_player_config_value(
714 self, player_id: str, key: str, default: _ConfigValueT
715 ) -> _ConfigValueT: ...
716
717 # Overload for when no default is provided - return ConfigValueType | None
718 @overload
719 def get_raw_player_config_value(
720 self, player_id: str, key: str, default: None = None
721 ) -> ConfigValueType | None: ...
722
723 def get_raw_player_config_value(
724 self, player_id: str, key: str, default: ConfigValueType = None
725 ) -> ConfigValueType:
726 """
727 Return (raw) single configentry value for a player.
728
729 Note that this only returns the stored value without any validation or default.
730 """
731 return cast(
732 "ConfigValueType",
733 self.get(
734 f"{CONF_PLAYERS}/{player_id}/values/{key}",
735 self.get(f"{CONF_PLAYERS}/{player_id}/{key}", default),
736 ),
737 )
738
739 def get_base_player_config(self, player_id: str, provider: str) -> PlayerConfig:
740 """
741 Return base PlayerConfig for a player.
742
743 This is used to get the base config for a player, without any provider specific values,
744 for initialization purposes.
745 """
746 if not (raw_conf := self.get(f"{CONF_PLAYERS}/{player_id}")):
747 raw_conf = {
748 "player_id": player_id,
749 "provider": provider,
750 }
751 return cast("PlayerConfig", PlayerConfig.parse([], raw_conf))
752
753 @api_command("config/players/save", required_role="admin")
754 async def save_player_config(
755 self, player_id: str, values: dict[str, ConfigValueType]
756 ) -> PlayerConfig:
757 """Save/update PlayerConfig."""
758 config = await self.get_player_config(player_id)
759 old_config = deepcopy(config)
760 changed_keys = config.update(values)
761 if not changed_keys:
762 # no changes
763 return config
764 # store updated config first (to prevent issues with enabling/disabling players)
765 conf_key = f"{CONF_PLAYERS}/{player_id}"
766 self.set(conf_key, config.to_raw())
767 try:
768 # validate/handle the update in the player manager
769 await self.mass.players.on_player_config_change(config, changed_keys)
770 except Exception:
771 # rollback on error
772 self.set(conf_key, old_config.to_raw())
773 raise
774 # send config updated event
775 self.mass.signal_event(
776 EventType.PLAYER_CONFIG_UPDATED,
777 object_id=config.player_id,
778 data=config,
779 )
780 # return full player config (just in case)
781 return await self.get_player_config(player_id)
782
783 @api_command("config/players/remove", required_role="admin")
784 async def remove_player_config(self, player_id: str) -> None:
785 """Remove PlayerConfig."""
786 conf_key = f"{CONF_PLAYERS}/{player_id}"
787 dsp_conf_key = f"{CONF_PLAYER_DSP}/{player_id}"
788 player_config = self.get(conf_key)
789 if not player_config:
790 msg = f"Player configuration for {player_id} does not exist"
791 raise KeyError(msg)
792 if self.mass.players.get(player_id):
793 try:
794 await self.mass.players.remove(player_id)
795 except UnsupportedFeaturedException:
796 # removing a player config while it is active is not allowed
797 # unless the provider reports it has the remove_player feature
798 raise ActionUnavailable("Can not remove config for an active player!")
799 # tell the player manager to remove the player if its lingering around
800 # set permanent to false otherwise we end up in an infinite loop
801 await self.mass.players.unregister(player_id, permanent=False)
802 # remove the actual config if all of the above passed
803 self.remove(conf_key)
804 # Also remove the DSP config if it exists
805 self.remove(dsp_conf_key)
806
807 def set_player_default_name(self, player_id: str, default_name: str) -> None:
808 """Set (or update) the default name for a player."""
809 conf_key = f"{CONF_PLAYERS}/{player_id}/default_name"
810 self.set(conf_key, default_name)
811
812 @api_command("config/players/dsp/get")
813 def get_player_dsp_config(self, player_id: str) -> DSPConfig:
814 """
815 Return the DSP Configuration for a player.
816
817 In case the player does not have a DSP configuration, a default one is returned.
818 """
819 if raw_conf := self.get(f"{CONF_PLAYER_DSP}/{player_id}"):
820 return DSPConfig.from_dict(raw_conf)
821 # return default DSP config
822 dsp_config = DSPConfig()
823 # The DSP config does not do anything by default, so we disable it
824 dsp_config.enabled = False
825 return dsp_config
826
827 @api_command("config/players/dsp/save", required_role="admin")
828 async def save_dsp_config(self, player_id: str, config: DSPConfig) -> DSPConfig:
829 """
830 Save/update DSPConfig for a player.
831
832 This method will validate the config and apply it to the player.
833 """
834 # validate the new config
835 config.validate()
836
837 # Save and apply the new config to the player
838 self.set(f"{CONF_PLAYER_DSP}/{player_id}", config.to_dict())
839 await self.mass.players.on_player_dsp_change(player_id)
840 # send the dsp config updated event
841 self.mass.signal_event(
842 EventType.PLAYER_DSP_CONFIG_UPDATED,
843 object_id=player_id,
844 data=config,
845 )
846 return config
847
848 @api_command("config/dsp_presets/get")
849 async def get_dsp_presets(self) -> list[DSPConfigPreset]:
850 """Return all user-defined DSP presets."""
851 raw_presets = self.get(CONF_PLAYER_DSP_PRESETS, {})
852 return [DSPConfigPreset.from_dict(preset) for preset in raw_presets.values()]
853
854 @api_command("config/dsp_presets/save", required_role="admin")
855 async def save_dsp_presets(self, preset: DSPConfigPreset) -> DSPConfigPreset:
856 """
857 Save/update a user-defined DSP presets.
858
859 This method will validate the config before saving it to the persistent storage.
860 """
861 preset.validate()
862
863 if preset.preset_id is None:
864 # Generate a new preset_id if it does not exist
865 preset.preset_id = shortuuid.random(8).lower()
866
867 # Save the preset to the persistent storage
868 self.set(f"{CONF_PLAYER_DSP_PRESETS}/preset_{preset.preset_id}", preset.to_dict())
869
870 all_presets = await self.get_dsp_presets()
871
872 self.mass.signal_event(
873 EventType.DSP_PRESETS_UPDATED,
874 data=all_presets,
875 )
876
877 return preset
878
879 @api_command("config/dsp_presets/remove", required_role="admin")
880 async def remove_dsp_preset(self, preset_id: str) -> None:
881 """Remove a user-defined DSP preset."""
882 self.mass.config.remove(f"{CONF_PLAYER_DSP_PRESETS}/preset_{preset_id}")
883
884 all_presets = await self.get_dsp_presets()
885
886 self.mass.signal_event(
887 EventType.DSP_PRESETS_UPDATED,
888 data=all_presets,
889 )
890
891 def create_default_player_config(
892 self,
893 player_id: str,
894 provider: str,
895 name: str | None = None,
896 enabled: bool = True,
897 values: dict[str, ConfigValueType] | None = None,
898 ) -> None:
899 """
900 Create default/empty PlayerConfig.
901
902 This is meant as helper to create default configs when a player is registered.
903 Called by the player manager on player register.
904 """
905 # return early if the config already exists
906 if self.get(f"{CONF_PLAYERS}/{player_id}"):
907 # update default name if needed
908 if name:
909 self.set(f"{CONF_PLAYERS}/{player_id}/default_name", name)
910 return
911 # config does not yet exist, create a default one
912 conf_key = f"{CONF_PLAYERS}/{player_id}"
913 default_conf = PlayerConfig(
914 values={},
915 provider=provider,
916 player_id=player_id,
917 enabled=enabled,
918 name=name,
919 default_name=name,
920 )
921 default_conf_raw = default_conf.to_raw()
922 if values is not None:
923 default_conf_raw["values"] = values
924 self.set(
925 conf_key,
926 default_conf_raw,
927 )
928
929 async def create_builtin_provider_config(self, provider_domain: str) -> None:
930 """
931 Create builtin ProviderConfig.
932
933 This is meant as helper to create default configs for builtin providers.
934 Called by the server initialization code which load all providers at startup.
935 """
936 for _ in await self.get_provider_configs(provider_domain=provider_domain):
937 # return if there is already any config
938 return
939 for prov in self.mass.get_provider_manifests():
940 if prov.domain == provider_domain:
941 manifest = prov
942 break
943 else:
944 msg = f"Unknown provider domain: {provider_domain}"
945 raise KeyError(msg)
946 config_entries = await self.get_provider_config_entries(provider_domain)
947 if manifest.multi_instance:
948 instance_id = f"{manifest.domain}--{shortuuid.random(8)}"
949 else:
950 instance_id = manifest.domain
951 default_config = cast(
952 "ProviderConfig",
953 ProviderConfig.parse(
954 config_entries,
955 {
956 "type": manifest.type.value,
957 "domain": manifest.domain,
958 "instance_id": instance_id,
959 "name": manifest.name,
960 # note: this will only work for providers that do
961 # not have any required config entries or provide defaults
962 "values": {},
963 },
964 ),
965 )
966 default_config.validate()
967 conf_key = f"{CONF_PROVIDERS}/{default_config.instance_id}"
968 self.set_default(conf_key, default_config.to_raw())
969
970 @api_command("config/core")
971 async def get_core_configs(self, include_values: bool = False) -> list[CoreConfig]:
972 """Return all core controllers config options."""
973 return [
974 await self.get_core_config(core_controller)
975 if include_values
976 else cast(
977 "CoreConfig",
978 CoreConfig.parse(
979 [],
980 self.get(f"{CONF_CORE}/{core_controller}", {"domain": core_controller}),
981 ),
982 )
983 for core_controller in CONFIGURABLE_CORE_CONTROLLERS
984 ]
985
986 @api_command("config/core/get")
987 async def get_core_config(self, domain: str) -> CoreConfig:
988 """Return configuration for a single core controller."""
989 raw_conf = self.get(f"{CONF_CORE}/{domain}", {"domain": domain})
990 config_entries = await self.get_core_config_entries(domain)
991 return cast("CoreConfig", CoreConfig.parse(config_entries, raw_conf))
992
993 @overload
994 async def get_core_config_value(
995 self,
996 domain: str,
997 key: str,
998 *,
999 default: _ConfigValueT,
1000 return_type: type[_ConfigValueT] = ...,
1001 ) -> _ConfigValueT: ...
1002
1003 @overload
1004 async def get_core_config_value(
1005 self,
1006 domain: str,
1007 key: str,
1008 *,
1009 default: ConfigValueType = ...,
1010 return_type: type[_ConfigValueT] = ...,
1011 ) -> _ConfigValueT: ...
1012
1013 @overload
1014 async def get_core_config_value(
1015 self,
1016 domain: str,
1017 key: str,
1018 *,
1019 default: ConfigValueType = ...,
1020 return_type: None = ...,
1021 ) -> ConfigValueType: ...
1022
1023 @api_command("config/core/get_value")
1024 async def get_core_config_value(
1025 self,
1026 domain: str,
1027 key: str,
1028 *,
1029 default: ConfigValueType = None,
1030 return_type: type[_ConfigValueT | ConfigValueType] | None = None,
1031 ) -> _ConfigValueT | ConfigValueType:
1032 """
1033 Return single configentry value for a core controller.
1034
1035 :param domain: The core controller domain.
1036 :param key: The config key to retrieve.
1037 :param default: Optional default value to return if key is not found.
1038 :param return_type: Optional type hint for type inference (e.g., str, int, bool).
1039 Note: This parameter is used purely for static type checking and does not
1040 perform runtime type validation. Callers are responsible for ensuring the
1041 specified type matches the actual config value type.
1042 """
1043 conf = await self.get_core_config(domain)
1044 if key not in conf.values:
1045 if default is not None:
1046 return default
1047 msg = f"Config key {key} not found for core controller {domain}"
1048 raise KeyError(msg)
1049 return (
1050 conf.values[key].value
1051 if conf.values[key].value is not None
1052 else conf.values[key].default_value
1053 )
1054
1055 @api_command("config/core/get_entries")
1056 async def get_core_config_entries(
1057 self,
1058 domain: str,
1059 action: str | None = None,
1060 values: dict[str, ConfigValueType] | None = None,
1061 ) -> list[ConfigEntry]:
1062 """
1063 Return Config entries to configure a core controller.
1064
1065 core_controller: name of the core controller
1066 action: [optional] action key called from config entries UI.
1067 values: the (intermediate) raw values for config entries sent with the action.
1068 """
1069 if values is None:
1070 values = self.get(f"{CONF_CORE}/{domain}/values", {})
1071 controller: CoreController = getattr(self.mass, domain)
1072 all_entries = list(
1073 await controller.get_config_entries(action=action, values=values)
1074 + DEFAULT_CORE_CONFIG_ENTRIES
1075 )
1076 # set current value from stored values
1077 for entry in all_entries:
1078 if entry.value is None:
1079 entry.value = values.get(entry.key, None)
1080 return all_entries
1081
1082 @api_command("config/core/save", required_role="admin")
1083 async def save_core_config(
1084 self,
1085 domain: str,
1086 values: dict[str, ConfigValueType],
1087 ) -> CoreConfig:
1088 """Save CoreController Config values."""
1089 config = await self.get_core_config(domain)
1090 prev_config = config.to_raw()
1091 changed_keys = config.update(values)
1092 # validate the new config
1093 config.validate()
1094 if not changed_keys:
1095 # no changes
1096 return config
1097 # save the config first before reloading to avoid issues on reload
1098 # for example when reloading the webserver we might be cancelled here
1099 conf_key = f"{CONF_CORE}/{domain}"
1100 self.set(conf_key, config.to_raw())
1101 self.save(immediate=True)
1102 try:
1103 controller: CoreController = getattr(self.mass, domain)
1104 await controller.update_config(config, changed_keys)
1105 except asyncio.CancelledError:
1106 pass
1107 except Exception:
1108 # revert to previous config on error
1109 self.set(conf_key, prev_config)
1110 self.save(immediate=True)
1111 raise
1112 # reload succeeded; clear last_error and persist the final state
1113 config.last_error = None
1114 # return full config
1115 return await self.get_core_config(domain)
1116
1117 if TYPE_CHECKING:
1118 # Overload for when default is provided - return type matches default type
1119 @overload
1120 def get_raw_core_config_value(
1121 self, core_module: str, key: str, default: _ConfigValueT
1122 ) -> _ConfigValueT: ...
1123
1124 # Overload for when no default is provided - return ConfigValueType | None
1125 @overload
1126 def get_raw_core_config_value(
1127 self, core_module: str, key: str, default: None = None
1128 ) -> ConfigValueType | None: ...
1129
1130 def get_raw_core_config_value(
1131 self, core_module: str, key: str, default: ConfigValueType = None
1132 ) -> ConfigValueType:
1133 """
1134 Return (raw) single configentry value for a core controller.
1135
1136 Note that this only returns the stored value without any validation or default.
1137 """
1138 return cast(
1139 "ConfigValueType",
1140 self.get(
1141 f"{CONF_CORE}/{core_module}/values/{key}",
1142 self.get(f"{CONF_CORE}/{core_module}/{key}", default),
1143 ),
1144 )
1145
1146 if TYPE_CHECKING:
1147 # Overload for when default is provided - return type matches default type
1148 @overload
1149 def get_raw_provider_config_value(
1150 self, provider_instance: str, key: str, default: _ConfigValueT
1151 ) -> _ConfigValueT: ...
1152
1153 # Overload for when no default is provided - return ConfigValueType | None
1154 @overload
1155 def get_raw_provider_config_value(
1156 self, provider_instance: str, key: str, default: None = None
1157 ) -> ConfigValueType | None: ...
1158
1159 def get_raw_provider_config_value(
1160 self, provider_instance: str, key: str, default: ConfigValueType = None
1161 ) -> ConfigValueType:
1162 """
1163 Return (raw) single config(entry) value for a provider.
1164
1165 Note that this only returns the stored value without any validation or default.
1166 """
1167 return cast(
1168 "ConfigValueType",
1169 self.get(
1170 f"{CONF_PROVIDERS}/{provider_instance}/values/{key}",
1171 self.get(f"{CONF_PROVIDERS}/{provider_instance}/{key}", default),
1172 ),
1173 )
1174
1175 def set_raw_provider_config_value(
1176 self,
1177 provider_instance: str,
1178 key: str,
1179 value: ConfigValueType,
1180 encrypted: bool = False,
1181 ) -> None:
1182 """
1183 Set (raw) single config(entry) value for a provider.
1184
1185 Note that this only stores the (raw) value without any validation or default.
1186 """
1187 if not self.get(f"{CONF_PROVIDERS}/{provider_instance}"):
1188 # only allow setting raw values if main entry exists
1189 msg = f"Invalid provider_instance: {provider_instance}"
1190 raise KeyError(msg)
1191 if encrypted:
1192 if not isinstance(value, str):
1193 msg = f"Cannot encrypt non-string value for key {key}"
1194 raise ValueError(msg)
1195 value = self.encrypt_string(value)
1196 if key in BASE_KEYS:
1197 self.set(f"{CONF_PROVIDERS}/{provider_instance}/{key}", value)
1198 return
1199 self.set(f"{CONF_PROVIDERS}/{provider_instance}/values/{key}", value)
1200
1201 def set_raw_core_config_value(self, core_module: str, key: str, value: ConfigValueType) -> None:
1202 """
1203 Set (raw) single config(entry) value for a core controller.
1204
1205 Note that this only stores the (raw) value without any validation or default.
1206 """
1207 if not self.get(f"{CONF_CORE}/{core_module}"):
1208 # create base object first if needed
1209 self.set(f"{CONF_CORE}/{core_module}", CoreConfig({}, core_module).to_raw())
1210 self.set(f"{CONF_CORE}/{core_module}/values/{key}", value)
1211
1212 def set_raw_player_config_value(self, player_id: str, key: str, value: ConfigValueType) -> None:
1213 """
1214 Set (raw) single config(entry) value for a player.
1215
1216 Note that this only stores the (raw) value without any validation or default.
1217 """
1218 if not self.get(f"{CONF_PLAYERS}/{player_id}"):
1219 # only allow setting raw values if main entry exists
1220 msg = f"Invalid player_id: {player_id}"
1221 raise KeyError(msg)
1222 if key in BASE_KEYS:
1223 self.set(f"{CONF_PLAYERS}/{player_id}/{key}", value)
1224 else:
1225 self.set(f"{CONF_PLAYERS}/{player_id}/values/{key}", value)
1226
1227 def save(self, immediate: bool = False) -> None:
1228 """Schedule save of data to disk."""
1229 self._value_cache = {}
1230 if self._timer_handle is not None:
1231 self._timer_handle.cancel()
1232 self._timer_handle = None
1233
1234 if immediate:
1235 self.mass.loop.create_task(self._async_save())
1236 else:
1237 # schedule the save for later
1238 self._timer_handle = self.mass.loop.call_later(
1239 DEFAULT_SAVE_DELAY, self.mass.create_task, self._async_save
1240 )
1241
1242 def encrypt_string(self, str_value: str) -> str:
1243 """Encrypt a (password)string with Fernet."""
1244 if str_value.startswith(ENCRYPT_SUFFIX):
1245 return str_value
1246 assert self._fernet is not None
1247 return ENCRYPT_SUFFIX + self._fernet.encrypt(str_value.encode()).decode()
1248
1249 def decrypt_string(self, encrypted_str: str) -> str:
1250 """Decrypt a (password)string with Fernet."""
1251 if not encrypted_str:
1252 return encrypted_str
1253 if not encrypted_str.startswith(ENCRYPT_SUFFIX):
1254 return encrypted_str
1255 assert self._fernet is not None
1256 try:
1257 return self._fernet.decrypt(encrypted_str.replace(ENCRYPT_SUFFIX, "").encode()).decode()
1258 except InvalidToken as err:
1259 msg = "Password decryption failed"
1260 raise InvalidDataError(msg) from err
1261
1262 async def _load(self) -> None:
1263 """Load data from persistent storage."""
1264 assert not self._data, "Already loaded"
1265
1266 for filename in (self.filename, f"{self.filename}.backup"):
1267 try:
1268 async with aiofiles.open(filename, encoding="utf-8") as _file:
1269 self._data = await async_json_loads(await _file.read())
1270 LOGGER.debug("Loaded persistent settings from %s", filename)
1271 await self._migrate()
1272 return
1273 except FileNotFoundError:
1274 pass
1275 except JSON_DECODE_EXCEPTIONS:
1276 LOGGER.exception("Error while reading persistent storage file %s", filename)
1277 LOGGER.debug("Started with empty storage: No persistent storage file found.")
1278
1279 async def _migrate(self) -> None: # noqa: PLR0915
1280 changed = False
1281
1282 # some type hints to help with the code below
1283 instance_id: str
1284 provider_config: dict[str, Any]
1285 player_config: dict[str, Any]
1286
1287 # Older versions of MA can create corrupt entries with no domain if retrying
1288 # logic runs after a provider has been removed. Remove those corrupt entries.
1289 for instance_id, provider_config in {**self._data.get(CONF_PROVIDERS, {})}.items():
1290 if "domain" not in provider_config:
1291 self._data[CONF_PROVIDERS].pop(instance_id, None)
1292 LOGGER.warning("Removed corrupt provider configuration: %s", instance_id)
1293 changed = True
1294
1295 # migrate manual_ips to new format
1296 for instance_id, provider_config in self._data.get(CONF_PROVIDERS, {}).items():
1297 if not (values := provider_config.get("values")):
1298 continue
1299 if not (ips := values.get("ips")):
1300 continue
1301 values["manual_discovery_ip_addresses"] = ips.split(",")
1302 del values["ips"]
1303 changed = True
1304
1305 # migrate sample_rates config entry
1306 for player_config in self._data.get(CONF_PLAYERS, {}).values():
1307 if not (values := player_config.get("values")):
1308 continue
1309 if not (sample_rates := values.get("sample_rates")):
1310 continue
1311 if not isinstance(sample_rates, list):
1312 del player_config["values"]["sample_rates"]
1313 if not any(isinstance(x, list) for x in sample_rates):
1314 continue
1315 player_config["values"]["sample_rates"] = [
1316 f"{x[0]}{MULTI_VALUE_SPLITTER}{x[1]}" if isinstance(x, list) else x
1317 for x in sample_rates
1318 ]
1319 changed = True
1320
1321 # migrate player_group entries
1322 ugp_found = False
1323 for player_config in self._data.get(CONF_PLAYERS, {}).values():
1324 provider = player_config.get("provider")
1325 if (
1326 not provider
1327 or not isinstance(provider, str)
1328 or not provider.startswith("player_group")
1329 ):
1330 continue
1331 if not (values := player_config.get("values")):
1332 continue
1333 if (group_type := values.pop("group_type", None)) is None:
1334 continue
1335 # this is a legacy player group, migrate the values
1336 changed = True
1337 if group_type == "universal":
1338 player_config["provider"] = "universal_group"
1339 ugp_found = True
1340 else:
1341 player_config["provider"] = group_type
1342 for provider_config in list(self._data.get(CONF_PROVIDERS, {}).values()):
1343 instance_id = provider_config["instance_id"]
1344 if not instance_id.startswith("player_group"):
1345 continue
1346 # this is the legacy player_group provider, migrate into 'universal_group'
1347 changed = True
1348 self._data[CONF_PROVIDERS].pop(instance_id, None)
1349 if not ugp_found:
1350 continue
1351 provider_config["domain"] = "universal_group"
1352 provider_config["instance_id"] = "universal_group"
1353 self._data[CONF_PROVIDERS]["universal_group"] = provider_config
1354
1355 # Migrate resonate provider to sendspin (renamed in 2.7 beta 19)
1356 for instance_id, provider_config in list(self._data.get(CONF_PROVIDERS, {}).items()):
1357 if provider_config.get("domain") == "resonate":
1358 self._data[CONF_PROVIDERS].pop(instance_id, None)
1359 provider_config["domain"] = "sendspin"
1360 provider_config["instance_id"] = "sendspin"
1361 self._data[CONF_PROVIDERS]["sendspin"] = provider_config
1362 changed = True
1363
1364 # Migrate smart_fades mode value to smart_crossfade
1365 for player_config in self._data.get(CONF_PLAYERS, {}).values():
1366 if not (values := player_config.get("values")):
1367 continue
1368 if values.get(CONF_SMART_FADES_MODE) == "smart_fades":
1369 # Update old 'smart_fades' value to new 'smart_crossfade' value
1370 values[CONF_SMART_FADES_MODE] = "smart_crossfade"
1371 changed = True
1372
1373 # Remove obsolete builtin_player configurations (provider was deleted in 2.7)
1374 for player_id, player_config in list(self._data.get(CONF_PLAYERS, {}).items()):
1375 if player_config.get("provider") != "builtin_player":
1376 continue
1377 self._data[CONF_PLAYERS].pop(player_id, None)
1378 # Also remove any DSP config for this player
1379 if CONF_PLAYER_DSP in self._data:
1380 self._data[CONF_PLAYER_DSP].pop(player_id, None)
1381 LOGGER.warning("Removed obsolete builtin_player configuration: %s", player_id)
1382 changed = True
1383
1384 # migrate player configs: always use instance_id for provider
1385 for player_config in self._data.get(CONF_PLAYERS, {}).values():
1386 if "provider" not in player_config:
1387 continue
1388 player_provider = player_config["provider"]
1389 try:
1390 if not (prov := self.mass.get_provider(player_provider)):
1391 continue
1392 except KeyError:
1393 # removed provider
1394 continue
1395 if player_config["provider"] == prov.instance_id:
1396 continue
1397 player_config["provider"] = prov.instance_id
1398 changed = True
1399
1400 # Migrate AirPlay legacy credentials (ap_credentials) to protocol-specific keys
1401 # The old key was used for both RAOP and AirPlay, now we have separate keys
1402 for player_id, player_config in self._data.get(CONF_PLAYERS, {}).items():
1403 if player_config.get("provider") != "airplay":
1404 continue
1405 if not (values := player_config.get("values")):
1406 continue
1407 if "ap_credentials" not in values:
1408 continue
1409 # Migrate to raop_credentials (RAOP is the default/fallback protocol)
1410 # The new code will use the correct key based on the protocol
1411 old_creds = values.pop("ap_credentials")
1412 if old_creds and "raop_credentials" not in values:
1413 values["raop_credentials"] = old_creds
1414 LOGGER.info("Migrated AirPlay credentials for player %s", player_id)
1415 changed = True
1416
1417 if changed:
1418 await self._async_save()
1419
1420 async def _async_save(self) -> None:
1421 """Save persistent data to disk."""
1422 filename_backup = f"{self.filename}.backup"
1423 # make backup before we write a new file
1424 if await isfile(self.filename):
1425 with contextlib.suppress(FileNotFoundError):
1426 await remove(filename_backup)
1427 await rename(self.filename, filename_backup)
1428
1429 async with aiofiles.open(self.filename, "w", encoding="utf-8") as _file:
1430 await _file.write(await async_json_dumps(self._data, indent=True))
1431 LOGGER.debug("Saved data to persistent storage")
1432
1433 @api_command("config/providers/reload", required_role="admin")
1434 async def _reload_provider(self, instance_id: str) -> None:
1435 """Reload provider."""
1436 try:
1437 config = await self.get_provider_config(instance_id)
1438 except KeyError:
1439 # Edge case: Provider was removed before we could reload it
1440 return
1441 await self.mass.load_provider_config(config)
1442
1443 async def _update_provider_config(
1444 self, instance_id: str, values: dict[str, ConfigValueType]
1445 ) -> ProviderConfig:
1446 """Update ProviderConfig."""
1447 config = await self.get_provider_config(instance_id)
1448 changed_keys = config.update(values)
1449 prov_instance = self.mass.get_provider(instance_id)
1450 available = prov_instance.available if prov_instance else False
1451 if not changed_keys and (config.enabled == available):
1452 # no changes
1453 return config
1454 # validate the new config
1455 config.validate()
1456 # save the config first to prevent issues when the
1457 # provider wants to manipulate the config during load
1458 conf_key = f"{CONF_PROVIDERS}/{config.instance_id}"
1459 raw_conf = config.to_raw()
1460 self.set(conf_key, raw_conf)
1461 if config.enabled and prov_instance is None:
1462 await self.mass.load_provider_config(config)
1463 if config.enabled and prov_instance and available:
1464 # update config for existing/loaded provider instance
1465 await prov_instance.update_config(config, changed_keys)
1466 elif config.enabled:
1467 # provider is enabled but not available, try to load it
1468 await self.mass.load_provider_config(config)
1469 else:
1470 # disable provider
1471 prov_manifest = self.mass.get_provider_manifest(config.domain)
1472 if not prov_manifest.allow_disable:
1473 msg = "Provider can not be disabled."
1474 raise RuntimeError(msg)
1475 # also unload any other providers dependent of this provider
1476 for dep_prov in self.mass.providers:
1477 if dep_prov.manifest.depends_on == config.domain:
1478 await self.mass.unload_provider(dep_prov.instance_id)
1479 await self.mass.unload_provider(config.instance_id)
1480 # For player providers, unload_provider should have removed all its players by now
1481 return config
1482
1483 async def _add_provider_config(
1484 self,
1485 provider_domain: str,
1486 values: dict[str, ConfigValueType],
1487 ) -> ProviderConfig:
1488 """
1489 Add new Provider (instance).
1490
1491 params:
1492 - provider_domain: domain of the provider for which to add an instance of.
1493 - values: the raw values for config entries.
1494
1495 Returns: newly created ProviderConfig.
1496 """
1497 # lookup provider manifest and module
1498 for prov in self.mass.get_provider_manifests():
1499 if prov.domain == provider_domain:
1500 manifest = prov
1501 break
1502 else:
1503 msg = f"Unknown provider domain: {provider_domain}"
1504 raise KeyError(msg)
1505 if prov.depends_on and not self.mass.get_provider(prov.depends_on):
1506 msg = f"Provider {manifest.name} depends on {prov.depends_on}"
1507 raise ValueError(msg)
1508 # create new provider config with given values
1509 existing = {
1510 x.instance_id for x in await self.get_provider_configs(provider_domain=provider_domain)
1511 }
1512 # determine instance id based on previous configs
1513 if existing and not manifest.multi_instance:
1514 msg = f"Provider {manifest.name} does not support multiple instances"
1515 raise ValueError(msg)
1516 if manifest.multi_instance:
1517 instance_id = f"{manifest.domain}--{shortuuid.random(8)}"
1518 else:
1519 instance_id = manifest.domain
1520 # all checks passed, create config object
1521 config_entries = await self.get_provider_config_entries(
1522 provider_domain=provider_domain, instance_id=instance_id, values=values
1523 )
1524 config = cast(
1525 "ProviderConfig",
1526 ProviderConfig.parse(
1527 config_entries,
1528 {
1529 "type": manifest.type.value,
1530 "domain": manifest.domain,
1531 "instance_id": instance_id,
1532 "default_name": manifest.name,
1533 "values": values,
1534 },
1535 ),
1536 )
1537 # validate the new config
1538 config.validate()
1539 # save the config first to prevent issues when the
1540 # provider wants to manipulate the config during load
1541 conf_key = f"{CONF_PROVIDERS}/{config.instance_id}"
1542 self.set(conf_key, config.to_raw())
1543 # try to load the provider
1544 try:
1545 await self.mass.load_provider_config(config)
1546 except Exception:
1547 # loading failed, remove config
1548 self.remove(conf_key)
1549 raise
1550 if not self.onboard_done:
1551 # mark onboard as complete as soon as the first provider is added
1552 await self.set_onboard_complete()
1553 if manifest.type == ProviderType.MUSIC:
1554 # correct any multi-instance provider mappings
1555 self.mass.create_task(self.mass.music.correct_multi_instance_provider_mappings())
1556 return config
1557
1558 def _get_default_player_config_entries(self, player: Player) -> list[ConfigEntry]:
1559 """Return the default player config entries."""
1560 entries: list[ConfigEntry] = []
1561 # default protocol-player config entries
1562 if player.type == PlayerType.PROTOCOL:
1563 # bare minimum: only playback related entries
1564 entries += [
1565 CONF_ENTRY_OUTPUT_CHANNELS,
1566 ]
1567 if not player.requires_flow_mode:
1568 entries.append(CONF_ENTRY_FLOW_MODE)
1569 if player.provider.domain not in NON_HTTP_PROVIDERS:
1570 entries += [
1571 CONF_ENTRY_SAMPLE_RATES,
1572 CONF_ENTRY_OUTPUT_CODEC,
1573 CONF_ENTRY_HTTP_PROFILE,
1574 CONF_ENTRY_ENABLE_ICY_METADATA,
1575 ]
1576 return entries
1577
1578 # some base entries for all player types
1579 entries += [
1580 CONF_ENTRY_SMART_FADES_MODE,
1581 CONF_ENTRY_CROSSFADE_DURATION,
1582 CONF_ENTRY_VOLUME_NORMALIZATION,
1583 CONF_ENTRY_OUTPUT_LIMITER,
1584 CONF_ENTRY_VOLUME_NORMALIZATION_TARGET,
1585 CONF_ENTRY_TTS_PRE_ANNOUNCE,
1586 ConfigEntry(
1587 key=CONF_PRE_ANNOUNCE_CHIME_URL,
1588 type=ConfigEntryType.STRING,
1589 label="Custom (pre)announcement chime URL",
1590 description="URL to a custom audio file to play before announcements.\n"
1591 "Leave empty to use the default chime.\n"
1592 "Supports http:// and https:// URLs pointing to "
1593 "audio files (.mp3, .wav, .flac, .ogg, .m4a, .aac).\n"
1594 "Example: http://homeassistant.local:8123/local/audio/custom_chime.mp3",
1595 category="announcements",
1596 required=False,
1597 depends_on=CONF_ENTRY_TTS_PRE_ANNOUNCE.key,
1598 depends_on_value=True,
1599 validate=lambda val: validate_announcement_chime_url(cast("str", val)),
1600 ),
1601 # add player control entries
1602 *self._create_player_control_config_entries(player),
1603 # add entry to hide player in UI
1604 ConfigEntry(
1605 key=CONF_HIDE_IN_UI,
1606 type=ConfigEntryType.BOOLEAN,
1607 label="Hide this player in the user interface",
1608 default_value=player.hidden_by_default,
1609 category="advanced",
1610 ),
1611 # add entry to expose player to HA
1612 ConfigEntry(
1613 key=CONF_EXPOSE_PLAYER_TO_HA,
1614 type=ConfigEntryType.BOOLEAN,
1615 label="Expose this player to Home Assistant",
1616 description="Expose this player to the Home Assistant integration. \n"
1617 "If disabled, this player will not be imported into Home Assistant.",
1618 category="advanced",
1619 default_value=player.expose_to_ha_by_default,
1620 ),
1621 ]
1622
1623 # group-player config entries
1624 if player.type == PlayerType.GROUP:
1625 is_dedicated_group_player = (
1626 not isinstance(player, SyncGroupPlayer)
1627 and player.provider.domain != "universal_group"
1628 )
1629 entries += [
1630 CONF_ENTRY_PLAYER_ICON_GROUP,
1631 ]
1632 if is_dedicated_group_player and not player.requires_flow_mode:
1633 entries.append(CONF_ENTRY_FLOW_MODE)
1634 if is_dedicated_group_player and player.provider.domain not in NON_HTTP_PROVIDERS:
1635 entries += [
1636 CONF_ENTRY_SAMPLE_RATES,
1637 CONF_ENTRY_OUTPUT_CODEC,
1638 CONF_ENTRY_HTTP_PROFILE,
1639 CONF_ENTRY_ENABLE_ICY_METADATA,
1640 ]
1641 return entries
1642
1643 # normal player (or stereo pair) config entries
1644 entries += [
1645 CONF_ENTRY_PLAYER_ICON,
1646 CONF_ENTRY_OUTPUT_CHANNELS,
1647 # add default entries for announce feature
1648 CONF_ENTRY_ANNOUNCE_VOLUME_STRATEGY,
1649 CONF_ENTRY_ANNOUNCE_VOLUME,
1650 CONF_ENTRY_ANNOUNCE_VOLUME_MIN,
1651 CONF_ENTRY_ANNOUNCE_VOLUME_MAX,
1652 ]
1653 # add flow mode config entry for players that not already explicitly enable it
1654 if not player.requires_flow_mode:
1655 entries.append(CONF_ENTRY_FLOW_MODE)
1656 # add HTTP streaming config entries for non-http players
1657 if player.provider.domain not in NON_HTTP_PROVIDERS:
1658 entries += [
1659 CONF_ENTRY_SAMPLE_RATES,
1660 CONF_ENTRY_OUTPUT_CODEC,
1661 CONF_ENTRY_HTTP_PROFILE,
1662 CONF_ENTRY_ENABLE_ICY_METADATA,
1663 ]
1664
1665 return entries
1666
1667 def _create_player_control_config_entries(self, player: Player) -> list[ConfigEntry]:
1668 """Create config entries for player controls."""
1669 all_controls = self.mass.players.player_controls()
1670 power_controls = [x for x in all_controls if x.supports_power]
1671 volume_controls = [x for x in all_controls if x.supports_volume]
1672 mute_controls = [x for x in all_controls if x.supports_mute]
1673 # work out player supported features
1674 supports_power = PlayerFeature.POWER in player.supported_features
1675 supports_volume = PlayerFeature.VOLUME_SET in player.supported_features
1676 supports_mute = PlayerFeature.VOLUME_MUTE in player.supported_features
1677 # create base options per control type (and add defaults like native and fake)
1678 base_power_options: list[ConfigValueOption] = [
1679 ConfigValueOption(title="None", value=PLAYER_CONTROL_NONE),
1680 ConfigValueOption(title="Fake power control", value=PLAYER_CONTROL_FAKE),
1681 ]
1682 if supports_power:
1683 base_power_options.append(
1684 ConfigValueOption(title="Native power control", value=PLAYER_CONTROL_NATIVE),
1685 )
1686 base_volume_options: list[ConfigValueOption] = [
1687 ConfigValueOption(title="None", value=PLAYER_CONTROL_NONE),
1688 ]
1689 if supports_volume:
1690 base_volume_options.append(
1691 ConfigValueOption(title="Native volume control", value=PLAYER_CONTROL_NATIVE),
1692 )
1693 base_mute_options: list[ConfigValueOption] = [
1694 ConfigValueOption(title="None", value=PLAYER_CONTROL_NONE),
1695 ConfigValueOption(title="Fake mute control", value=PLAYER_CONTROL_FAKE),
1696 ]
1697 if supports_mute:
1698 base_mute_options.append(
1699 ConfigValueOption(title="Native mute control", value=PLAYER_CONTROL_NATIVE),
1700 )
1701 # return final config entries for all options
1702 return [
1703 # Power control config entry
1704 ConfigEntry(
1705 key=CONF_POWER_CONTROL,
1706 type=ConfigEntryType.STRING,
1707 label="Power Control",
1708 default_value=PLAYER_CONTROL_NATIVE if supports_power else PLAYER_CONTROL_NONE,
1709 required=True,
1710 options=[
1711 *base_power_options,
1712 *(ConfigValueOption(x.name, x.id) for x in power_controls),
1713 ],
1714 category="player_controls",
1715 hidden=player.type == PlayerType.GROUP,
1716 ),
1717 # Volume control config entry
1718 ConfigEntry(
1719 key=CONF_VOLUME_CONTROL,
1720 type=ConfigEntryType.STRING,
1721 label="Volume Control",
1722 default_value=PLAYER_CONTROL_NATIVE if supports_volume else PLAYER_CONTROL_NONE,
1723 required=True,
1724 options=[
1725 *base_volume_options,
1726 *(ConfigValueOption(x.name, x.id) for x in volume_controls),
1727 ],
1728 category="player_controls",
1729 hidden=player.type == PlayerType.GROUP,
1730 ),
1731 # Mute control config entry
1732 ConfigEntry(
1733 key=CONF_MUTE_CONTROL,
1734 type=ConfigEntryType.STRING,
1735 label="Mute Control",
1736 default_value=PLAYER_CONTROL_NATIVE if supports_mute else PLAYER_CONTROL_NONE,
1737 required=True,
1738 options=[
1739 *base_mute_options,
1740 *[ConfigValueOption(x.name, x.id) for x in mute_controls],
1741 ],
1742 category="player_controls",
1743 hidden=player.type == PlayerType.GROUP,
1744 ),
1745 # auto-play on power on control config entry
1746 CONF_ENTRY_AUTO_PLAY,
1747 ]
1748