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