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