/
/
/
1"""Provider configuration handling for the ConfigController."""
2
3from __future__ import annotations
4
5import asyncio
6import builtins
7import logging
8from typing import TYPE_CHECKING, Any, cast, overload
9
10import shortuuid
11from music_assistant_models.auth import Scope
12from music_assistant_models.config_entries import (
13 ConfigActionResult,
14 ConfigEntry,
15 ConfigValueType,
16 ProviderConfig,
17 ProviderError,
18)
19from music_assistant_models.enums import (
20 ConfigEntryType,
21 EventType,
22 ProviderFeature,
23 ProviderType,
24)
25from music_assistant_models.errors import ActionUnavailable
26
27from music_assistant.constants import (
28 CONF_ENTRY_LIBRARY_SYNC_ALBUM_TRACKS,
29 CONF_ENTRY_LIBRARY_SYNC_ALBUMS,
30 CONF_ENTRY_LIBRARY_SYNC_ARTISTS,
31 CONF_ENTRY_LIBRARY_SYNC_AUDIOBOOKS,
32 CONF_ENTRY_LIBRARY_SYNC_BACK,
33 CONF_ENTRY_LIBRARY_SYNC_DELETIONS,
34 CONF_ENTRY_LIBRARY_SYNC_PLAYLIST_TRACKS,
35 CONF_ENTRY_LIBRARY_SYNC_PLAYLISTS,
36 CONF_ENTRY_LIBRARY_SYNC_PODCASTS,
37 CONF_ENTRY_LIBRARY_SYNC_RADIOS,
38 CONF_ENTRY_LIBRARY_SYNC_TRACKS,
39 CONF_PLAYERS,
40 CONF_PROVIDERS,
41 DEFAULT_PROVIDER_CONFIG_ENTRIES,
42)
43from music_assistant.controllers.config.constants import BASE_KEYS, _ConfigValueT
44from music_assistant.controllers.config.helpers import (
45 _provider_status,
46 _with_translation_owner,
47)
48from music_assistant.helpers.api import api_command
49from music_assistant.models.music_provider import MusicProvider
50
51if TYPE_CHECKING:
52 from music_assistant import MusicAssistant
53 from music_assistant.models.provider import Provider
54
55
56LOGGER = logging.getLogger(__name__)
57
58
59class ProviderConfigMixin:
60 """Mixin providing provider configuration handling for the ConfigController."""
61
62 # Type hints for attributes/methods provided by the class this mixin is used with
63 if TYPE_CHECKING:
64 mass: MusicAssistant
65
66 @property
67 def onboard_done(self) -> bool: ... # noqa: D102
68
69 def get(self, key: str, default: Any = None) -> Any: ... # noqa: D102
70
71 def set(self, key: str, value: Any, immediate: bool = False) -> None: ... # noqa: D102
72
73 def set_default(self, key: str, default_value: Any) -> None: ... # noqa: D102
74
75 def remove(self, key: str) -> None: ... # noqa: D102
76
77 def encrypt_string(self, str_value: str) -> str: ... # noqa: D102
78
79 def decrypt_string(self, encrypted_str: str) -> str: ... # noqa: D102
80
81 async def set_onboard_complete(self) -> None: ... # noqa: D102
82
83 @api_command("config/providers", required_scope=Scope.CONFIG_PROVIDERS_READ)
84 async def get_provider_configs(
85 self,
86 provider_type: ProviderType | None = None,
87 provider_domain: str | None = None,
88 include_values: bool = False,
89 ) -> list[ProviderConfig]:
90 """Return all known provider configurations, optionally filtered by ProviderType."""
91 raw_values = self.get(CONF_PROVIDERS, {})
92 prov_entries = {x.domain for x in self.mass.get_provider_manifests()}
93 configs: list[ProviderConfig] = []
94 for prov_conf in raw_values.values():
95 if provider_type is not None and prov_conf["type"] != provider_type:
96 continue
97 if provider_domain is not None and prov_conf["domain"] != provider_domain:
98 continue
99 # guard for deleted providers
100 if prov_conf["domain"] not in prov_entries:
101 continue
102 if include_values:
103 # get_provider_config already stamps the derived status
104 configs.append(await self.get_provider_config(prov_conf["instance_id"]))
105 continue
106 conf = cast("ProviderConfig", ProviderConfig.parse([], prov_conf))
107 is_loaded = (
108 self.mass.get_provider(conf.instance_id, return_unavailable=True) is not None
109 )
110 conf.status = _provider_status(conf, is_loaded)
111 configs.append(conf)
112 return configs
113
114 @api_command("config/providers/get", required_scope=Scope.CONFIG_PROVIDERS_READ)
115 async def get_provider_config(self, instance_id: str) -> ProviderConfig:
116 """Return configuration for a single provider."""
117 if raw_conf := self.get(f"{CONF_PROVIDERS}/{instance_id}", {}):
118 for prov in self.mass.get_provider_manifests():
119 if prov.domain == raw_conf["domain"]:
120 break
121 else:
122 msg = f"Unknown provider domain: {raw_conf['domain']}"
123 raise KeyError(msg)
124 config_entries = await self.get_provider_config_entries(instance_id)
125 conf = cast("ProviderConfig", ProviderConfig.parse(config_entries, raw_conf))
126 is_loaded = self.mass.get_provider(instance_id, return_unavailable=True) is not None
127 conf.status = _provider_status(conf, is_loaded)
128 return conf
129 msg = f"No config found for provider id {instance_id}"
130 raise KeyError(msg)
131
132 @overload
133 async def get_provider_config_value(
134 self,
135 instance_id: str,
136 key: str,
137 *,
138 default: _ConfigValueT,
139 return_type: type[_ConfigValueT] = ...,
140 ) -> _ConfigValueT: ...
141
142 @overload
143 async def get_provider_config_value(
144 self,
145 instance_id: str,
146 key: str,
147 *,
148 default: ConfigValueType = ...,
149 return_type: type[_ConfigValueT] = ...,
150 ) -> _ConfigValueT: ...
151
152 @overload
153 async def get_provider_config_value(
154 self,
155 instance_id: str,
156 key: str,
157 *,
158 default: ConfigValueType = ...,
159 return_type: None = ...,
160 ) -> ConfigValueType: ...
161
162 @api_command("config/providers/get_value", required_scope=Scope.CONFIG_PROVIDERS_READ)
163 async def get_provider_config_value(
164 self,
165 instance_id: str,
166 key: str,
167 *,
168 default: ConfigValueType = None,
169 return_type: type[_ConfigValueT | ConfigValueType] | None = None,
170 ) -> _ConfigValueT | ConfigValueType:
171 """
172 Return single configentry value for a provider.
173
174 :param instance_id: The provider instance ID.
175 :param key: The config key to retrieve.
176 :param default: Optional default value to return if key is not found.
177 :param return_type: Optional type hint for type inference (e.g., str, int, bool).
178 Note: This parameter is used purely for static type checking and does not
179 perform runtime type validation. Callers are responsible for ensuring the
180 specified type matches the actual config value type.
181 """
182 # prefer stored value so we don't have to retrieve all config entries every time
183 if (raw_value := self.get_raw_provider_config_value(instance_id, key)) is not None:
184 return raw_value
185 conf = await self.get_provider_config(instance_id)
186 if key not in conf.values:
187 if default is not None:
188 return default
189 msg = f"Config key {key} not found for provider {instance_id}"
190 raise KeyError(msg)
191 return (
192 conf.values[key].value
193 if conf.values[key].value is not None
194 else conf.values[key].default_value
195 )
196
197 @api_command("config/providers/get_entries", required_scope=Scope.CONFIG_PROVIDERS_READ)
198 async def get_provider_config_entries(self, instance_id: str) -> list[ConfigEntry]:
199 """
200 Return the config (options) entries for an existing provider instance.
201
202 Options are resolved from the loaded provider instance (from its current config
203 and capabilities). When the instance is not loaded, only the server-injected
204 default entries are returned - the frontend surfaces the load error and a
205 Reconfigure action for a failed provider instead of an editable options form.
206
207 :param instance_id: The provider instance id.
208 """
209 if provider := self.mass.get_provider(instance_id, return_unavailable=True):
210 return await self._resolve_provider_config_entries(provider)
211 # not loaded: feature-derived and provider-specific entries can't be computed
212 # without the instance, so only the server defaults are returned
213 raw_conf = self.get(f"{CONF_PROVIDERS}/{instance_id}")
214 owner = f"provider.{raw_conf['domain']}" if raw_conf else "common"
215 return _with_translation_owner(list(DEFAULT_PROVIDER_CONFIG_ENTRIES), owner)
216
217 @api_command("config/providers/invoke_action", required_scope=Scope.CONFIG_PROVIDERS_WRITE)
218 async def invoke_provider_config_action(
219 self, instance_id: str, action: str
220 ) -> list[ConfigEntry] | ConfigActionResult:
221 """
222 Run a one-shot action button from a provider's options.
223
224 A ``ConfigActionResult`` holds the outcome to report to the user; an empty list
225 means the action ran with nothing to report; a non-empty list holds the entries
226 the options page should re-render with.
227
228 :param instance_id: The provider instance id (must be loaded).
229 :param action: The action id of the pressed button.
230 """
231 provider = self.mass.get_provider(instance_id, return_unavailable=True)
232 if provider is None:
233 msg = f"Provider {instance_id} is not loaded"
234 raise ActionUnavailable(msg)
235 if (result := await provider.handle_config_action(action)) is None:
236 return []
237 if isinstance(result, ConfigActionResult):
238 result.translation_owner = result.translation_owner or f"provider.{provider.domain}"
239 return result
240 return self._wrap_provider_config_entries(provider, result)
241
242 def seed_stored_config_values(self, config: ProviderConfig) -> None:
243 """
244 Seed a load-time config with its stored raw values so construction-time reads work.
245
246 The provider's real (typed) options entries are only resolvable once the instance
247 exists (get_config_entries is an instance method), so the config built for load only
248 carries the server defaults. A provider may however read its stored option values
249 already in ``setup``/``__init__``; this adds those stored values as passthrough
250 entries so those reads see them. ``rehydrate_provider_config`` then replaces the
251 config with the fully-typed entries before async init.
252
253 :param config: The (load-time) provider config to seed in place.
254 """
255 raw_conf = self.get(f"{CONF_PROVIDERS}/{config.instance_id}") or {}
256 for key, value in (raw_conf.get("values") or {}).items():
257 if key in config.values:
258 continue
259 config.values[key] = ConfigEntry(key=key, type=ConfigEntryType.STRING, value=value)
260
261 async def rehydrate_provider_config(self, provider: Provider) -> None:
262 """
263 Repopulate a freshly-instantiated provider's config with its full declared entries.
264
265 Called during load right after the instance is created: the provider's options
266 entries can only be resolved once the instance exists, so the config the instance
267 was constructed with (built from the server defaults only, since the instance was
268 not yet loaded) is re-parsed against the full entry set here - before validation
269 and async init, so get_config_value reads see the stored values.
270
271 :param provider: The freshly instantiated provider whose config to rehydrate.
272 """
273 raw_conf = self.get(f"{CONF_PROVIDERS}/{provider.instance_id}")
274 if not raw_conf:
275 return
276 entries = await self._resolve_provider_config_entries(provider)
277 provider.config = cast("ProviderConfig", ProviderConfig.parse(entries, raw_conf))
278
279 @api_command("config/providers/save", required_scope=Scope.CONFIG_PROVIDERS_WRITE)
280 async def save_provider_config(
281 self,
282 provider_domain: str,
283 values: dict[str, ConfigValueType],
284 instance_id: str | None = None,
285 ) -> ProviderConfig:
286 """
287 Save changes to an existing Provider(instance) config.
288
289 Adding a new instance goes exclusively through the setup flow
290 (``config/providers/setup``); this endpoint only updates an existing instance.
291
292 :param provider_domain: Domain of the provider (retained for API compatibility).
293 :param values: The raw values for config entries to store/update.
294 :param instance_id: The existing provider instance to update (required).
295 """
296 if instance_id is None:
297 msg = "Adding a provider is only possible through the setup flow"
298 raise ValueError(msg)
299 config = await self._update_provider_config(instance_id, values)
300 # return full config, just in case
301 return await self.get_provider_config(config.instance_id)
302
303 @api_command("config/providers/remove", required_scope=Scope.CONFIG_PROVIDERS_WRITE)
304 async def remove_provider_config(self, instance_id: str) -> None:
305 """Remove ProviderConfig."""
306 conf_key = f"{CONF_PROVIDERS}/{instance_id}"
307 existing = self.get(conf_key)
308 if not existing:
309 msg = f"Provider {instance_id} does not exist"
310 raise KeyError(msg)
311 prov_manifest = self.mass.get_provider_manifest(existing["domain"])
312 if prov_manifest.builtin:
313 msg = f"Builtin provider {prov_manifest.name} can not be removed."
314 raise RuntimeError(msg)
315 self.remove(conf_key)
316 await self.mass.unload_provider(instance_id, True)
317 # a user access filter is an allow-list of provider instance ids, so it must not be
318 # left pointing at a provider that no longer exists
319 await self.mass.webserver.auth.remove_from_user_filters(provider_instance_ids=[instance_id])
320 if existing["type"] == "music":
321 # rewrite shortcuts before cleanup removes the items they point at
322 await self.mass.music.cleanup_provider_shortcuts(instance_id)
323 await self.mass.music.cleanup_provider(instance_id)
324 await self.mass.music.cleanup_library_shortcuts()
325 if existing["type"] == "player":
326 # all players should already be removed by now through unload_provider
327 for player in list(self.mass.players):
328 if player.provider.instance_id != instance_id:
329 continue
330 self.mass.players.delete_player_config(player.player_id)
331 # cleanup remaining player configs
332 for key, player_conf in list(self.get(CONF_PLAYERS, {}).items()):
333 if not isinstance(player_conf, dict):
334 continue
335 if player_conf.get("provider") == instance_id:
336 self.mass.players.delete_player_config(player_conf.get("player_id") or key)
337
338 async def remove_provider_config_value(self, instance_id: str, key: str) -> None:
339 """Remove/reset single Provider config value."""
340 conf_key = f"{CONF_PROVIDERS}/{instance_id}/values/{key}"
341 existing = self.get(conf_key)
342 if not existing:
343 return
344 self.remove(conf_key)
345
346 def set_provider_default_name(self, instance_id: str, default_name: str) -> None:
347 """Set (or update) the default name for a provider."""
348 conf_key = f"{CONF_PROVIDERS}/{instance_id}/default_name"
349 self.set(conf_key, default_name)
350
351 def update_provider_last_error(self, instance_id: str, error: ProviderError | None) -> None:
352 """
353 Persist (or clear) a provider's last_error.
354
355 Only writes if the provider config still exists; this avoids re-creating a
356 config entry that was removed while a load was still in flight, which would
357 leave a stub entry without a domain. See #5728.
358 """
359 conf_key = f"{CONF_PROVIDERS}/{instance_id}"
360 if not self.get(conf_key):
361 return
362 self.set(f"{conf_key}/last_error", error.to_dict() if error else None)
363
364 async def create_builtin_provider_config(self, provider_domain: str) -> None:
365 """
366 Create builtin ProviderConfig.
367
368 This is meant as helper to create default configs for builtin/default providers.
369 Called by the server initialization code which load all providers at startup.
370
371 The config is created with empty values (the options entries can only be resolved
372 once the instance is loaded); validation happens at load time.
373 """
374 for _ in await self.get_provider_configs(provider_domain=provider_domain):
375 # return if there is already any config
376 return
377 for prov in self.mass.get_provider_manifests():
378 if prov.domain == provider_domain:
379 manifest = prov
380 break
381 else:
382 msg = f"Unknown provider domain: {provider_domain}"
383 raise KeyError(msg)
384 if manifest.multi_instance:
385 instance_id = f"{manifest.domain}--{shortuuid.random(8)}"
386 else:
387 instance_id = manifest.domain
388 default_config = cast(
389 "ProviderConfig",
390 ProviderConfig.parse(
391 DEFAULT_PROVIDER_CONFIG_ENTRIES,
392 {
393 "type": manifest.type.value,
394 "domain": manifest.domain,
395 "instance_id": instance_id,
396 "name": manifest.name,
397 "values": {},
398 },
399 ),
400 )
401 conf_key = f"{CONF_PROVIDERS}/{default_config.instance_id}"
402 self.set_default(conf_key, default_config.to_raw())
403
404 if TYPE_CHECKING:
405 # Overload for when default is provided - return type matches default type
406 @overload
407 def get_raw_provider_config_value(
408 self, provider_instance: str, key: str, default: _ConfigValueT
409 ) -> _ConfigValueT: ...
410
411 # Overload for when no default is provided - return ConfigValueType | None
412 @overload
413 def get_raw_provider_config_value(
414 self, provider_instance: str, key: str, default: None = None
415 ) -> ConfigValueType | None: ...
416
417 def get_raw_provider_config_value(
418 self, provider_instance: str, key: str, default: ConfigValueType = None
419 ) -> ConfigValueType:
420 """
421 Return (raw) single config(entry) value for a provider.
422
423 Note that this only returns the stored value without any validation or default.
424 """
425 return cast(
426 "ConfigValueType",
427 self.get(
428 f"{CONF_PROVIDERS}/{provider_instance}/values/{key}",
429 self.get(f"{CONF_PROVIDERS}/{provider_instance}/{key}", default),
430 ),
431 )
432
433 def get_provider_setup_value(
434 self, instance_id: str, key: str, default: ConfigValueType = None
435 ) -> ConfigValueType:
436 """
437 Return a single (decrypted) setup_data value for a provider from storage.
438
439 Returns the given default when the key is not present in setup_data.
440 Works without a loaded provider instance.
441
442 :param instance_id: The provider instance ID.
443 :param key: The setup data key to retrieve.
444 :param default: Value to return when the key is not present in setup_data.
445 """
446 setup_data = self.get(f"{CONF_PROVIDERS}/{instance_id}/setup_data") or {}
447 if key not in setup_data:
448 return default
449 value = cast("ConfigValueType", setup_data[key])
450 if isinstance(value, str):
451 return self.decrypt_string(value)
452 return value
453
454 def set_raw_provider_config_value(
455 self,
456 provider_instance: str,
457 key: str,
458 value: ConfigValueType,
459 encrypted: bool = False,
460 immediate: bool = False,
461 ) -> None:
462 """
463 Set (raw) single config(entry) value for a provider.
464
465 Note that this only stores the (raw) value without any validation or default.
466 When immediate is set the value is flushed to disk right away instead of on the
467 debounced save timer, so a critical value (e.g. a rotated auth token) is not lost
468 if the process is killed within the debounce window.
469 """
470 if not self.get(f"{CONF_PROVIDERS}/{provider_instance}"):
471 # only allow setting raw values if main entry exists
472 msg = f"Invalid provider_instance: {provider_instance}"
473 raise KeyError(msg)
474 if encrypted:
475 if not isinstance(value, str):
476 msg = f"Cannot encrypt non-string value for key {key}"
477 raise ValueError(msg)
478 value = self.encrypt_string(value)
479 if key in BASE_KEYS:
480 self.set(f"{CONF_PROVIDERS}/{provider_instance}/{key}", value, immediate=immediate)
481 return
482 self.set(f"{CONF_PROVIDERS}/{provider_instance}/values/{key}", value, immediate=immediate)
483 # also update the loaded provider's in-place config copy so object-local value
484 # reads stay in sync with raw writes; include unavailable instances, since values
485 # like a rotated auth token can be written while the provider is temporarily
486 # unavailable and its copy must not lag behind the stored value
487 if (provider := self.mass.get_provider(provider_instance, return_unavailable=True)) and (
488 entry := provider.config.values.get(key)
489 ):
490 entry.value = value
491
492 @api_command("config/providers/reload", required_scope=Scope.CONFIG_PROVIDERS_WRITE)
493 async def _reload_provider(self, instance_id: str) -> None:
494 """Reload provider."""
495 try:
496 config = await self.get_provider_config(instance_id)
497 except KeyError:
498 # Edge case: Provider was removed before we could reload it
499 return
500 await self.mass.load_provider_config(config)
501
502 async def _update_provider_config(
503 self, instance_id: str, values: dict[str, ConfigValueType]
504 ) -> ProviderConfig:
505 """Update ProviderConfig."""
506 config = await self.get_provider_config(instance_id)
507 changed_keys = config.update(values)
508 prov_instance = self.mass.get_provider(instance_id)
509 available = prov_instance.available if prov_instance else False
510 if not changed_keys and (config.enabled == available):
511 # no changes
512 return config
513 # validate the new config
514 config.validate()
515 # save the config first to prevent issues when the
516 # provider wants to manipulate the config during load
517 conf_key = f"{CONF_PROVIDERS}/{config.instance_id}"
518 raw_conf = config.to_raw()
519 # Preserve stored values that don't have config entries in the current context
520 # (e.g. values written by a provider at runtime while its declared entries
521 # changed) - to_raw() only rebuilds the values from the declared entries.
522 existing_values = (self.get(conf_key) or {}).get("values", {})
523 new_values = raw_conf.get("values", {})
524 config_entry_keys = set(config.values.keys())
525 for key, value in existing_values.items():
526 if key not in new_values and key not in config_entry_keys:
527 new_values[key] = value
528 raw_conf["values"] = new_values
529 self.set(conf_key, raw_conf)
530 if config.enabled and prov_instance and available:
531 # update config for existing/loaded provider instance
532 await prov_instance.update_config(config, changed_keys)
533 # push instance name to config (to persist it if it was autogenerated)
534 if prov_instance.default_name != config.default_name:
535 self.set_provider_default_name(
536 prov_instance.instance_id, prov_instance.default_name
537 )
538 if "name" in changed_keys:
539 # signal providers updated so frontends refresh the provider name
540 self.mass.signal_event(EventType.PROVIDERS_UPDATED, data=self.mass.get_providers())
541 elif config.enabled:
542 # provider is enabled but not available, try to load it
543 await self.mass.load_provider_config(config)
544 else:
545 # disable provider
546 prov_manifest = self.mass.get_provider_manifest(config.domain)
547 if not prov_manifest.allow_disable:
548 msg = "Provider can not be disabled."
549 raise RuntimeError(msg)
550 # also unload any other providers dependent of this provider
551 for dep_prov in self.mass.providers:
552 if dep_prov.manifest.depends_on == config.domain:
553 await self.mass.unload_provider(dep_prov.instance_id)
554 await self.mass.unload_provider(config.instance_id)
555 # For player providers, unload_provider should have removed all its players by now
556 return config
557
558 async def _create_provider_instance(
559 self,
560 provider_domain: str,
561 values: dict[str, ConfigValueType],
562 setup_data: dict[str, Any] | None = None,
563 ) -> ProviderConfig:
564 """
565 Create, persist and load a new provider instance.
566
567 Shared creation tail used by both the provider config save path and the
568 setup flow finish path. The created config is removed again when loading
569 the provider with it fails.
570
571 :param provider_domain: Domain of the provider to create an instance of.
572 :param values: The raw values for the (options) config entries.
573 :param setup_data: Optional setup flow data (pre-encrypted) to store on the config.
574 """
575 for prov in self.mass.get_provider_manifests():
576 if prov.domain == provider_domain:
577 manifest = prov
578 break
579 else:
580 msg = f"Unknown provider domain: {provider_domain}"
581 raise KeyError(msg)
582 # create new provider config with given values
583 existing = {
584 x.instance_id for x in await self.get_provider_configs(provider_domain=provider_domain)
585 }
586 # determine instance id based on previous configs
587 if existing and not manifest.multi_instance:
588 msg = f"Provider {manifest.name} does not support multiple instances"
589 raise ValueError(msg)
590 if manifest.multi_instance:
591 instance_id = f"{manifest.domain}--{shortuuid.random(8)}"
592 else:
593 instance_id = manifest.domain
594 # Create the config with only the server-default entries (no provider options: those
595 # can only be resolved once the instance is loaded, since get_config_entries is an
596 # instance method). The defaults carry the log-level entry the provider reads in
597 # __init__; the passed values are persisted raw and full validation is deferred to
598 # load time (see _load_provider -> rehydrate_provider_config). Setup flows collect
599 # their input into setup_data.
600 config = cast(
601 "ProviderConfig",
602 ProviderConfig.parse(
603 DEFAULT_PROVIDER_CONFIG_ENTRIES,
604 {
605 "type": manifest.type.value,
606 "domain": manifest.domain,
607 "instance_id": instance_id,
608 "default_name": manifest.name,
609 "values": values,
610 "setup_data": setup_data or {},
611 },
612 ),
613 )
614 # save the config first to prevent issues when the
615 # provider wants to manipulate the config during load
616 conf_key = f"{CONF_PROVIDERS}/{config.instance_id}"
617 raw_conf = config.to_raw()
618 # to_raw rebuilds values from the (currently empty) declared entries, so persist
619 # the raw values explicitly to keep any values passed by the caller
620 raw_conf["values"] = values
621 self.set(conf_key, raw_conf)
622 # try to load the provider
623 try:
624 await self.mass.load_provider_config(config)
625 except asyncio.CancelledError:
626 # a cancelled load (e.g. an aborted setup flow) must not leave a
627 # half-created config behind either
628 self.remove(conf_key)
629 raise
630 except Exception:
631 # loading failed, remove config
632 self.remove(conf_key)
633 raise
634 if not self.onboard_done:
635 # mark onboard as complete as soon as the first provider is added
636 await self.set_onboard_complete()
637 if manifest.type == ProviderType.MUSIC:
638 # correct any multi-instance provider mappings
639 self.mass.music.queue_provider_mapping_correction_task()
640 return config
641
642 async def _resolve_provider_config_entries(self, provider: Provider) -> list[ConfigEntry]:
643 """Return the full config-entry set for a (loaded) provider instance."""
644 return self._wrap_provider_config_entries(provider, await provider.get_config_entries())
645
646 def _wrap_provider_config_entries(
647 self, provider: Provider, provider_entries: tuple[ConfigEntry, ...]
648 ) -> list[ConfigEntry]:
649 """Wrap a provider's own entries with the server defaults + feature-derived entries."""
650 extra_entries = self._build_sync_entries(
651 provider.manifest, provider.supported_features, provider
652 )
653 all_entries = [
654 *DEFAULT_PROVIDER_CONFIG_ENTRIES,
655 *extra_entries,
656 *provider_entries,
657 ]
658 return _with_translation_owner(all_entries, f"provider.{provider.domain}")
659
660 def _build_sync_entries(
661 self,
662 manifest: Any,
663 supported_features: builtins.set[ProviderFeature],
664 provider: Any,
665 ) -> list[ConfigEntry]:
666 """Build sync-related ConfigEntry list based on provider features."""
667 if manifest.type != ProviderType.MUSIC:
668 return []
669 extra_entries: list[ConfigEntry] = []
670 # library sync settings
671 if ProviderFeature.LIBRARY_ARTISTS in supported_features:
672 extra_entries.append(CONF_ENTRY_LIBRARY_SYNC_ARTISTS)
673 if ProviderFeature.LIBRARY_ALBUMS in supported_features:
674 extra_entries.append(CONF_ENTRY_LIBRARY_SYNC_ALBUMS)
675 if provider and isinstance(provider, MusicProvider) and provider.is_streaming_provider:
676 extra_entries.append(CONF_ENTRY_LIBRARY_SYNC_ALBUM_TRACKS)
677 if ProviderFeature.LIBRARY_TRACKS in supported_features:
678 extra_entries.append(CONF_ENTRY_LIBRARY_SYNC_TRACKS)
679 if ProviderFeature.LIBRARY_PLAYLISTS in supported_features:
680 extra_entries.append(CONF_ENTRY_LIBRARY_SYNC_PLAYLISTS)
681 if provider and isinstance(provider, MusicProvider) and provider.is_streaming_provider:
682 extra_entries.append(CONF_ENTRY_LIBRARY_SYNC_PLAYLIST_TRACKS)
683 if ProviderFeature.LIBRARY_AUDIOBOOKS in supported_features:
684 extra_entries.append(CONF_ENTRY_LIBRARY_SYNC_AUDIOBOOKS)
685 if ProviderFeature.LIBRARY_PODCASTS in supported_features:
686 extra_entries.append(CONF_ENTRY_LIBRARY_SYNC_PODCASTS)
687 if ProviderFeature.LIBRARY_RADIOS in supported_features:
688 extra_entries.append(CONF_ENTRY_LIBRARY_SYNC_RADIOS)
689 # sync export settings
690 if supported_features.intersection(
691 {
692 ProviderFeature.LIBRARY_ARTISTS_EDIT,
693 ProviderFeature.LIBRARY_ALBUMS_EDIT,
694 ProviderFeature.LIBRARY_TRACKS_EDIT,
695 ProviderFeature.LIBRARY_PLAYLISTS_EDIT,
696 ProviderFeature.LIBRARY_AUDIOBOOKS_EDIT,
697 ProviderFeature.LIBRARY_PODCASTS_EDIT,
698 ProviderFeature.LIBRARY_RADIOS_EDIT,
699 }
700 ):
701 extra_entries.append(CONF_ENTRY_LIBRARY_SYNC_BACK)
702 if (
703 provider
704 and isinstance(provider, MusicProvider)
705 and provider.is_streaming_provider
706 and supported_features.intersection(
707 {
708 ProviderFeature.LIBRARY_ARTISTS,
709 ProviderFeature.LIBRARY_ALBUMS,
710 ProviderFeature.LIBRARY_TRACKS,
711 ProviderFeature.LIBRARY_PLAYLISTS,
712 ProviderFeature.LIBRARY_AUDIOBOOKS,
713 ProviderFeature.LIBRARY_PODCASTS,
714 ProviderFeature.LIBRARY_RADIOS,
715 }
716 )
717 ):
718 extra_entries.append(CONF_ENTRY_LIBRARY_SYNC_DELETIONS)
719 return extra_entries
720