/
/
/
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 if existing["type"] == "music":
318 # cleanup entries in library
319 await self.mass.music.cleanup_provider(instance_id)
320 if existing["type"] == "player":
321 # all players should already be removed by now through unload_provider
322 for player in list(self.mass.players):
323 if player.provider.instance_id != instance_id:
324 continue
325 self.mass.players.delete_player_config(player.player_id)
326 # cleanup remaining player configs
327 for key, player_conf in list(self.get(CONF_PLAYERS, {}).items()):
328 if not isinstance(player_conf, dict):
329 continue
330 if player_conf.get("provider") == instance_id:
331 self.mass.players.delete_player_config(player_conf.get("player_id") or key)
332
333 async def remove_provider_config_value(self, instance_id: str, key: str) -> None:
334 """Remove/reset single Provider config value."""
335 conf_key = f"{CONF_PROVIDERS}/{instance_id}/values/{key}"
336 existing = self.get(conf_key)
337 if not existing:
338 return
339 self.remove(conf_key)
340
341 def set_provider_default_name(self, instance_id: str, default_name: str) -> None:
342 """Set (or update) the default name for a provider."""
343 conf_key = f"{CONF_PROVIDERS}/{instance_id}/default_name"
344 self.set(conf_key, default_name)
345
346 def update_provider_last_error(self, instance_id: str, error: ProviderError | None) -> None:
347 """
348 Persist (or clear) a provider's last_error.
349
350 Only writes if the provider config still exists; this avoids re-creating a
351 config entry that was removed while a load was still in flight, which would
352 leave a stub entry without a domain. See #5728.
353 """
354 conf_key = f"{CONF_PROVIDERS}/{instance_id}"
355 if not self.get(conf_key):
356 return
357 self.set(f"{conf_key}/last_error", error.to_dict() if error else None)
358
359 async def create_builtin_provider_config(self, provider_domain: str) -> None:
360 """
361 Create builtin ProviderConfig.
362
363 This is meant as helper to create default configs for builtin/default providers.
364 Called by the server initialization code which load all providers at startup.
365
366 The config is created with empty values (the options entries can only be resolved
367 once the instance is loaded); validation happens at load time.
368 """
369 for _ in await self.get_provider_configs(provider_domain=provider_domain):
370 # return if there is already any config
371 return
372 for prov in self.mass.get_provider_manifests():
373 if prov.domain == provider_domain:
374 manifest = prov
375 break
376 else:
377 msg = f"Unknown provider domain: {provider_domain}"
378 raise KeyError(msg)
379 if manifest.multi_instance:
380 instance_id = f"{manifest.domain}--{shortuuid.random(8)}"
381 else:
382 instance_id = manifest.domain
383 default_config = cast(
384 "ProviderConfig",
385 ProviderConfig.parse(
386 DEFAULT_PROVIDER_CONFIG_ENTRIES,
387 {
388 "type": manifest.type.value,
389 "domain": manifest.domain,
390 "instance_id": instance_id,
391 "name": manifest.name,
392 "values": {},
393 },
394 ),
395 )
396 conf_key = f"{CONF_PROVIDERS}/{default_config.instance_id}"
397 self.set_default(conf_key, default_config.to_raw())
398
399 if TYPE_CHECKING:
400 # Overload for when default is provided - return type matches default type
401 @overload
402 def get_raw_provider_config_value(
403 self, provider_instance: str, key: str, default: _ConfigValueT
404 ) -> _ConfigValueT: ...
405
406 # Overload for when no default is provided - return ConfigValueType | None
407 @overload
408 def get_raw_provider_config_value(
409 self, provider_instance: str, key: str, default: None = None
410 ) -> ConfigValueType | None: ...
411
412 def get_raw_provider_config_value(
413 self, provider_instance: str, key: str, default: ConfigValueType = None
414 ) -> ConfigValueType:
415 """
416 Return (raw) single config(entry) value for a provider.
417
418 Note that this only returns the stored value without any validation or default.
419 """
420 return cast(
421 "ConfigValueType",
422 self.get(
423 f"{CONF_PROVIDERS}/{provider_instance}/values/{key}",
424 self.get(f"{CONF_PROVIDERS}/{provider_instance}/{key}", default),
425 ),
426 )
427
428 def get_provider_setup_value(
429 self, instance_id: str, key: str, default: ConfigValueType = None
430 ) -> ConfigValueType:
431 """
432 Return a single (decrypted) setup_data value for a provider from storage.
433
434 Returns the given default when the key is not present in setup_data.
435 Works without a loaded provider instance.
436
437 :param instance_id: The provider instance ID.
438 :param key: The setup data key to retrieve.
439 :param default: Value to return when the key is not present in setup_data.
440 """
441 setup_data = self.get(f"{CONF_PROVIDERS}/{instance_id}/setup_data") or {}
442 if key not in setup_data:
443 return default
444 value = cast("ConfigValueType", setup_data[key])
445 if isinstance(value, str):
446 return self.decrypt_string(value)
447 return value
448
449 def set_raw_provider_config_value(
450 self,
451 provider_instance: str,
452 key: str,
453 value: ConfigValueType,
454 encrypted: bool = False,
455 immediate: bool = False,
456 ) -> None:
457 """
458 Set (raw) single config(entry) value for a provider.
459
460 Note that this only stores the (raw) value without any validation or default.
461 When immediate is set the value is flushed to disk right away instead of on the
462 debounced save timer, so a critical value (e.g. a rotated auth token) is not lost
463 if the process is killed within the debounce window.
464 """
465 if not self.get(f"{CONF_PROVIDERS}/{provider_instance}"):
466 # only allow setting raw values if main entry exists
467 msg = f"Invalid provider_instance: {provider_instance}"
468 raise KeyError(msg)
469 if encrypted:
470 if not isinstance(value, str):
471 msg = f"Cannot encrypt non-string value for key {key}"
472 raise ValueError(msg)
473 value = self.encrypt_string(value)
474 if key in BASE_KEYS:
475 self.set(f"{CONF_PROVIDERS}/{provider_instance}/{key}", value, immediate=immediate)
476 return
477 self.set(f"{CONF_PROVIDERS}/{provider_instance}/values/{key}", value, immediate=immediate)
478 # also update the loaded provider's in-place config copy so object-local value
479 # reads stay in sync with raw writes; include unavailable instances, since values
480 # like a rotated auth token can be written while the provider is temporarily
481 # unavailable and its copy must not lag behind the stored value
482 if (provider := self.mass.get_provider(provider_instance, return_unavailable=True)) and (
483 entry := provider.config.values.get(key)
484 ):
485 entry.value = value
486
487 @api_command("config/providers/reload", required_scope=Scope.CONFIG_PROVIDERS_WRITE)
488 async def _reload_provider(self, instance_id: str) -> None:
489 """Reload provider."""
490 try:
491 config = await self.get_provider_config(instance_id)
492 except KeyError:
493 # Edge case: Provider was removed before we could reload it
494 return
495 await self.mass.load_provider_config(config)
496
497 async def _update_provider_config(
498 self, instance_id: str, values: dict[str, ConfigValueType]
499 ) -> ProviderConfig:
500 """Update ProviderConfig."""
501 config = await self.get_provider_config(instance_id)
502 changed_keys = config.update(values)
503 prov_instance = self.mass.get_provider(instance_id)
504 available = prov_instance.available if prov_instance else False
505 if not changed_keys and (config.enabled == available):
506 # no changes
507 return config
508 # validate the new config
509 config.validate()
510 # save the config first to prevent issues when the
511 # provider wants to manipulate the config during load
512 conf_key = f"{CONF_PROVIDERS}/{config.instance_id}"
513 raw_conf = config.to_raw()
514 # Preserve stored values that don't have config entries in the current context
515 # (e.g. values written by a provider at runtime while its declared entries
516 # changed) - to_raw() only rebuilds the values from the declared entries.
517 existing_values = (self.get(conf_key) or {}).get("values", {})
518 new_values = raw_conf.get("values", {})
519 config_entry_keys = set(config.values.keys())
520 for key, value in existing_values.items():
521 if key not in new_values and key not in config_entry_keys:
522 new_values[key] = value
523 raw_conf["values"] = new_values
524 self.set(conf_key, raw_conf)
525 if config.enabled and prov_instance and available:
526 # update config for existing/loaded provider instance
527 await prov_instance.update_config(config, changed_keys)
528 # push instance name to config (to persist it if it was autogenerated)
529 if prov_instance.default_name != config.default_name:
530 self.set_provider_default_name(
531 prov_instance.instance_id, prov_instance.default_name
532 )
533 if "name" in changed_keys:
534 # signal providers updated so frontends refresh the provider name
535 self.mass.signal_event(EventType.PROVIDERS_UPDATED, data=self.mass.get_providers())
536 elif config.enabled:
537 # provider is enabled but not available, try to load it
538 await self.mass.load_provider_config(config)
539 else:
540 # disable provider
541 prov_manifest = self.mass.get_provider_manifest(config.domain)
542 if not prov_manifest.allow_disable:
543 msg = "Provider can not be disabled."
544 raise RuntimeError(msg)
545 # also unload any other providers dependent of this provider
546 for dep_prov in self.mass.providers:
547 if dep_prov.manifest.depends_on == config.domain:
548 await self.mass.unload_provider(dep_prov.instance_id)
549 await self.mass.unload_provider(config.instance_id)
550 # For player providers, unload_provider should have removed all its players by now
551 return config
552
553 async def _create_provider_instance(
554 self,
555 provider_domain: str,
556 values: dict[str, ConfigValueType],
557 setup_data: dict[str, Any] | None = None,
558 ) -> ProviderConfig:
559 """
560 Create, persist and load a new provider instance.
561
562 Shared creation tail used by both the provider config save path and the
563 setup flow finish path. The created config is removed again when loading
564 the provider with it fails.
565
566 :param provider_domain: Domain of the provider to create an instance of.
567 :param values: The raw values for the (options) config entries.
568 :param setup_data: Optional setup flow data (pre-encrypted) to store on the config.
569 """
570 for prov in self.mass.get_provider_manifests():
571 if prov.domain == provider_domain:
572 manifest = prov
573 break
574 else:
575 msg = f"Unknown provider domain: {provider_domain}"
576 raise KeyError(msg)
577 # create new provider config with given values
578 existing = {
579 x.instance_id for x in await self.get_provider_configs(provider_domain=provider_domain)
580 }
581 # determine instance id based on previous configs
582 if existing and not manifest.multi_instance:
583 msg = f"Provider {manifest.name} does not support multiple instances"
584 raise ValueError(msg)
585 if manifest.multi_instance:
586 instance_id = f"{manifest.domain}--{shortuuid.random(8)}"
587 else:
588 instance_id = manifest.domain
589 # Create the config with only the server-default entries (no provider options: those
590 # can only be resolved once the instance is loaded, since get_config_entries is an
591 # instance method). The defaults carry the log-level entry the provider reads in
592 # __init__; the passed values are persisted raw and full validation is deferred to
593 # load time (see _load_provider -> rehydrate_provider_config). Setup flows collect
594 # their input into setup_data.
595 config = cast(
596 "ProviderConfig",
597 ProviderConfig.parse(
598 DEFAULT_PROVIDER_CONFIG_ENTRIES,
599 {
600 "type": manifest.type.value,
601 "domain": manifest.domain,
602 "instance_id": instance_id,
603 "default_name": manifest.name,
604 "values": values,
605 "setup_data": setup_data or {},
606 },
607 ),
608 )
609 # save the config first to prevent issues when the
610 # provider wants to manipulate the config during load
611 conf_key = f"{CONF_PROVIDERS}/{config.instance_id}"
612 raw_conf = config.to_raw()
613 # to_raw rebuilds values from the (currently empty) declared entries, so persist
614 # the raw values explicitly to keep any values passed by the caller
615 raw_conf["values"] = values
616 self.set(conf_key, raw_conf)
617 # try to load the provider
618 try:
619 await self.mass.load_provider_config(config)
620 except asyncio.CancelledError:
621 # a cancelled load (e.g. an aborted setup flow) must not leave a
622 # half-created config behind either
623 self.remove(conf_key)
624 raise
625 except Exception:
626 # loading failed, remove config
627 self.remove(conf_key)
628 raise
629 if not self.onboard_done:
630 # mark onboard as complete as soon as the first provider is added
631 await self.set_onboard_complete()
632 if manifest.type == ProviderType.MUSIC:
633 # correct any multi-instance provider mappings
634 self.mass.music.queue_provider_mapping_correction_task()
635 return config
636
637 async def _resolve_provider_config_entries(self, provider: Provider) -> list[ConfigEntry]:
638 """Return the full config-entry set for a (loaded) provider instance."""
639 return self._wrap_provider_config_entries(provider, await provider.get_config_entries())
640
641 def _wrap_provider_config_entries(
642 self, provider: Provider, provider_entries: tuple[ConfigEntry, ...]
643 ) -> list[ConfigEntry]:
644 """Wrap a provider's own entries with the server defaults + feature-derived entries."""
645 extra_entries = self._build_sync_entries(
646 provider.manifest, provider.supported_features, provider
647 )
648 all_entries = [
649 *DEFAULT_PROVIDER_CONFIG_ENTRIES,
650 *extra_entries,
651 *provider_entries,
652 ]
653 return _with_translation_owner(all_entries, f"provider.{provider.domain}")
654
655 def _build_sync_entries(
656 self,
657 manifest: Any,
658 supported_features: builtins.set[ProviderFeature],
659 provider: Any,
660 ) -> list[ConfigEntry]:
661 """Build sync-related ConfigEntry list based on provider features."""
662 if manifest.type != ProviderType.MUSIC:
663 return []
664 extra_entries: list[ConfigEntry] = []
665 # library sync settings
666 if ProviderFeature.LIBRARY_ARTISTS in supported_features:
667 extra_entries.append(CONF_ENTRY_LIBRARY_SYNC_ARTISTS)
668 if ProviderFeature.LIBRARY_ALBUMS in supported_features:
669 extra_entries.append(CONF_ENTRY_LIBRARY_SYNC_ALBUMS)
670 if provider and isinstance(provider, MusicProvider) and provider.is_streaming_provider:
671 extra_entries.append(CONF_ENTRY_LIBRARY_SYNC_ALBUM_TRACKS)
672 if ProviderFeature.LIBRARY_TRACKS in supported_features:
673 extra_entries.append(CONF_ENTRY_LIBRARY_SYNC_TRACKS)
674 if ProviderFeature.LIBRARY_PLAYLISTS in supported_features:
675 extra_entries.append(CONF_ENTRY_LIBRARY_SYNC_PLAYLISTS)
676 if provider and isinstance(provider, MusicProvider) and provider.is_streaming_provider:
677 extra_entries.append(CONF_ENTRY_LIBRARY_SYNC_PLAYLIST_TRACKS)
678 if ProviderFeature.LIBRARY_AUDIOBOOKS in supported_features:
679 extra_entries.append(CONF_ENTRY_LIBRARY_SYNC_AUDIOBOOKS)
680 if ProviderFeature.LIBRARY_PODCASTS in supported_features:
681 extra_entries.append(CONF_ENTRY_LIBRARY_SYNC_PODCASTS)
682 if ProviderFeature.LIBRARY_RADIOS in supported_features:
683 extra_entries.append(CONF_ENTRY_LIBRARY_SYNC_RADIOS)
684 # sync export settings
685 if supported_features.intersection(
686 {
687 ProviderFeature.LIBRARY_ARTISTS_EDIT,
688 ProviderFeature.LIBRARY_ALBUMS_EDIT,
689 ProviderFeature.LIBRARY_TRACKS_EDIT,
690 ProviderFeature.LIBRARY_PLAYLISTS_EDIT,
691 ProviderFeature.LIBRARY_AUDIOBOOKS_EDIT,
692 ProviderFeature.LIBRARY_PODCASTS_EDIT,
693 ProviderFeature.LIBRARY_RADIOS_EDIT,
694 }
695 ):
696 extra_entries.append(CONF_ENTRY_LIBRARY_SYNC_BACK)
697 if (
698 provider
699 and isinstance(provider, MusicProvider)
700 and provider.is_streaming_provider
701 and supported_features.intersection(
702 {
703 ProviderFeature.LIBRARY_ARTISTS,
704 ProviderFeature.LIBRARY_ALBUMS,
705 ProviderFeature.LIBRARY_TRACKS,
706 ProviderFeature.LIBRARY_PLAYLISTS,
707 ProviderFeature.LIBRARY_AUDIOBOOKS,
708 ProviderFeature.LIBRARY_PODCASTS,
709 ProviderFeature.LIBRARY_RADIOS,
710 }
711 )
712 ):
713 extra_entries.append(CONF_ENTRY_LIBRARY_SYNC_DELETIONS)
714 return extra_entries
715