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