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