/
/
/
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 # This can happen when config entries are dynamic (e.g., protocol settings depend on
814 # player.available and linked protocols). If save_player_config is called before
815 # the player is fully available, those entries won't exist and their stored values
816 # would be lost without this preservation.
817 existing_raw = self.get(conf_key) or {}
818 existing_values = existing_raw.get("values", {})
819 new_raw = config.to_raw()
820 new_values = new_raw.get("values", {})
821 # Preserve values from storage that don't have config entries in current context
822 for key, value in existing_values.items():
823 if key not in new_values:
824 new_values[key] = value
825 new_raw["values"] = new_values
826 self.set(conf_key, new_raw)
827 try:
828 # validate/handle the update in the player manager
829 await self.mass.players.on_player_config_change(config, changed_keys)
830 except Exception:
831 # rollback on error - use existing_raw to preserve all values
832 self.set(conf_key, existing_raw)
833 raise
834 # send config updated event
835 self.mass.signal_event(
836 EventType.PLAYER_CONFIG_UPDATED,
837 object_id=config.player_id,
838 data=config,
839 )
840 # return full player config (just in case)
841 return await self.get_player_config(player_id)
842
843 @api_command("config/players/remove", required_role="admin")
844 async def remove_player_config(self, player_id: str) -> None:
845 """Remove PlayerConfig."""
846 conf_key = f"{CONF_PLAYERS}/{player_id}"
847 dsp_conf_key = f"{CONF_PLAYER_DSP}/{player_id}"
848 player_config = self.get(conf_key)
849 if not player_config:
850 msg = f"Player configuration for {player_id} does not exist"
851 raise KeyError(msg)
852 if self.mass.players.get_player(player_id):
853 try:
854 await self.mass.players.remove(player_id)
855 except UnsupportedFeaturedException:
856 # removing a player config while it is active is not allowed
857 # unless the provider reports it has the remove_player feature
858 raise ActionUnavailable("Can not remove config for an active player!")
859 # tell the player manager to remove the player if its lingering around
860 # set permanent to false otherwise we end up in an infinite loop
861 await self.mass.players.unregister(player_id, permanent=False)
862 # remove the actual config if all of the above passed
863 self.remove(conf_key)
864 # Also remove the DSP config if it exists
865 self.remove(dsp_conf_key)
866
867 def set_player_default_name(self, player_id: str, default_name: str) -> None:
868 """Set (or update) the default name for a player."""
869 conf_key = f"{CONF_PLAYERS}/{player_id}/default_name"
870 self.set(conf_key, default_name)
871
872 def set_player_type(self, player_id: str, player_type: PlayerType) -> None:
873 """Set (or update) the type for a player."""
874 conf_key = f"{CONF_PLAYERS}/{player_id}/player_type"
875 self.set(conf_key, player_type)
876
877 def create_default_player_config(
878 self,
879 player_id: str,
880 provider: str,
881 player_type: PlayerType,
882 name: str | None = None,
883 enabled: bool = True,
884 values: dict[str, ConfigValueType] | None = None,
885 ) -> None:
886 """
887 Create default/empty PlayerConfig.
888
889 This is meant as helper to create default configs when a player is registered.
890 Called by the player manager on player register.
891 """
892 # return early if the config already exists
893 if existing_conf := self.get(f"{CONF_PLAYERS}/{player_id}"):
894 # update default name if needed
895 if name and name != existing_conf.get("default_name"):
896 self.set(f"{CONF_PLAYERS}/{player_id}/default_name", name)
897 # update player_type if needed
898 if existing_conf.get("player_type") != player_type:
899 self.set(f"{CONF_PLAYERS}/{player_id}/player_type", player_type.value)
900 return
901 # config does not yet exist, create a default one
902 conf_key = f"{CONF_PLAYERS}/{player_id}"
903 default_conf = PlayerConfig(
904 values={},
905 provider=provider,
906 player_id=player_id,
907 enabled=enabled,
908 name=name,
909 default_name=name,
910 player_type=player_type,
911 )
912 default_conf_raw = default_conf.to_raw()
913 if values is not None:
914 default_conf_raw["values"] = values
915 self.set(
916 conf_key,
917 default_conf_raw,
918 )
919
920 @api_command("config/players/dsp/get")
921 def get_player_dsp_config(self, player_id: str) -> DSPConfig:
922 """
923 Return the DSP Configuration for a player.
924
925 In case the player does not have a DSP configuration, a default one is returned.
926 """
927 if raw_conf := self.get(f"{CONF_PLAYER_DSP}/{player_id}"):
928 return DSPConfig.from_dict(raw_conf)
929 # return default DSP config
930 dsp_config = DSPConfig()
931 # The DSP config does not do anything by default, so we disable it
932 dsp_config.enabled = False
933 return dsp_config
934
935 @api_command("config/players/dsp/save", required_role="admin")
936 async def save_dsp_config(self, player_id: str, config: DSPConfig) -> DSPConfig:
937 """
938 Save/update DSPConfig for a player.
939
940 This method will validate the config and apply it to the player.
941 """
942 # validate the new config
943 config.validate()
944
945 # Save and apply the new config to the player
946 self.set(f"{CONF_PLAYER_DSP}/{player_id}", config.to_dict())
947 await self.mass.players.on_player_dsp_change(player_id)
948 # send the dsp config updated event
949 self.mass.signal_event(
950 EventType.PLAYER_DSP_CONFIG_UPDATED,
951 object_id=player_id,
952 data=config,
953 )
954 return config
955
956 @api_command("config/dsp_presets/get")
957 async def get_dsp_presets(self) -> list[DSPConfigPreset]:
958 """Return all user-defined DSP presets."""
959 raw_presets = self.get(CONF_PLAYER_DSP_PRESETS, {})
960 return [DSPConfigPreset.from_dict(preset) for preset in raw_presets.values()]
961
962 @api_command("config/dsp_presets/save", required_role="admin")
963 async def save_dsp_presets(self, preset: DSPConfigPreset) -> DSPConfigPreset:
964 """
965 Save/update a user-defined DSP presets.
966
967 This method will validate the config before saving it to the persistent storage.
968 """
969 preset.validate()
970
971 if preset.preset_id is None:
972 # Generate a new preset_id if it does not exist
973 preset.preset_id = shortuuid.random(8).lower()
974
975 # Save the preset to the persistent storage
976 self.set(f"{CONF_PLAYER_DSP_PRESETS}/preset_{preset.preset_id}", preset.to_dict())
977
978 all_presets = await self.get_dsp_presets()
979
980 self.mass.signal_event(
981 EventType.DSP_PRESETS_UPDATED,
982 data=all_presets,
983 )
984
985 return preset
986
987 @api_command("config/dsp_presets/remove", required_role="admin")
988 async def remove_dsp_preset(self, preset_id: str) -> None:
989 """Remove a user-defined DSP preset."""
990 self.mass.config.remove(f"{CONF_PLAYER_DSP_PRESETS}/preset_{preset_id}")
991
992 all_presets = await self.get_dsp_presets()
993
994 self.mass.signal_event(
995 EventType.DSP_PRESETS_UPDATED,
996 data=all_presets,
997 )
998
999 async def create_builtin_provider_config(self, provider_domain: str) -> None:
1000 """
1001 Create builtin ProviderConfig.
1002
1003 This is meant as helper to create default configs for builtin/default providers.
1004 Called by the server initialization code which load all providers at startup.
1005 """
1006 for _ in await self.get_provider_configs(provider_domain=provider_domain):
1007 # return if there is already any config
1008 return
1009 for prov in self.mass.get_provider_manifests():
1010 if prov.domain == provider_domain:
1011 manifest = prov
1012 break
1013 else:
1014 msg = f"Unknown provider domain: {provider_domain}"
1015 raise KeyError(msg)
1016 config_entries = await self.get_provider_config_entries(provider_domain)
1017 if manifest.multi_instance:
1018 instance_id = f"{manifest.domain}--{shortuuid.random(8)}"
1019 else:
1020 instance_id = manifest.domain
1021 default_config = cast(
1022 "ProviderConfig",
1023 ProviderConfig.parse(
1024 config_entries,
1025 {
1026 "type": manifest.type.value,
1027 "domain": manifest.domain,
1028 "instance_id": instance_id,
1029 "name": manifest.name,
1030 # note: this will only work for providers that do
1031 # not have any required config entries or provide defaults
1032 "values": {},
1033 },
1034 ),
1035 )
1036 default_config.validate()
1037 conf_key = f"{CONF_PROVIDERS}/{default_config.instance_id}"
1038 self.set_default(conf_key, default_config.to_raw())
1039
1040 @api_command("config/core")
1041 async def get_core_configs(self, include_values: bool = False) -> list[CoreConfig]:
1042 """Return all core controllers config options."""
1043 return [
1044 await self.get_core_config(core_controller)
1045 if include_values
1046 else cast(
1047 "CoreConfig",
1048 CoreConfig.parse(
1049 [],
1050 self.get(f"{CONF_CORE}/{core_controller}", {"domain": core_controller}),
1051 ),
1052 )
1053 for core_controller in CONFIGURABLE_CORE_CONTROLLERS
1054 ]
1055
1056 @api_command("config/core/get")
1057 async def get_core_config(self, domain: str) -> CoreConfig:
1058 """Return configuration for a single core controller."""
1059 raw_conf = self.get(f"{CONF_CORE}/{domain}", {"domain": domain})
1060 config_entries = await self.get_core_config_entries(domain)
1061 return cast("CoreConfig", CoreConfig.parse(config_entries, raw_conf))
1062
1063 @overload
1064 async def get_core_config_value(
1065 self,
1066 domain: str,
1067 key: str,
1068 *,
1069 default: _ConfigValueT,
1070 return_type: type[_ConfigValueT] = ...,
1071 ) -> _ConfigValueT: ...
1072
1073 @overload
1074 async def get_core_config_value(
1075 self,
1076 domain: str,
1077 key: str,
1078 *,
1079 default: ConfigValueType = ...,
1080 return_type: type[_ConfigValueT] = ...,
1081 ) -> _ConfigValueT: ...
1082
1083 @overload
1084 async def get_core_config_value(
1085 self,
1086 domain: str,
1087 key: str,
1088 *,
1089 default: ConfigValueType = ...,
1090 return_type: None = ...,
1091 ) -> ConfigValueType: ...
1092
1093 @api_command("config/core/get_value")
1094 async def get_core_config_value(
1095 self,
1096 domain: str,
1097 key: str,
1098 *,
1099 default: ConfigValueType = None,
1100 return_type: type[_ConfigValueT | ConfigValueType] | None = None,
1101 ) -> _ConfigValueT | ConfigValueType:
1102 """
1103 Return single configentry value for a core controller.
1104
1105 :param domain: The core controller domain.
1106 :param key: The config key to retrieve.
1107 :param default: Optional default value to return if key is not found.
1108 :param return_type: Optional type hint for type inference (e.g., str, int, bool).
1109 Note: This parameter is used purely for static type checking and does not
1110 perform runtime type validation. Callers are responsible for ensuring the
1111 specified type matches the actual config value type.
1112 """
1113 # prefer stored value so we don't have to retrieve all config entries every time
1114 if (raw_value := self.get_raw_core_config_value(domain, key)) is not None:
1115 return raw_value
1116 conf = await self.get_core_config(domain)
1117 if key not in conf.values:
1118 if default is not None:
1119 return default
1120 msg = f"Config key {key} not found for core controller {domain}"
1121 raise KeyError(msg)
1122 return (
1123 conf.values[key].value
1124 if conf.values[key].value is not None
1125 else conf.values[key].default_value
1126 )
1127
1128 @api_command("config/core/get_entries")
1129 async def get_core_config_entries(
1130 self,
1131 domain: str,
1132 action: str | None = None,
1133 values: dict[str, ConfigValueType] | None = None,
1134 ) -> list[ConfigEntry]:
1135 """
1136 Return Config entries to configure a core controller.
1137
1138 core_controller: name of the core controller
1139 action: [optional] action key called from config entries UI.
1140 values: the (intermediate) raw values for config entries sent with the action.
1141 """
1142 controller: CoreController = getattr(self.mass, domain)
1143 all_entries = list(
1144 await controller.get_config_entries(action=action, values=values)
1145 + DEFAULT_CORE_CONFIG_ENTRIES
1146 )
1147 if action and values is not None:
1148 # set current value from passed values for config entries
1149 # only do this if we're passed values (e.g. during an action)
1150 # deepcopy here to avoid modifying original entries
1151 all_entries = [deepcopy(entry) for entry in all_entries]
1152 for entry in all_entries:
1153 if entry.value is None:
1154 entry.value = values.get(entry.key, entry.default_value)
1155 return all_entries
1156
1157 @api_command("config/core/save", required_role="admin")
1158 async def save_core_config(
1159 self,
1160 domain: str,
1161 values: dict[str, ConfigValueType],
1162 ) -> CoreConfig:
1163 """Save CoreController Config values."""
1164 config = await self.get_core_config(domain)
1165 prev_config = config.to_raw()
1166 changed_keys = config.update(values)
1167 # validate the new config
1168 config.validate()
1169 if not changed_keys:
1170 # no changes
1171 return config
1172 # save the config first before reloading to avoid issues on reload
1173 # for example when reloading the webserver we might be cancelled here
1174 conf_key = f"{CONF_CORE}/{domain}"
1175 self.set(conf_key, config.to_raw())
1176 self.save(immediate=True)
1177 try:
1178 controller: CoreController = getattr(self.mass, domain)
1179 await controller.update_config(config, changed_keys)
1180 except asyncio.CancelledError:
1181 pass
1182 except Exception:
1183 # revert to previous config on error
1184 self.set(conf_key, prev_config)
1185 self.save(immediate=True)
1186 raise
1187 # reload succeeded; clear last_error and persist the final state
1188 config.last_error = None
1189 # return full config
1190 return await self.get_core_config(domain)
1191
1192 if TYPE_CHECKING:
1193 # Overload for when default is provided - return type matches default type
1194 @overload
1195 def get_raw_core_config_value(
1196 self, core_module: str, key: str, default: _ConfigValueT
1197 ) -> _ConfigValueT: ...
1198
1199 # Overload for when no default is provided - return ConfigValueType | None
1200 @overload
1201 def get_raw_core_config_value(
1202 self, core_module: str, key: str, default: None = None
1203 ) -> ConfigValueType | None: ...
1204
1205 def get_raw_core_config_value(
1206 self, core_module: str, key: str, default: ConfigValueType = None
1207 ) -> ConfigValueType:
1208 """
1209 Return (raw) single configentry value for a core controller.
1210
1211 Note that this only returns the stored value without any validation or default.
1212 """
1213 return cast(
1214 "ConfigValueType",
1215 self.get(
1216 f"{CONF_CORE}/{core_module}/values/{key}",
1217 self.get(f"{CONF_CORE}/{core_module}/{key}", default),
1218 ),
1219 )
1220
1221 if TYPE_CHECKING:
1222 # Overload for when default is provided - return type matches default type
1223 @overload
1224 def get_raw_provider_config_value(
1225 self, provider_instance: str, key: str, default: _ConfigValueT
1226 ) -> _ConfigValueT: ...
1227
1228 # Overload for when no default is provided - return ConfigValueType | None
1229 @overload
1230 def get_raw_provider_config_value(
1231 self, provider_instance: str, key: str, default: None = None
1232 ) -> ConfigValueType | None: ...
1233
1234 def get_raw_provider_config_value(
1235 self, provider_instance: str, key: str, default: ConfigValueType = None
1236 ) -> ConfigValueType:
1237 """
1238 Return (raw) single config(entry) value for a provider.
1239
1240 Note that this only returns the stored value without any validation or default.
1241 """
1242 return cast(
1243 "ConfigValueType",
1244 self.get(
1245 f"{CONF_PROVIDERS}/{provider_instance}/values/{key}",
1246 self.get(f"{CONF_PROVIDERS}/{provider_instance}/{key}", default),
1247 ),
1248 )
1249
1250 def set_raw_provider_config_value(
1251 self,
1252 provider_instance: str,
1253 key: str,
1254 value: ConfigValueType,
1255 encrypted: bool = False,
1256 ) -> None:
1257 """
1258 Set (raw) single config(entry) value for a provider.
1259
1260 Note that this only stores the (raw) value without any validation or default.
1261 """
1262 if not self.get(f"{CONF_PROVIDERS}/{provider_instance}"):
1263 # only allow setting raw values if main entry exists
1264 msg = f"Invalid provider_instance: {provider_instance}"
1265 raise KeyError(msg)
1266 if encrypted:
1267 if not isinstance(value, str):
1268 msg = f"Cannot encrypt non-string value for key {key}"
1269 raise ValueError(msg)
1270 value = self.encrypt_string(value)
1271 if key in BASE_KEYS:
1272 self.set(f"{CONF_PROVIDERS}/{provider_instance}/{key}", value)
1273 return
1274 self.set(f"{CONF_PROVIDERS}/{provider_instance}/values/{key}", value)
1275
1276 def set_raw_core_config_value(self, core_module: str, key: str, value: ConfigValueType) -> None:
1277 """
1278 Set (raw) single config(entry) value for a core controller.
1279
1280 Note that this only stores the (raw) value without any validation or default.
1281 """
1282 if not self.get(f"{CONF_CORE}/{core_module}"):
1283 # create base object first if needed
1284 self.set(f"{CONF_CORE}/{core_module}", CoreConfig({}, core_module).to_raw())
1285 self.set(f"{CONF_CORE}/{core_module}/values/{key}", value)
1286
1287 def set_raw_player_config_value(self, player_id: str, key: str, value: ConfigValueType) -> None:
1288 """
1289 Set (raw) single config(entry) value for a player.
1290
1291 Note that this only stores the (raw) value without any validation or default.
1292 """
1293 if not self.get(f"{CONF_PLAYERS}/{player_id}"):
1294 # only allow setting raw values if main entry exists
1295 msg = f"Invalid player_id: {player_id}"
1296 raise KeyError(msg)
1297 if key in BASE_KEYS:
1298 self.set(f"{CONF_PLAYERS}/{player_id}/{key}", value)
1299 else:
1300 self.set(f"{CONF_PLAYERS}/{player_id}/values/{key}", value)
1301
1302 def save(self, immediate: bool = False) -> None:
1303 """Schedule save of data to disk."""
1304 if self._timer_handle is not None:
1305 self._timer_handle.cancel()
1306 self._timer_handle = None
1307
1308 if immediate:
1309 self.mass.loop.create_task(self._async_save())
1310 else:
1311 # schedule the save for later
1312 self._timer_handle = self.mass.loop.call_later(
1313 DEFAULT_SAVE_DELAY, self.mass.create_task, self._async_save
1314 )
1315
1316 def encrypt_string(self, str_value: str) -> str:
1317 """Encrypt a (password)string with Fernet."""
1318 if str_value.startswith(ENCRYPT_SUFFIX):
1319 return str_value
1320 assert self._fernet is not None
1321 return ENCRYPT_SUFFIX + self._fernet.encrypt(str_value.encode()).decode()
1322
1323 def decrypt_string(self, encrypted_str: str) -> str:
1324 """Decrypt a (password)string with Fernet."""
1325 if not encrypted_str:
1326 return encrypted_str
1327 if not encrypted_str.startswith(ENCRYPT_SUFFIX):
1328 return encrypted_str
1329 assert self._fernet is not None
1330 try:
1331 return self._fernet.decrypt(encrypted_str.replace(ENCRYPT_SUFFIX, "").encode()).decode()
1332 except InvalidToken as err:
1333 msg = "Password decryption failed"
1334 raise InvalidDataError(msg) from err
1335
1336 async def _load(self) -> None:
1337 """Load data from persistent storage."""
1338 assert not self._data, "Already loaded"
1339
1340 for filename in (self.filename, f"{self.filename}.backup"):
1341 try:
1342 async with aiofiles.open(filename, encoding="utf-8") as _file:
1343 self._data = await async_json_loads(await _file.read())
1344 LOGGER.debug("Loaded persistent settings from %s", filename)
1345 await self._migrate()
1346 return
1347 except FileNotFoundError:
1348 pass
1349 except JSON_DECODE_EXCEPTIONS:
1350 LOGGER.exception("Error while reading persistent storage file %s", filename)
1351 LOGGER.debug("Started with empty storage: No persistent storage file found.")
1352
1353 async def _migrate(self) -> None: # noqa: PLR0915
1354 changed = False
1355
1356 # some type hints to help with the code below
1357 instance_id: str
1358 player_id: str
1359 provider_config: dict[str, Any]
1360 player_config: dict[str, Any]
1361
1362 # Older versions of MA can create corrupt entries with no domain if retrying
1363 # logic runs after a provider has been removed. Remove those corrupt entries.
1364 # TODO: remove after 2.8 release
1365 for instance_id, provider_config in {**self._data.get(CONF_PROVIDERS, {})}.items():
1366 if "domain" not in provider_config:
1367 self._data[CONF_PROVIDERS].pop(instance_id, None)
1368 LOGGER.warning("Removed corrupt provider configuration: %s", instance_id)
1369 changed = True
1370
1371 # migrate manual_ips to new format
1372 # TODO: remove after 2.8 release
1373 for instance_id, provider_config in self._data.get(CONF_PROVIDERS, {}).items():
1374 if not (values := provider_config.get("values")):
1375 continue
1376 if not (ips := values.get("ips")):
1377 continue
1378 values["manual_discovery_ip_addresses"] = ips.split(",")
1379 del values["ips"]
1380 changed = True
1381
1382 # migrate sample_rates config entry
1383 # TODO: remove after 2.8 release
1384 for player_config in self._data.get(CONF_PLAYERS, {}).values():
1385 if not (values := player_config.get("values")):
1386 continue
1387 if not (sample_rates := values.get("sample_rates")):
1388 continue
1389 if not isinstance(sample_rates, list):
1390 del player_config["values"]["sample_rates"]
1391 if not any(isinstance(x, list) for x in sample_rates):
1392 continue
1393 player_config["values"]["sample_rates"] = [
1394 f"{x[0]}{MULTI_VALUE_SPLITTER}{x[1]}" if isinstance(x, list) else x
1395 for x in sample_rates
1396 ]
1397 changed = True
1398
1399 # Remove obsolete builtin_player configurations (provider was deleted in 2.7)
1400 # TODO: remove after 2.8 release
1401 for player_id, player_config in list(self._data.get(CONF_PLAYERS, {}).items()):
1402 if player_config.get("provider") != "builtin_player":
1403 continue
1404 self._data[CONF_PLAYERS].pop(player_id, None)
1405 # Also remove any DSP config for this player
1406 if CONF_PLAYER_DSP in self._data:
1407 self._data[CONF_PLAYER_DSP].pop(player_id, None)
1408 LOGGER.warning("Removed obsolete builtin_player configuration: %s", player_id)
1409 changed = True
1410
1411 # Remove corrupt player configurations that are missing the required 'provider' key
1412 # or have an invalid/removed provider
1413 all_provider_ids: set[str] = set(self._data.get(CONF_PROVIDERS, {}).keys())
1414 # TODO: remove after 2.8 release
1415 for player_id, player_config in list(self._data.get(CONF_PLAYERS, {}).items()):
1416 player_provider = player_config.get("provider")
1417 if not player_provider:
1418 LOGGER.warning("Removing corrupt player configuration: %s", player_id)
1419 elif player_provider not in all_provider_ids:
1420 LOGGER.warning("Removed orphaned player configuration: %s", player_id)
1421 else:
1422 continue
1423 self._data[CONF_PLAYERS].pop(player_id, None)
1424 # Also remove any DSP config for this player
1425 if CONF_PLAYER_DSP in self._data:
1426 self._data[CONF_PLAYER_DSP].pop(player_id, None)
1427 changed = True
1428
1429 # migrate sync_group players to use the new sync_group provider
1430 # TODO: remove after 2.8 release
1431 for player_id, player_config in list(self._data.get(CONF_PLAYERS, {}).items()):
1432 if not player_id.startswith(SGP_PREFIX):
1433 continue
1434 player_provider = player_config["provider"]
1435 if player_provider == "sync_group":
1436 continue
1437 player_config["provider"] = "sync_group"
1438 changed = True
1439
1440 # Migrate AirPlay legacy credentials (ap_credentials) to protocol-specific keys
1441 # The old key was used for both RAOP and AirPlay, now we have separate keys
1442 # TODO: remove after 2.8 release
1443 for player_id, player_config in self._data.get(CONF_PLAYERS, {}).items():
1444 if player_config.get("provider") != "airplay":
1445 continue
1446 if not (values := player_config.get("values")):
1447 continue
1448 if "ap_credentials" not in values:
1449 continue
1450 # Migrate to raop_credentials (RAOP is the default/fallback protocol)
1451 # The new code will use the correct key based on the protocol
1452 old_creds = values.pop("ap_credentials")
1453 if old_creds and "raop_credentials" not in values:
1454 values["raop_credentials"] = old_creds
1455 LOGGER.info("Migrated AirPlay credentials for player %s", player_id)
1456 changed = True
1457
1458 if changed:
1459 await self._async_save()
1460
1461 async def _async_save(self) -> None:
1462 """Save persistent data to disk."""
1463 filename_backup = f"{self.filename}.backup"
1464 # make backup before we write a new file
1465 if await isfile(self.filename):
1466 with contextlib.suppress(FileNotFoundError):
1467 await remove(filename_backup)
1468 await rename(self.filename, filename_backup)
1469
1470 async with aiofiles.open(self.filename, "w", encoding="utf-8") as _file:
1471 await _file.write(await async_json_dumps(self._data, indent=True))
1472 LOGGER.debug("Saved data to persistent storage")
1473
1474 @api_command("config/providers/reload", required_role="admin")
1475 async def _reload_provider(self, instance_id: str) -> None:
1476 """Reload provider."""
1477 try:
1478 config = await self.get_provider_config(instance_id)
1479 except KeyError:
1480 # Edge case: Provider was removed before we could reload it
1481 return
1482 await self.mass.load_provider_config(config)
1483
1484 async def _update_provider_config(
1485 self, instance_id: str, values: dict[str, ConfigValueType]
1486 ) -> ProviderConfig:
1487 """Update ProviderConfig."""
1488 config = await self.get_provider_config(instance_id)
1489 changed_keys = config.update(values)
1490 prov_instance = self.mass.get_provider(instance_id)
1491 available = prov_instance.available if prov_instance else False
1492 if not changed_keys and (config.enabled == available):
1493 # no changes
1494 return config
1495 # validate the new config
1496 config.validate()
1497 # save the config first to prevent issues when the
1498 # provider wants to manipulate the config during load
1499 conf_key = f"{CONF_PROVIDERS}/{config.instance_id}"
1500 raw_conf = config.to_raw()
1501 self.set(conf_key, raw_conf)
1502 if config.enabled and prov_instance is None:
1503 await self.mass.load_provider_config(config)
1504 if config.enabled and prov_instance and available:
1505 # update config for existing/loaded provider instance
1506 await prov_instance.update_config(config, changed_keys)
1507 # push instance name to config (to persist it if it was autogenerated)
1508 if prov_instance.default_name != config.default_name:
1509 self.set_provider_default_name(
1510 prov_instance.instance_id, prov_instance.default_name
1511 )
1512 elif config.enabled:
1513 # provider is enabled but not available, try to load it
1514 await self.mass.load_provider_config(config)
1515 else:
1516 # disable provider
1517 prov_manifest = self.mass.get_provider_manifest(config.domain)
1518 if not prov_manifest.allow_disable:
1519 msg = "Provider can not be disabled."
1520 raise RuntimeError(msg)
1521 # also unload any other providers dependent of this provider
1522 for dep_prov in self.mass.providers:
1523 if dep_prov.manifest.depends_on == config.domain:
1524 await self.mass.unload_provider(dep_prov.instance_id)
1525 await self.mass.unload_provider(config.instance_id)
1526 # For player providers, unload_provider should have removed all its players by now
1527 return config
1528
1529 async def _add_provider_config(
1530 self,
1531 provider_domain: str,
1532 values: dict[str, ConfigValueType],
1533 ) -> ProviderConfig:
1534 """
1535 Add new Provider (instance).
1536
1537 params:
1538 - provider_domain: domain of the provider for which to add an instance of.
1539 - values: the raw values for config entries.
1540
1541 Returns: newly created ProviderConfig.
1542 """
1543 # lookup provider manifest and module
1544 for prov in self.mass.get_provider_manifests():
1545 if prov.domain == provider_domain:
1546 manifest = prov
1547 break
1548 else:
1549 msg = f"Unknown provider domain: {provider_domain}"
1550 raise KeyError(msg)
1551 if prov.depends_on and not self.mass.get_provider(prov.depends_on):
1552 msg = f"Provider {manifest.name} depends on {prov.depends_on}"
1553 raise ValueError(msg)
1554 # create new provider config with given values
1555 existing = {
1556 x.instance_id for x in await self.get_provider_configs(provider_domain=provider_domain)
1557 }
1558 # determine instance id based on previous configs
1559 if existing and not manifest.multi_instance:
1560 msg = f"Provider {manifest.name} does not support multiple instances"
1561 raise ValueError(msg)
1562 if manifest.multi_instance:
1563 instance_id = f"{manifest.domain}--{shortuuid.random(8)}"
1564 else:
1565 instance_id = manifest.domain
1566 # all checks passed, create config object
1567 config_entries = await self.get_provider_config_entries(
1568 provider_domain=provider_domain, instance_id=instance_id, values=values
1569 )
1570 config = cast(
1571 "ProviderConfig",
1572 ProviderConfig.parse(
1573 config_entries,
1574 {
1575 "type": manifest.type.value,
1576 "domain": manifest.domain,
1577 "instance_id": instance_id,
1578 "default_name": manifest.name,
1579 "values": values,
1580 },
1581 ),
1582 )
1583 # validate the new config
1584 config.validate()
1585 # save the config first to prevent issues when the
1586 # provider wants to manipulate the config during load
1587 conf_key = f"{CONF_PROVIDERS}/{config.instance_id}"
1588 self.set(conf_key, config.to_raw())
1589 # try to load the provider
1590 try:
1591 await self.mass.load_provider_config(config)
1592 except Exception:
1593 # loading failed, remove config
1594 self.remove(conf_key)
1595 raise
1596 if not self.onboard_done:
1597 # mark onboard as complete as soon as the first provider is added
1598 await self.set_onboard_complete()
1599 if manifest.type == ProviderType.MUSIC:
1600 # correct any multi-instance provider mappings
1601 self.mass.create_task(self.mass.music.correct_multi_instance_provider_mappings())
1602 return config
1603
1604 async def _get_player_config_entries(
1605 self,
1606 player: Player,
1607 action: str | None = None,
1608 values: dict[str, ConfigValueType] | None = None,
1609 ) -> list[ConfigEntry]:
1610 """
1611 Return Player(protocol) specific config entries, without any default entries.
1612
1613 In general this returns entries that are specific to this provider/player type only,
1614 and includes audio related entries that are not part of the default set.
1615
1616 player: the player instance
1617 action: [optional] action key called from config entries UI.
1618 values: the (intermediate) raw values for config entries sent with the action.
1619 """
1620 default_entries: list[ConfigEntry]
1621 is_dedicated_group_player = player.state.type in (
1622 PlayerType.GROUP,
1623 PlayerType.STEREO_PAIR,
1624 ) and not player.player_id.startswith((UGP_PREFIX, SGP_PREFIX))
1625 is_http_based_player_protocol = player.provider.domain not in NON_HTTP_PROVIDERS
1626 if player.state.type == PlayerType.GROUP and not is_dedicated_group_player:
1627 # no audio related entries for universal group players or sync group players
1628 default_entries = []
1629 elif PlayerFeature.PLAY_MEDIA not in player.supported_features:
1630 # no audio related entries for players that do not support play_media
1631 default_entries = []
1632 else:
1633 # default output/audio related entries
1634 default_entries = [
1635 # output channel is always configurable per player(protocol)
1636 CONF_ENTRY_OUTPUT_CHANNELS
1637 ]
1638 if is_http_based_player_protocol:
1639 # for http based players we can add the http streaming related entries
1640 default_entries += [
1641 CONF_ENTRY_SAMPLE_RATES,
1642 CONF_ENTRY_OUTPUT_CODEC,
1643 CONF_ENTRY_HTTP_PROFILE,
1644 CONF_ENTRY_ENABLE_ICY_METADATA,
1645 ]
1646 # add flow mode entry for http-based players that do not already enforce it
1647 if not player.requires_flow_mode:
1648 default_entries.append(CONF_ENTRY_FLOW_MODE)
1649 # request player specific entries
1650 player_entries = await player.get_config_entries(action=action, values=values)
1651 players_keys = {entry.key for entry in player_entries}
1652 # filter out any default entries that are already provided by the player
1653 default_entries = [entry for entry in default_entries if entry.key not in players_keys]
1654 return [*player_entries, *default_entries]
1655
1656 def _get_default_player_config_entries(self, player: Player) -> list[ConfigEntry]:
1657 """
1658 Return the default (generic) player config entries.
1659
1660 This does not return audio/protocol specific entries, those are handled elsewhere.
1661 """
1662 entries: list[ConfigEntry] = []
1663 # default protocol-player config entries
1664 if player.state.type == PlayerType.PROTOCOL:
1665 # protocol players have no generic config entries
1666 # only audio/protocol specific ones
1667 return []
1668
1669 # some base entries for all player types
1670 # note that these may NOT be playback/audio related
1671 entries += [
1672 CONF_ENTRY_SMART_FADES_MODE,
1673 CONF_ENTRY_CROSSFADE_DURATION,
1674 # we allow volume normalization/output limiter here as it is a per-queue(player) setting
1675 CONF_ENTRY_VOLUME_NORMALIZATION,
1676 CONF_ENTRY_OUTPUT_LIMITER,
1677 CONF_ENTRY_VOLUME_NORMALIZATION_TARGET,
1678 CONF_ENTRY_TTS_PRE_ANNOUNCE,
1679 ConfigEntry(
1680 key=CONF_PRE_ANNOUNCE_CHIME_URL,
1681 type=ConfigEntryType.STRING,
1682 label="Custom (pre)announcement chime URL",
1683 description="URL to a custom audio file to play before announcements.\n"
1684 "Leave empty to use the default chime.\n"
1685 "Supports http:// and https:// URLs pointing to "
1686 "audio files (.mp3, .wav, .flac, .ogg, .m4a, .aac).\n"
1687 "Example: http://homeassistant.local:8123/local/audio/custom_chime.mp3",
1688 category="announcements",
1689 required=False,
1690 depends_on=CONF_ENTRY_TTS_PRE_ANNOUNCE.key,
1691 depends_on_value=True,
1692 validate=lambda val: validate_announcement_chime_url(cast("str", val)),
1693 ),
1694 # add player control entries
1695 *self._create_player_control_config_entries(player),
1696 # add entry to hide player in UI
1697 ConfigEntry(
1698 key=CONF_HIDE_IN_UI,
1699 type=ConfigEntryType.BOOLEAN,
1700 label="Hide this player in the user interface",
1701 default_value=player.hidden_by_default,
1702 category="generic",
1703 advanced=False,
1704 ),
1705 # add entry to expose player to HA
1706 ConfigEntry(
1707 key=CONF_EXPOSE_PLAYER_TO_HA,
1708 type=ConfigEntryType.BOOLEAN,
1709 label="Expose this player to Home Assistant",
1710 description="Expose this player to the Home Assistant integration. \n"
1711 "If disabled, this player will not be imported into Home Assistant.",
1712 category="generic",
1713 advanced=False,
1714 default_value=player.expose_to_ha_by_default,
1715 ),
1716 ]
1717 # group-player config entries
1718 if player.state.type == PlayerType.GROUP:
1719 entries += [
1720 CONF_ENTRY_PLAYER_ICON_GROUP,
1721 ]
1722 return entries
1723 # normal player (or stereo pair) config entries
1724 entries += [
1725 CONF_ENTRY_PLAYER_ICON,
1726 # add default entries for announce feature
1727 CONF_ENTRY_ANNOUNCE_VOLUME_STRATEGY,
1728 CONF_ENTRY_ANNOUNCE_VOLUME,
1729 CONF_ENTRY_ANNOUNCE_VOLUME_MIN,
1730 CONF_ENTRY_ANNOUNCE_VOLUME_MAX,
1731 ]
1732 return entries
1733
1734 def _create_player_control_config_entries(self, player: Player) -> list[ConfigEntry]:
1735 """Create config entries for player controls."""
1736 all_controls = self.mass.players.player_controls()
1737 power_controls = [x for x in all_controls if x.supports_power]
1738 volume_controls = [x for x in all_controls if x.supports_volume]
1739 mute_controls = [x for x in all_controls if x.supports_mute]
1740 # work out player supported features
1741 base_power_options: list[ConfigValueOption] = []
1742 if player.supports_feature(PlayerFeature.POWER):
1743 base_power_options.append(
1744 ConfigValueOption(title="Native power control", value=PLAYER_CONTROL_NATIVE),
1745 )
1746 base_volume_options: list[ConfigValueOption] = []
1747 if player.supports_feature(PlayerFeature.VOLUME_SET):
1748 base_volume_options.append(
1749 ConfigValueOption(title="Native volume control", value=PLAYER_CONTROL_NATIVE),
1750 )
1751 base_mute_options: list[ConfigValueOption] = []
1752 if player.supports_feature(PlayerFeature.VOLUME_MUTE):
1753 base_mute_options.append(
1754 ConfigValueOption(title="Native mute control", value=PLAYER_CONTROL_NATIVE),
1755 )
1756 # append protocol-specific volume and mute controls to the base options
1757 for linked_protocol in player.linked_output_protocols:
1758 if protocol_player := self.mass.players.get_player(linked_protocol.output_protocol_id):
1759 if protocol_player.supports_feature(PlayerFeature.VOLUME_SET):
1760 base_volume_options.append(
1761 ConfigValueOption(
1762 title=linked_protocol.name, value=linked_protocol.output_protocol_id
1763 )
1764 )
1765 if protocol_player.supports_feature(PlayerFeature.VOLUME_MUTE):
1766 base_mute_options.append(
1767 ConfigValueOption(
1768 title=linked_protocol.name,
1769 value=linked_protocol.output_protocol_id,
1770 )
1771 )
1772 # append none+fake options
1773 base_power_options += [
1774 ConfigValueOption(title="None", value=PLAYER_CONTROL_NONE),
1775 ConfigValueOption(title="Fake power control", value=PLAYER_CONTROL_FAKE),
1776 ]
1777 base_volume_options += [
1778 ConfigValueOption(title="None", value=PLAYER_CONTROL_NONE),
1779 ]
1780 base_mute_options.append(ConfigValueOption(title="None", value=PLAYER_CONTROL_NONE))
1781 if player.supports_feature(PlayerFeature.VOLUME_SET):
1782 base_mute_options.append(
1783 ConfigValueOption(title="Fake mute control", value=PLAYER_CONTROL_FAKE)
1784 )
1785
1786 # return final config entries for all options
1787 return [
1788 # Power control config entry
1789 ConfigEntry(
1790 key=CONF_POWER_CONTROL,
1791 type=ConfigEntryType.STRING,
1792 label="Power Control",
1793 default_value=base_power_options[0].value
1794 if base_power_options
1795 else PLAYER_CONTROL_NONE,
1796 required=False,
1797 options=[
1798 *base_power_options,
1799 *(ConfigValueOption(x.name, x.id) for x in power_controls),
1800 ],
1801 category="player_controls",
1802 ),
1803 # Volume control config entry
1804 ConfigEntry(
1805 key=CONF_VOLUME_CONTROL,
1806 type=ConfigEntryType.STRING,
1807 label="Volume Control",
1808 default_value=base_volume_options[0].value
1809 if base_volume_options
1810 else PLAYER_CONTROL_NONE,
1811 required=True,
1812 options=[
1813 *base_volume_options,
1814 *(ConfigValueOption(x.name, x.id) for x in volume_controls),
1815 ],
1816 category="player_controls",
1817 ),
1818 # Mute control config entry
1819 ConfigEntry(
1820 key=CONF_MUTE_CONTROL,
1821 type=ConfigEntryType.STRING,
1822 label="Mute Control",
1823 default_value=base_mute_options[0].value
1824 if base_mute_options
1825 else PLAYER_CONTROL_NONE,
1826 required=True,
1827 options=[
1828 *base_mute_options,
1829 *[ConfigValueOption(x.name, x.id) for x in mute_controls],
1830 ],
1831 category="player_controls",
1832 ),
1833 # auto-play on power on control config entry
1834 CONF_ENTRY_AUTO_PLAY,
1835 ]
1836
1837 async def _create_output_protocol_config_entries( # noqa: PLR0915
1838 self,
1839 player: Player,
1840 action: str | None = None,
1841 values: dict[str, ConfigValueType] | None = None,
1842 ) -> list[ConfigEntry]:
1843 """
1844 Create config entry for preferred output protocol.
1845
1846 Returns empty list if there are no output protocol options (native only or no protocols).
1847 The player.output_protocols property includes native, active, and disabled protocols,
1848 with the available flag indicating their status.
1849 """
1850 all_entries: list[ConfigEntry] = []
1851 output_protocols = player.output_protocols
1852
1853 if not player.available:
1854 # if player is not available, we cannot reliably determine the available protocols
1855 # so we return no options to avoid confusion
1856 return all_entries
1857
1858 # Build options from available output protocols, sorted by priority
1859 options: list[ConfigValueOption] = []
1860 default_value: str | None = None
1861
1862 # Add each available output protocol as an option, sorted by priority
1863 for protocol in sorted(output_protocols, key=lambda p: p.priority):
1864 if provider_manifest := self.mass.get_provider_manifest(protocol.protocol_domain):
1865 protocol_name = provider_manifest.name
1866 else:
1867 protocol_name = protocol.protocol_domain.upper()
1868 if protocol.available:
1869 # Use "native" for native playback,
1870 # otherwise use the protocol output id (=player id)
1871 title = f"{protocol_name} (native)" if protocol.is_native else protocol_name
1872 value = "native" if protocol.is_native else protocol.output_protocol_id
1873 options.append(ConfigValueOption(title=title, value=value))
1874 # First available protocol becomes the default (highest priority)
1875 if default_value is None:
1876 default_value = str(value)
1877
1878 all_entries.append(
1879 ConfigEntry(
1880 key=CONF_PREFERRED_OUTPUT_PROTOCOL,
1881 type=ConfigEntryType.STRING,
1882 label="Preferred Output Protocol",
1883 description="Select the preferred protocol for audio playback to this device.",
1884 default_value=default_value or "native",
1885 required=True,
1886 options=options,
1887 category="protocol_general",
1888 requires_reload=False,
1889 hidden=len(output_protocols) <= 1,
1890 )
1891 )
1892
1893 # Add config entries for all protocol players/outputs
1894 for protocol in output_protocols:
1895 domain = protocol.protocol_domain
1896 if provider_manifest := self.mass.get_provider_manifest(protocol.protocol_domain):
1897 protocol_name = provider_manifest.name
1898 else:
1899 protocol_name = protocol.protocol_domain.upper()
1900 protocol_player_enabled = self.get_raw_player_config_value(
1901 protocol.output_protocol_id, CONF_ENABLED, True
1902 )
1903 provider_available = self.mass.get_provider(protocol.protocol_domain) is not None
1904 if not provider_available:
1905 # protocol provider is not available, skip adding entries
1906 continue
1907 protocol_prefix = f"{protocol.output_protocol_id}{CONF_PROTOCOL_KEY_SPLITTER}"
1908 protocol_enabled_key = f"{protocol_prefix}enabled"
1909 protocol_category = f"{CONF_PROTOCOL_CATEGORY_PREFIX}_{domain}"
1910 category_translation_key = "settings.category.protocol_output_settings"
1911 if not protocol.is_native:
1912 all_entries.append(
1913 ConfigEntry(
1914 key=protocol_enabled_key,
1915 type=ConfigEntryType.BOOLEAN,
1916 label="Enable",
1917 description="Enable or disable this output protocol for the player.",
1918 value=protocol_player_enabled,
1919 default_value=protocol_player_enabled,
1920 category=protocol_category,
1921 category_translation_key=category_translation_key,
1922 category_translation_params=[protocol_name],
1923 requires_reload=False,
1924 )
1925 )
1926 if protocol.is_native:
1927 # add protocol-specific entries from native player
1928 protocol_entries = await self._get_player_config_entries(
1929 player, action=action, values=values
1930 )
1931 for proto_entry in protocol_entries:
1932 # deep copy to avoid mutating shared/constant ConfigEntry objects
1933 entry = deepcopy(proto_entry)
1934 entry.category = protocol_category
1935 entry.category_translation_key = category_translation_key
1936 entry.category_translation_params = [protocol_name]
1937 all_entries.append(entry)
1938
1939 elif protocol_player := self.mass.players.get_player(protocol.output_protocol_id):
1940 # we grab the config entries from the protocol player
1941 # and then prefix them to avoid key collisions
1942
1943 if action and protocol_prefix in action:
1944 protocol_action = action.replace(protocol_prefix, "")
1945 else:
1946 protocol_action = None
1947 if values:
1948 # extract only relevant values for this protocol player
1949 protocol_values = {
1950 key.replace(protocol_prefix, ""): val
1951 for key, val in values.items()
1952 if key.startswith(protocol_prefix)
1953 }
1954 else:
1955 protocol_values = None
1956 protocol_entries = await self._get_player_config_entries(
1957 protocol_player, action=protocol_action, values=protocol_values
1958 )
1959 for proto_entry in protocol_entries:
1960 # deep copy to avoid mutating shared/constant ConfigEntry objects
1961 entry = deepcopy(proto_entry)
1962 entry.category = protocol_category
1963 entry.category_translation_key = category_translation_key
1964 entry.category_translation_params = [protocol_name]
1965 entry.key = f"{protocol_prefix}{entry.key}"
1966 entry.depends_on = None if protocol.is_native else protocol_enabled_key
1967 entry.action = f"{protocol_prefix}{entry.action}" if entry.action else None
1968 all_entries.append(entry)
1969
1970 return all_entries
1971
1972 async def _update_output_protocol_config(
1973 self, values: dict[str, ConfigValueType]
1974 ) -> dict[str, ConfigValueType]:
1975 """
1976 Update output protocol related config for a player based on config values.
1977
1978 Returns updated values dict with output protocol related entries removed.
1979 """
1980 protocol_values: dict[str, dict[str, ConfigValueType]] = {}
1981 for key, value in list(values.items()):
1982 if CONF_PROTOCOL_KEY_SPLITTER not in key:
1983 continue
1984 # extract protocol player id and actual key
1985 protocol_player_id, actual_key = key.split(CONF_PROTOCOL_KEY_SPLITTER)
1986 if protocol_player_id not in protocol_values:
1987 protocol_values[protocol_player_id] = {}
1988 protocol_values[protocol_player_id][actual_key] = value
1989 # remove from main values dict
1990 del values[key]
1991 for protocol_player_id, proto_values in protocol_values.items():
1992 await self.save_player_config(protocol_player_id, proto_values)
1993 if proto_values.get(CONF_ENABLED):
1994 # wait max 10 seconds for protocol to become available
1995 for _ in range(10):
1996 protocol_player = self.mass.players.get_player(protocol_player_id)
1997 if protocol_player is not None:
1998 break
1999 await asyncio.sleep(1)
2000 # wait max 10 seconds for protocol
2001 return values
2002
2003 async def _get_output_protocol_config_values(
2004 self,
2005 entries: list[ConfigEntry],
2006 ) -> dict[str, ConfigValueType]:
2007 """Extract output protocol related config values for given (parent) player entries."""
2008 values: dict[str, ConfigValueType] = {}
2009 for entry in entries:
2010 if CONF_PROTOCOL_KEY_SPLITTER not in entry.key:
2011 continue
2012 protocol_player_id, actual_key = entry.key.split(CONF_PROTOCOL_KEY_SPLITTER)
2013 stored_value = self.get_raw_player_config_value(protocol_player_id, actual_key)
2014 if stored_value is None:
2015 continue
2016 values[entry.key] = stored_value
2017 return values
2018