/
/
/
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 # note that we only check for missing players in the player controller,
550 # and we do allow players that are temporary unavailable (player.available = false)
551 # because this can also mean that the player needs additional configuration
552 # such as airplay devices that need pairing.
553 player = self.mass.players.get(raw_conf["player_id"], False)
554 if not include_unavailable and player is None and raw_conf.get("enabled", True):
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 # Remove corrupt player configurations that are missing the required 'provider' key
1407 for player_id, player_config in list(self._data.get(CONF_PLAYERS, {}).items()):
1408 if "provider" in player_config:
1409 continue
1410 self._data[CONF_PLAYERS].pop(player_id, None)
1411 # Also remove any DSP config for this player
1412 if CONF_PLAYER_DSP in self._data:
1413 self._data[CONF_PLAYER_DSP].pop(player_id, None)
1414 LOGGER.warning("Removed corrupt player configuration (missing provider): %s", player_id)
1415 changed = True
1416
1417 # migrate player configs: always use instance_id for provider
1418 for player_config in self._data.get(CONF_PLAYERS, {}).values():
1419 if "provider" not in player_config:
1420 continue
1421 player_provider = player_config["provider"]
1422 try:
1423 if not (prov := self.mass.get_provider(player_provider)):
1424 continue
1425 except KeyError:
1426 # removed provider
1427 continue
1428 if player_config["provider"] == prov.instance_id:
1429 continue
1430 player_config["provider"] = prov.instance_id
1431 changed = True
1432
1433 # Migrate AirPlay legacy credentials (ap_credentials) to protocol-specific keys
1434 # The old key was used for both RAOP and AirPlay, now we have separate keys
1435 for player_id, player_config in self._data.get(CONF_PLAYERS, {}).items():
1436 if player_config.get("provider") != "airplay":
1437 continue
1438 if not (values := player_config.get("values")):
1439 continue
1440 if "ap_credentials" not in values:
1441 continue
1442 # Migrate to raop_credentials (RAOP is the default/fallback protocol)
1443 # The new code will use the correct key based on the protocol
1444 old_creds = values.pop("ap_credentials")
1445 if old_creds and "raop_credentials" not in values:
1446 values["raop_credentials"] = old_creds
1447 LOGGER.info("Migrated AirPlay credentials for player %s", player_id)
1448 changed = True
1449
1450 if changed:
1451 await self._async_save()
1452
1453 async def _async_save(self) -> None:
1454 """Save persistent data to disk."""
1455 filename_backup = f"{self.filename}.backup"
1456 # make backup before we write a new file
1457 if await isfile(self.filename):
1458 with contextlib.suppress(FileNotFoundError):
1459 await remove(filename_backup)
1460 await rename(self.filename, filename_backup)
1461
1462 async with aiofiles.open(self.filename, "w", encoding="utf-8") as _file:
1463 await _file.write(await async_json_dumps(self._data, indent=True))
1464 LOGGER.debug("Saved data to persistent storage")
1465
1466 @api_command("config/providers/reload", required_role="admin")
1467 async def _reload_provider(self, instance_id: str) -> None:
1468 """Reload provider."""
1469 try:
1470 config = await self.get_provider_config(instance_id)
1471 except KeyError:
1472 # Edge case: Provider was removed before we could reload it
1473 return
1474 await self.mass.load_provider_config(config)
1475
1476 async def _update_provider_config(
1477 self, instance_id: str, values: dict[str, ConfigValueType]
1478 ) -> ProviderConfig:
1479 """Update ProviderConfig."""
1480 config = await self.get_provider_config(instance_id)
1481 changed_keys = config.update(values)
1482 prov_instance = self.mass.get_provider(instance_id)
1483 available = prov_instance.available if prov_instance else False
1484 if not changed_keys and (config.enabled == available):
1485 # no changes
1486 return config
1487 # validate the new config
1488 config.validate()
1489 # save the config first to prevent issues when the
1490 # provider wants to manipulate the config during load
1491 conf_key = f"{CONF_PROVIDERS}/{config.instance_id}"
1492 raw_conf = config.to_raw()
1493 self.set(conf_key, raw_conf)
1494 if config.enabled and prov_instance is None:
1495 await self.mass.load_provider_config(config)
1496 if config.enabled and prov_instance and available:
1497 # update config for existing/loaded provider instance
1498 await prov_instance.update_config(config, changed_keys)
1499 # push instance name to config (to persist it if it was autogenerated)
1500 if prov_instance.default_name != config.default_name:
1501 self.set_provider_default_name(
1502 prov_instance.instance_id, prov_instance.default_name
1503 )
1504 elif config.enabled:
1505 # provider is enabled but not available, try to load it
1506 await self.mass.load_provider_config(config)
1507 else:
1508 # disable provider
1509 prov_manifest = self.mass.get_provider_manifest(config.domain)
1510 if not prov_manifest.allow_disable:
1511 msg = "Provider can not be disabled."
1512 raise RuntimeError(msg)
1513 # also unload any other providers dependent of this provider
1514 for dep_prov in self.mass.providers:
1515 if dep_prov.manifest.depends_on == config.domain:
1516 await self.mass.unload_provider(dep_prov.instance_id)
1517 await self.mass.unload_provider(config.instance_id)
1518 # For player providers, unload_provider should have removed all its players by now
1519 return config
1520
1521 async def _add_provider_config(
1522 self,
1523 provider_domain: str,
1524 values: dict[str, ConfigValueType],
1525 ) -> ProviderConfig:
1526 """
1527 Add new Provider (instance).
1528
1529 params:
1530 - provider_domain: domain of the provider for which to add an instance of.
1531 - values: the raw values for config entries.
1532
1533 Returns: newly created ProviderConfig.
1534 """
1535 # lookup provider manifest and module
1536 for prov in self.mass.get_provider_manifests():
1537 if prov.domain == provider_domain:
1538 manifest = prov
1539 break
1540 else:
1541 msg = f"Unknown provider domain: {provider_domain}"
1542 raise KeyError(msg)
1543 if prov.depends_on and not self.mass.get_provider(prov.depends_on):
1544 msg = f"Provider {manifest.name} depends on {prov.depends_on}"
1545 raise ValueError(msg)
1546 # create new provider config with given values
1547 existing = {
1548 x.instance_id for x in await self.get_provider_configs(provider_domain=provider_domain)
1549 }
1550 # determine instance id based on previous configs
1551 if existing and not manifest.multi_instance:
1552 msg = f"Provider {manifest.name} does not support multiple instances"
1553 raise ValueError(msg)
1554 if manifest.multi_instance:
1555 instance_id = f"{manifest.domain}--{shortuuid.random(8)}"
1556 else:
1557 instance_id = manifest.domain
1558 # all checks passed, create config object
1559 config_entries = await self.get_provider_config_entries(
1560 provider_domain=provider_domain, instance_id=instance_id, values=values
1561 )
1562 config = cast(
1563 "ProviderConfig",
1564 ProviderConfig.parse(
1565 config_entries,
1566 {
1567 "type": manifest.type.value,
1568 "domain": manifest.domain,
1569 "instance_id": instance_id,
1570 "default_name": manifest.name,
1571 "values": values,
1572 },
1573 ),
1574 )
1575 # validate the new config
1576 config.validate()
1577 # save the config first to prevent issues when the
1578 # provider wants to manipulate the config during load
1579 conf_key = f"{CONF_PROVIDERS}/{config.instance_id}"
1580 self.set(conf_key, config.to_raw())
1581 # try to load the provider
1582 try:
1583 await self.mass.load_provider_config(config)
1584 except Exception:
1585 # loading failed, remove config
1586 self.remove(conf_key)
1587 raise
1588 if not self.onboard_done:
1589 # mark onboard as complete as soon as the first provider is added
1590 await self.set_onboard_complete()
1591 if manifest.type == ProviderType.MUSIC:
1592 # correct any multi-instance provider mappings
1593 self.mass.create_task(self.mass.music.correct_multi_instance_provider_mappings())
1594 return config
1595
1596 async def _get_player_config_entries(
1597 self,
1598 player: Player,
1599 action: str | None = None,
1600 values: dict[str, ConfigValueType] | None = None,
1601 ) -> list[ConfigEntry]:
1602 """
1603 Return Player(protocol) specific config entries, without any default entries.
1604
1605 In general this returns entries that are specific to this provider/player type only,
1606 and includes audio related entries that are not part of the default set.
1607
1608 player: the player instance
1609 action: [optional] action key called from config entries UI.
1610 values: the (intermediate) raw values for config entries sent with the action.
1611 """
1612 default_entries: list[ConfigEntry]
1613 is_dedicated_group_player = player.type in (
1614 PlayerType.GROUP,
1615 PlayerType.STEREO_PAIR,
1616 ) and not player.player_id.startswith(("universal_", SYNCGROUP_PREFIX))
1617 is_http_based_player_protocol = player.provider.domain not in NON_HTTP_PROVIDERS
1618 if player.type == PlayerType.GROUP and not is_dedicated_group_player:
1619 # no audio related entries for universal group players or sync group players
1620 default_entries = []
1621 else:
1622 # default output/audio related entries
1623 default_entries = [
1624 # output channel is always configurable per player(protocol)
1625 CONF_ENTRY_OUTPUT_CHANNELS
1626 ]
1627 if is_http_based_player_protocol:
1628 # for http based players we can add the http streaming related entries
1629 default_entries += [
1630 CONF_ENTRY_SAMPLE_RATES,
1631 CONF_ENTRY_OUTPUT_CODEC,
1632 CONF_ENTRY_HTTP_PROFILE,
1633 CONF_ENTRY_ENABLE_ICY_METADATA,
1634 ]
1635 # add flow mode entry for http-based players that do not already enforce it
1636 if not player.requires_flow_mode:
1637 default_entries.append(CONF_ENTRY_FLOW_MODE)
1638 # request player specific entries
1639 player_entries = await player.get_config_entries(action=action, values=values)
1640 players_keys = {entry.key for entry in player_entries}
1641 # filter out any default entries that are already provided by the player
1642 default_entries = [entry for entry in default_entries if entry.key not in players_keys]
1643 return [*player_entries, *default_entries]
1644
1645 def _get_default_player_config_entries(self, player: Player) -> list[ConfigEntry]:
1646 """
1647 Return the default (generic) player config entries.
1648
1649 This does not return audio/protocol specific entries, those are handled elsewhere.
1650 """
1651 entries: list[ConfigEntry] = []
1652 # default protocol-player config entries
1653 if player.type == PlayerType.PROTOCOL:
1654 # protocol players have no generic config entries
1655 # only audio/protocol specific ones
1656 return []
1657
1658 # some base entries for all player types
1659 # note that these may NOT be playback/audio related
1660 entries += [
1661 CONF_ENTRY_SMART_FADES_MODE,
1662 CONF_ENTRY_CROSSFADE_DURATION,
1663 # we allow volume normalization/output limiter here as it is a per-queue(player) setting
1664 CONF_ENTRY_VOLUME_NORMALIZATION,
1665 CONF_ENTRY_OUTPUT_LIMITER,
1666 CONF_ENTRY_VOLUME_NORMALIZATION_TARGET,
1667 CONF_ENTRY_TTS_PRE_ANNOUNCE,
1668 ConfigEntry(
1669 key=CONF_PRE_ANNOUNCE_CHIME_URL,
1670 type=ConfigEntryType.STRING,
1671 label="Custom (pre)announcement chime URL",
1672 description="URL to a custom audio file to play before announcements.\n"
1673 "Leave empty to use the default chime.\n"
1674 "Supports http:// and https:// URLs pointing to "
1675 "audio files (.mp3, .wav, .flac, .ogg, .m4a, .aac).\n"
1676 "Example: http://homeassistant.local:8123/local/audio/custom_chime.mp3",
1677 category="announcements",
1678 required=False,
1679 depends_on=CONF_ENTRY_TTS_PRE_ANNOUNCE.key,
1680 depends_on_value=True,
1681 validate=lambda val: validate_announcement_chime_url(cast("str", val)),
1682 ),
1683 # add player control entries
1684 *self._create_player_control_config_entries(player),
1685 # add entry to hide player in UI
1686 ConfigEntry(
1687 key=CONF_HIDE_IN_UI,
1688 type=ConfigEntryType.BOOLEAN,
1689 label="Hide this player in the user interface",
1690 default_value=player.hidden_by_default,
1691 category="generic",
1692 advanced=True,
1693 ),
1694 # add entry to expose player to HA
1695 ConfigEntry(
1696 key=CONF_EXPOSE_PLAYER_TO_HA,
1697 type=ConfigEntryType.BOOLEAN,
1698 label="Expose this player to Home Assistant",
1699 description="Expose this player to the Home Assistant integration. \n"
1700 "If disabled, this player will not be imported into Home Assistant.",
1701 category="generic",
1702 advanced=True,
1703 default_value=player.expose_to_ha_by_default,
1704 ),
1705 ]
1706 # group-player config entries
1707 if player.type == PlayerType.GROUP:
1708 entries += [
1709 CONF_ENTRY_PLAYER_ICON_GROUP,
1710 ]
1711 return entries
1712 # normal player (or stereo pair) config entries
1713 entries += [
1714 CONF_ENTRY_PLAYER_ICON,
1715 # add default entries for announce feature
1716 CONF_ENTRY_ANNOUNCE_VOLUME_STRATEGY,
1717 CONF_ENTRY_ANNOUNCE_VOLUME,
1718 CONF_ENTRY_ANNOUNCE_VOLUME_MIN,
1719 CONF_ENTRY_ANNOUNCE_VOLUME_MAX,
1720 ]
1721 return entries
1722
1723 def _create_player_control_config_entries(self, player: Player) -> list[ConfigEntry]:
1724 """Create config entries for player controls."""
1725 all_controls = self.mass.players.player_controls()
1726 power_controls = [x for x in all_controls if x.supports_power]
1727 volume_controls = [x for x in all_controls if x.supports_volume]
1728 mute_controls = [x for x in all_controls if x.supports_mute]
1729 # work out player supported features
1730 supports_power = PlayerFeature.POWER in player.supported_features
1731 supports_volume = PlayerFeature.VOLUME_SET in player.supported_features
1732 supports_mute = PlayerFeature.VOLUME_MUTE in player.supported_features
1733 # create base options per control type (and add defaults like native and fake)
1734 base_power_options: list[ConfigValueOption] = [
1735 ConfigValueOption(title="None", value=PLAYER_CONTROL_NONE),
1736 ConfigValueOption(title="Fake power control", value=PLAYER_CONTROL_FAKE),
1737 ]
1738 if supports_power:
1739 base_power_options.append(
1740 ConfigValueOption(title="Native power control", value=PLAYER_CONTROL_NATIVE),
1741 )
1742 base_volume_options: list[ConfigValueOption] = [
1743 ConfigValueOption(title="None", value=PLAYER_CONTROL_NONE),
1744 ]
1745 if supports_volume:
1746 base_volume_options.append(
1747 ConfigValueOption(title="Native volume control", value=PLAYER_CONTROL_NATIVE),
1748 )
1749 base_mute_options: list[ConfigValueOption] = [
1750 ConfigValueOption(title="None", value=PLAYER_CONTROL_NONE),
1751 ConfigValueOption(title="Fake mute control", value=PLAYER_CONTROL_FAKE),
1752 ]
1753 if supports_mute:
1754 base_mute_options.append(
1755 ConfigValueOption(title="Native mute control", value=PLAYER_CONTROL_NATIVE),
1756 )
1757 # return final config entries for all options
1758 return [
1759 # Power control config entry
1760 ConfigEntry(
1761 key=CONF_POWER_CONTROL,
1762 type=ConfigEntryType.STRING,
1763 label="Power Control",
1764 default_value=PLAYER_CONTROL_NATIVE if supports_power else PLAYER_CONTROL_NONE,
1765 required=True,
1766 options=[
1767 *base_power_options,
1768 *(ConfigValueOption(x.name, x.id) for x in power_controls),
1769 ],
1770 category="player_controls",
1771 hidden=player.type == PlayerType.GROUP,
1772 ),
1773 # Volume control config entry
1774 ConfigEntry(
1775 key=CONF_VOLUME_CONTROL,
1776 type=ConfigEntryType.STRING,
1777 label="Volume Control",
1778 default_value=PLAYER_CONTROL_NATIVE if supports_volume else PLAYER_CONTROL_NONE,
1779 required=True,
1780 options=[
1781 *base_volume_options,
1782 *(ConfigValueOption(x.name, x.id) for x in volume_controls),
1783 ],
1784 category="player_controls",
1785 hidden=player.type == PlayerType.GROUP,
1786 ),
1787 # Mute control config entry
1788 ConfigEntry(
1789 key=CONF_MUTE_CONTROL,
1790 type=ConfigEntryType.STRING,
1791 label="Mute Control",
1792 default_value=PLAYER_CONTROL_NATIVE if supports_mute else PLAYER_CONTROL_NONE,
1793 required=True,
1794 options=[
1795 *base_mute_options,
1796 *[ConfigValueOption(x.name, x.id) for x in mute_controls],
1797 ],
1798 category="player_controls",
1799 hidden=player.type == PlayerType.GROUP,
1800 ),
1801 # auto-play on power on control config entry
1802 CONF_ENTRY_AUTO_PLAY,
1803 ]
1804