music-assistant-server

73.9 KBPY
config.py
73.9 KB1,804 lines • python
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