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