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