/
/
/
1"""Player configuration handling for the ConfigController."""
2
3from __future__ import annotations
4
5import asyncio
6import logging
7from copy import deepcopy
8from typing import TYPE_CHECKING, Any, Literal, cast, overload
9
10from music_assistant_models.auth import Scope
11from music_assistant_models.config_entries import (
12 ConfigActionResult,
13 ConfigEntry,
14 ConfigValueOption,
15 ConfigValueType,
16 PlayerConfig,
17)
18from music_assistant_models.constants import (
19 PLAYER_CONTROL_FAKE,
20 PLAYER_CONTROL_NATIVE,
21 PLAYER_CONTROL_NONE,
22)
23from music_assistant_models.enums import (
24 ConfigEntryType,
25 EventType,
26 PlayerFeature,
27 PlayerType,
28)
29from music_assistant_models.errors import (
30 ActionUnavailable,
31 UnsupportedFeaturedException,
32)
33
34from music_assistant.constants import (
35 CONF_ENABLED,
36 CONF_ENTRY_ANNOUNCE_VOLUME,
37 CONF_ENTRY_ANNOUNCE_VOLUME_MAX,
38 CONF_ENTRY_ANNOUNCE_VOLUME_MIN,
39 CONF_ENTRY_ANNOUNCE_VOLUME_STRATEGY,
40 CONF_ENTRY_AUTO_PLAY,
41 CONF_ENTRY_CROSSFADE_DIFFERENT_SAMPLE_RATES,
42 CONF_ENTRY_ENABLE_ICY_METADATA,
43 CONF_ENTRY_FLOW_MODE,
44 CONF_ENTRY_FLOW_MODE_SAMPLE_RATE,
45 CONF_ENTRY_HTTP_PROFILE,
46 CONF_ENTRY_MAX_VOLUME,
47 CONF_ENTRY_MIN_VOLUME,
48 CONF_ENTRY_OUTPUT_CHANNELS,
49 CONF_ENTRY_OUTPUT_CODEC,
50 CONF_ENTRY_PLAY_MEDIA_OVERRIDES_GROUP,
51 CONF_ENTRY_PLAYER_ICON,
52 CONF_ENTRY_PLAYER_ICON_GROUP,
53 CONF_ENTRY_PREFER_WAV_FOR_LIVE_SOURCES,
54 CONF_ENTRY_SAMPLE_RATES,
55 CONF_ENTRY_TTS_PRE_ANNOUNCE,
56 CONF_EXPOSE_PLAYER_TO_HA,
57 CONF_HIDE_IN_UI,
58 CONF_ICON,
59 CONF_MUTE_CONTROL,
60 CONF_PLAYERS,
61 CONF_PLUGIN_KEY_SPLITTER,
62 CONF_POWER_CONTROL,
63 CONF_PRE_ANNOUNCE_CHIME_URL,
64 CONF_PREFERRED_OUTPUT_PROTOCOL,
65 CONF_PROTOCOL_CATEGORY_PREFIX,
66 CONF_PROTOCOL_KEY_SPLITTER,
67 CONF_UNDERLYING_PLAYER_ID,
68 CONF_VOLUME_CONTROL,
69 NON_HTTP_PROVIDERS,
70 PLAYER_CONTROL_PROTOCOL,
71)
72from music_assistant.controllers.config.constants import BASE_KEYS, _ConfigValueT
73from music_assistant.controllers.config.helpers import _with_translation_owner
74from music_assistant.helpers.api import api_command
75from music_assistant.helpers.config_entries import CONF_CONNECTED_PLAYERS, PLAYBACK_TARGET_TYPES
76from music_assistant.helpers.util import validate_announcement_chime_url
77from music_assistant.models.plugin import PluginProvider
78from music_assistant.providers.sync_group.constants import SGP_PREFIX
79from music_assistant.providers.universal_group.constants import UGP_PREFIX
80
81if TYPE_CHECKING:
82 from music_assistant_models.player import OutputProtocol
83
84 from music_assistant import MusicAssistant
85 from music_assistant.models.player import Player
86
87
88LOGGER = logging.getLogger(__name__)
89
90
91def _first_enabled_control_value(options: list[ConfigValueOption]) -> ConfigValueType:
92 """
93 Return the value of the first selectable option, so a disabled option is never the default.
94
95 Player-control selects always list the "native" option (shown disabled when the feature is
96 unsupported); this picks the first enabled option as the entry default and falls back to the
97 always-present "none" control.
98
99 :param options: The control entry's options, in display order.
100 """
101 for option in options:
102 if not option.disabled:
103 return option.value
104 return PLAYER_CONTROL_NONE
105
106
107def _reconcile_player_icon_value(
108 submitted_values: dict[str, ConfigValueType],
109 stored_values: dict[str, Any],
110 new_values: dict[str, ConfigValueType],
111) -> bool:
112 """Persist explicit icon selections while keeping None as automatic selection."""
113 stored_icon = stored_values.get(CONF_ICON)
114 has_explicit_icon = isinstance(stored_icon, str) and bool(stored_icon)
115 if CONF_ICON not in submitted_values:
116 if has_explicit_icon:
117 new_values[CONF_ICON] = stored_icon
118 else:
119 new_values.pop(CONF_ICON, None)
120 return False
121
122 submitted_icon = submitted_values[CONF_ICON]
123 if isinstance(submitted_icon, str) and submitted_icon:
124 new_values[CONF_ICON] = submitted_icon
125 return not has_explicit_icon or submitted_icon != stored_icon
126
127 new_values.pop(CONF_ICON, None)
128 return has_explicit_icon
129
130
131def _apply_raw_player_icon_value(
132 config: PlayerConfig,
133 raw_values: dict[str, Any],
134) -> None:
135 """Apply the stored icon value to a parsed player config."""
136 if icon_entry := config.values.get(CONF_ICON):
137 stored_icon = raw_values.get(CONF_ICON)
138 icon_entry.value = stored_icon if isinstance(stored_icon, str) and stored_icon else None
139
140
141class PlayerConfigMixin:
142 """Mixin providing player configuration handling for the ConfigController."""
143
144 # Type hints for attributes/methods provided by the class this mixin is used with
145 if TYPE_CHECKING:
146 mass: MusicAssistant
147
148 def get(self, key: str, default: Any = None) -> Any: ... # noqa: D102
149
150 def set(self, key: str, value: Any) -> None: ... # noqa: D102
151
152 def remove(self, key: str) -> None: ... # noqa: D102
153
154 @api_command("config/players", required_scope=Scope.CONFIG_PLAYERS_READ)
155 async def get_player_configs(
156 self,
157 provider: str | None = None,
158 include_values: bool = False,
159 include_unavailable: bool = True,
160 include_disabled: bool = True,
161 ) -> list[PlayerConfig]:
162 """Return all known player configurations, optionally filtered by provider id."""
163 result: list[PlayerConfig] = []
164 for key, raw_conf in list(self.get(CONF_PLAYERS, {}).items()):
165 # guard against malformed entries that lost their base keys
166 # (can happen via race between delete_player_config and a stale player
167 # update writing back a nested sub-key, which recreates a partial dict).
168 if not isinstance(raw_conf, dict) or "player_id" not in raw_conf:
169 LOGGER.warning("Removing malformed player config entry %s (missing player_id)", key)
170 self.remove(f"{CONF_PLAYERS}/{key}")
171 continue
172 # optional provider filter
173 if provider is not None and raw_conf.get("provider") != provider:
174 continue
175 # filter out unavailable players
176 # (unless disabled, otherwise there is no way to re-enable them)
177 # note that we only check for missing players in the player controller,
178 # and we do allow players that are temporary unavailable
179 # (player.state.available = false) because this can also mean that the
180 # player needs additional configuration such as airplay devices that need pairing.
181 player = self.mass.players.get_player(raw_conf["player_id"], False)
182 if not include_unavailable and player is None and raw_conf.get("enabled", True):
183 continue
184 # filter out protocol players
185 # their configuration is handled differently as part of their parent player
186 if raw_conf.get("player_type") == PlayerType.PROTOCOL or (
187 player and player.state.type == PlayerType.PROTOCOL
188 ):
189 continue
190 # filter out disabled players
191 if not include_disabled and not raw_conf.get("enabled", True):
192 continue
193 if include_values:
194 result.append(await self.get_player_config(raw_conf["player_id"]))
195 else:
196 summary_conf = deepcopy(raw_conf)
197 summary_conf["default_name"] = (
198 player.state.name if player else summary_conf.get("default_name")
199 )
200 summary_conf["available"] = player.state.available if player else False
201 result.append(cast("PlayerConfig", PlayerConfig.parse([], summary_conf)))
202 return result
203
204 @api_command("config/players/get", required_scope=Scope.CONFIG_PLAYERS_READ)
205 async def get_player_config(
206 self,
207 player_id: str,
208 ) -> PlayerConfig:
209 """Return (full) configuration for a single player."""
210 raw_conf: dict[str, Any]
211 if raw_conf := self.get(f"{CONF_PLAYERS}/{player_id}"):
212 raw_conf = deepcopy(raw_conf)
213 # protocol- and plugin-prefixed entries are virtual mirrors of another config
214 # (the linked protocol player resp. the plugin provider is the canonical store).
215 # Drop any that linger in this player's persisted values so a stale copy can never
216 # shadow the canonical value; the live values are merged back in below.
217 if stored_values := raw_conf.get("values"):
218 for key in [
219 key
220 for key in stored_values
221 if CONF_PROTOCOL_KEY_SPLITTER in key or CONF_PLUGIN_KEY_SPLITTER in key
222 ]:
223 del stored_values[key]
224 if player := self.mass.players.get_player(player_id, False):
225 raw_conf["default_name"] = player.state.name
226 raw_conf["provider"] = player.provider.instance_id
227 config_entries = await self.get_player_config_entries(
228 player_id,
229 )
230 # also grab (raw) values for protocol outputs
231 if protocol_values := await self._get_output_protocol_config_values(config_entries):
232 if "values" not in raw_conf:
233 raw_conf["values"] = {}
234 raw_conf["values"].update(protocol_values)
235 # ...and the (derived) values for the per-player plugin toggles
236 if plugin_values := self._get_plugin_provider_config_values(config_entries):
237 if "values" not in raw_conf:
238 raw_conf["values"] = {}
239 raw_conf["values"].update(plugin_values)
240 else:
241 # handle unavailable player and/or provider
242 config_entries = []
243 raw_conf["available"] = False
244 raw_conf["default_name"] = (
245 raw_conf.get("default_name") or raw_conf.get("player_id") or player_id
246 )
247 raw_conf.setdefault("player_id", player_id)
248
249 conf = cast("PlayerConfig", PlayerConfig.parse(config_entries, raw_conf))
250 _apply_raw_player_icon_value(conf, raw_conf.get("values", {}))
251 # parse() stamps every entry with this player's owner; injected protocol entries
252 # belong to their own protocol provider, so restore that owner for string resolution.
253 for entry in conf.values.values():
254 if CONF_PROTOCOL_KEY_SPLITTER not in entry.key:
255 continue
256 protocol_player_id = entry.key.split(CONF_PROTOCOL_KEY_SPLITTER, 1)[0]
257 if protocol_player := self.mass.players.get_player(protocol_player_id, False):
258 entry.translation_owner = protocol_player.translation_owner
259 return conf
260 msg = f"No config found for player id {player_id}"
261 raise KeyError(msg)
262
263 @api_command("config/players/get_entries", required_scope=Scope.CONFIG_PLAYERS_READ)
264 async def get_player_config_entries(self, player_id: str) -> list[ConfigEntry]:
265 """
266 Return Config entries to configure a player.
267
268 :param player_id: id of an existing player instance.
269 """
270 if not (player := self.mass.players.get_player(player_id, False)):
271 msg = f"Player {player_id} not found"
272 raise KeyError(msg)
273
274 default_entries: list[ConfigEntry]
275 player_entries: list[ConfigEntry]
276 if player.state.type == PlayerType.PROTOCOL:
277 default_entries = []
278 player_entries = await self._get_player_config_entries(player)
279 else:
280 # get default entries which are common for all (non protocol)players
281 default_entries = self._get_default_player_config_entries(player)
282
283 # get player(protocol) specific entries
284 # this basically injects virtual config entries for each protocol output
285 # this feels maybe a bit of a hack to do it this way but it keeps the UI logic simple
286 # and maximizes api client compatibility because you can configure the whole player
287 # including its protocols from a single config endpoint without needing special handling
288 # for protocol players in the UI/api clients
289 if protocol_entries := await self._create_output_protocol_config_entries(player):
290 player_entries = protocol_entries
291 if not any(protocol.is_native for protocol in player.output_protocols):
292 # A control-only player (e.g. a device that delegates playback to a
293 # linked DLNA protocol player) has no native output protocol, so the
294 # block above never injects the player's own entries. Append them here
295 # so it keeps its own config surface, skipping keys the protocol
296 # entries already cover.
297 protocol_keys = {entry.key for entry in protocol_entries}
298 player_entries = [
299 *protocol_entries,
300 *[
301 entry
302 for entry in await self._get_player_config_entries(player)
303 if entry.key not in protocol_keys
304 ],
305 ]
306 else:
307 player_entries = await self._get_player_config_entries(player)
308
309 # append the per-player plugin toggles (e.g. Spotify Connect), the same
310 # virtual-entry approach as the protocol outputs above but backed by the
311 # plugin provider's own config
312 player_entries = [
313 *player_entries,
314 *self._create_plugin_provider_config_entries(player),
315 ]
316
317 player_entries_keys = {entry.key for entry in player_entries}
318 all_entries = [
319 # ignore default entries that were overridden by the player specific ones
320 *[x for x in default_entries if x.key not in player_entries_keys],
321 *player_entries,
322 ]
323 return _with_translation_owner(all_entries, player.translation_owner)
324
325 @api_command("config/players/invoke_action", required_scope=Scope.CONFIG_PLAYERS_WRITE)
326 async def invoke_player_config_action(
327 self, player_id: str, action: str
328 ) -> list[ConfigEntry] | ConfigActionResult:
329 """
330 Run a one-shot action button from a player's config.
331
332 A protocol-prefixed action (``<protocol_player_id>||protocol||<action>``) is routed to
333 the linked protocol player; the parent player's entries are then re-rendered so the
334 injected protocol entries pick up any state change. A ``ConfigActionResult`` holds the
335 outcome to report to the user; an empty list means the action ran with nothing to
336 report; a non-empty list holds the parent player's entries the config form should
337 re-render with.
338
339 :param player_id: The player whose config surface holds the action.
340 :param action: The action id of the pressed button (may be protocol-prefixed).
341 """
342 if not (player := self.mass.players.get_player(player_id, False)):
343 msg = f"Player {player_id} not found"
344 raise KeyError(msg)
345 if CONF_PROTOCOL_KEY_SPLITTER in action:
346 protocol_player_id, protocol_action = action.split(CONF_PROTOCOL_KEY_SPLITTER, 1)
347 if not (target := self.mass.players.get_player(protocol_player_id, False)):
348 msg = f"Player {protocol_player_id} not found"
349 raise KeyError(msg)
350 result = await target.handle_config_action(protocol_action)
351 else:
352 target = player
353 result = await player.handle_config_action(action)
354 if result is None:
355 return []
356 if isinstance(result, ConfigActionResult):
357 # the strings belong to the provider that handled the action, which for a
358 # protocol-prefixed action is the protocol player's, not the host player's
359 result.translation_owner = result.translation_owner or target.translation_owner
360 return result
361 # re-render the full (parent) player entries so injected protocol entries refresh
362 return await self.get_player_config_entries(player_id)
363
364 @overload
365 async def get_player_config_value(
366 self,
367 player_id: str,
368 key: str,
369 unpack_splitted_values: Literal[True],
370 *,
371 default: ConfigValueType = ...,
372 return_type: type[_ConfigValueT] | None = ...,
373 ) -> tuple[str, ...] | list[tuple[str, ...]]: ...
374
375 @overload
376 async def get_player_config_value(
377 self,
378 player_id: str,
379 key: str,
380 unpack_splitted_values: Literal[False] = False,
381 *,
382 default: _ConfigValueT,
383 return_type: type[_ConfigValueT] = ...,
384 ) -> _ConfigValueT: ...
385
386 @overload
387 async def get_player_config_value(
388 self,
389 player_id: str,
390 key: str,
391 unpack_splitted_values: Literal[False] = False,
392 *,
393 default: ConfigValueType = ...,
394 return_type: type[_ConfigValueT] = ...,
395 ) -> _ConfigValueT: ...
396
397 @overload
398 async def get_player_config_value(
399 self,
400 player_id: str,
401 key: str,
402 unpack_splitted_values: Literal[False] = False,
403 *,
404 default: ConfigValueType = ...,
405 return_type: None = ...,
406 ) -> ConfigValueType: ...
407
408 @api_command("config/players/get_value", required_scope=Scope.CONFIG_PLAYERS_READ)
409 async def get_player_config_value(
410 self,
411 player_id: str,
412 key: str,
413 unpack_splitted_values: bool = False,
414 *,
415 default: ConfigValueType = None,
416 return_type: type[_ConfigValueT | ConfigValueType] | None = None,
417 ) -> _ConfigValueT | ConfigValueType | tuple[str, ...] | list[tuple[str, ...]]:
418 """
419 Return single configentry value for a player.
420
421 :param player_id: The player ID.
422 :param key: The config key to retrieve.
423 :param unpack_splitted_values: Whether to unpack multi-value config entries.
424 :param default: Optional default value to return if key is not found.
425 :param return_type: Optional type hint for type inference (e.g., str, int, bool).
426 Note: This parameter is used purely for static type checking and does not
427 perform runtime type validation. Callers are responsible for ensuring the
428 specified type matches the actual config value type.
429 """
430 # prefer stored value so we don't have to retrieve all config entries every time
431 if (raw_value := self.get_raw_player_config_value(player_id, key)) is not None:
432 if not unpack_splitted_values:
433 return raw_value
434 conf = await self.get_player_config(player_id)
435 if key not in conf.values:
436 if default is not None:
437 return default
438 msg = f"Config key {key} not found for player {player_id}"
439 raise KeyError(msg)
440 if unpack_splitted_values:
441 return conf.values[key].get_splitted_values()
442 return (
443 conf.values[key].value
444 if conf.values[key].value is not None
445 else conf.values[key].default_value
446 )
447
448 if TYPE_CHECKING:
449 # Overload for when default is provided - return type matches default type
450 @overload
451 def get_raw_player_config_value(
452 self, player_id: str, key: str, default: _ConfigValueT
453 ) -> _ConfigValueT: ...
454
455 # Overload for when no default is provided - return ConfigValueType | None
456 @overload
457 def get_raw_player_config_value(
458 self, player_id: str, key: str, default: None = None
459 ) -> ConfigValueType | None: ...
460
461 def get_raw_player_config_value(
462 self, player_id: str, key: str, default: ConfigValueType = None
463 ) -> ConfigValueType:
464 """
465 Return (raw) single configentry value for a player.
466
467 Note that this only returns the stored value without any validation or default.
468 """
469 return cast(
470 "ConfigValueType",
471 self.get(
472 f"{CONF_PLAYERS}/{player_id}/values/{key}",
473 self.get(f"{CONF_PLAYERS}/{player_id}/{key}", default),
474 ),
475 )
476
477 def get_base_player_config(self, player_id: str, provider: str) -> PlayerConfig:
478 """
479 Return base PlayerConfig for a player.
480
481 This is used to get the base config for a player, without any provider specific values,
482 for initialization purposes.
483 """
484 if not (raw_conf := self.get(f"{CONF_PLAYERS}/{player_id}")):
485 raw_conf = {
486 "player_id": player_id,
487 "provider": provider,
488 }
489 return cast("PlayerConfig", PlayerConfig.parse([], raw_conf))
490
491 @api_command("config/players/save", required_scope=Scope.CONFIG_PLAYERS_WRITE)
492 async def save_player_config(
493 self, player_id: str, values: dict[str, ConfigValueType]
494 ) -> PlayerConfig:
495 """Save/update PlayerConfig."""
496 values = await self._update_output_protocol_config(values)
497 values = await self._update_plugin_provider_config(player_id, values)
498 conf_key = f"{CONF_PLAYERS}/{player_id}"
499 existing_raw = self.get(conf_key) or {}
500 existing_values = existing_raw.get("values", {})
501 if values.get(CONF_ICON) is None and CONF_ICON not in existing_values:
502 values = {key: value for key, value in values.items() if key != CONF_ICON}
503 config = await self.get_player_config(player_id)
504 changed_keys = config.update(values)
505 new_raw = config.to_raw()
506 new_values = new_raw.get("values", {})
507 # Preserve values from storage that don't have config entries in current context.
508 config_entry_keys = set(config.values.keys())
509 for key, value in existing_values.items():
510 if key not in new_values and key not in config_entry_keys:
511 new_values[key] = value
512 # never persist protocol- or plugin-prefixed (virtual) entries on this player; the linked
513 # protocol player resp. the plugin provider is the canonical store (handled above). Storing
514 # a copy here would shadow the canonical value once it is reset back to its default.
515 new_values = {
516 key: value
517 for key, value in new_values.items()
518 if CONF_PROTOCOL_KEY_SPLITTER not in key and CONF_PLUGIN_KEY_SPLITTER not in key
519 }
520 if _reconcile_player_icon_value(values, existing_values, new_values):
521 changed_keys.add(f"values/{CONF_ICON}")
522 _apply_raw_player_icon_value(config, new_values)
523 if not changed_keys:
524 # no changes
525 return config
526 # store updated config first (to prevent issues with enabling/disabling players)
527 new_raw["values"] = new_values
528 self.set(conf_key, new_raw)
529 try:
530 # validate/handle the update in the player manager
531 await self.mass.players.on_player_config_change(config, changed_keys)
532 except Exception:
533 # rollback on error - use existing_raw to preserve all values
534 self.set(conf_key, existing_raw)
535 raise
536 # send config updated event
537 self.mass.signal_event(
538 EventType.PLAYER_CONFIG_UPDATED,
539 object_id=config.player_id,
540 data=config,
541 )
542 # return full player config (just in case)
543 return await self.get_player_config(player_id)
544
545 @api_command("config/players/remove", required_scope=Scope.CONFIG_PLAYERS_WRITE)
546 async def remove_player_config(self, player_id: str) -> None:
547 """Remove PlayerConfig."""
548 conf_key = f"{CONF_PLAYERS}/{player_id}"
549 player_config = self.get(conf_key)
550 if not player_config:
551 msg = f"Player configuration for {player_id} does not exist"
552 raise KeyError(msg)
553 if self.mass.players.get_player(player_id):
554 try:
555 await self.mass.players.remove(player_id)
556 except UnsupportedFeaturedException:
557 # removing a player config while it is active is not allowed
558 # unless the provider reports it has the remove_player feature
559 raise ActionUnavailable("Can not remove config for an active player!")
560 # tell the player manager to remove the player if its lingering around
561 # set permanent to false otherwise we end up in an infinite loop
562 await self.mass.players.unregister(player_id, permanent=False)
563 # all of the above passed, so wipe the config (incl. DSP and linked protocol players)
564 self.mass.players.delete_player_config(player_id)
565
566 def set_player_default_name(self, player_id: str, default_name: str) -> None:
567 """Set (or update) the default name for a player."""
568 # skip if the player config root no longer exists, otherwise the
569 # nested set would resurrect a partial entry (missing player_id etc).
570 if not self.get(f"{CONF_PLAYERS}/{player_id}"):
571 return
572 conf_key = f"{CONF_PLAYERS}/{player_id}/default_name"
573 self.set(conf_key, default_name)
574
575 def set_player_type(self, player_id: str, player_type: PlayerType) -> None:
576 """Set (or update) the type for a player."""
577 # skip if the player config root no longer exists, otherwise the
578 # nested set would resurrect a partial entry (missing player_id etc).
579 if not self.get(f"{CONF_PLAYERS}/{player_id}"):
580 return
581 conf_key = f"{CONF_PLAYERS}/{player_id}/player_type"
582 self.set(conf_key, player_type)
583
584 def create_default_player_config(
585 self,
586 player_id: str,
587 provider: str,
588 player_type: PlayerType,
589 name: str | None = None,
590 enabled: bool = True,
591 values: dict[str, ConfigValueType] | None = None,
592 ) -> None:
593 """
594 Create default/empty PlayerConfig.
595
596 This is meant as helper to create default configs when a player is registered.
597 Called by the player manager on player register.
598 """
599 # return early if the config already exists
600 if existing_conf := self.get(f"{CONF_PLAYERS}/{player_id}"):
601 # update default name if needed
602 if name and name != existing_conf.get("default_name"):
603 self.set(f"{CONF_PLAYERS}/{player_id}/default_name", name)
604 # deliberately do NOT update player_type here: this is called from
605 # Player.__init__ where the type can still be a transient class default.
606 # Genuine type changes are persisted by update_state after registration.
607 return
608 # config does not yet exist, create a default one.
609 # the name is stored as the default name only: a stored (custom) name means
610 # the user renamed the player and must keep shadowing the default name.
611 conf_key = f"{CONF_PLAYERS}/{player_id}"
612 default_conf = PlayerConfig(
613 values={},
614 provider=provider,
615 player_id=player_id,
616 enabled=enabled,
617 name=None,
618 default_name=name,
619 player_type=player_type,
620 )
621 default_conf_raw = default_conf.to_raw()
622 if values is not None:
623 default_conf_raw["values"] = values
624 self.set(
625 conf_key,
626 default_conf_raw,
627 )
628
629 def set_raw_player_config_value(self, player_id: str, key: str, value: ConfigValueType) -> None:
630 """
631 Set (raw) single config(entry) value for a player.
632
633 Note that this only stores the (raw) value without any validation or default.
634 """
635 if not self.get(f"{CONF_PLAYERS}/{player_id}"):
636 # only allow setting raw values if main entry exists
637 msg = f"Invalid player_id: {player_id}"
638 raise KeyError(msg)
639 if key in BASE_KEYS:
640 self.set(f"{CONF_PLAYERS}/{player_id}/{key}", value)
641 else:
642 self.set(f"{CONF_PLAYERS}/{player_id}/values/{key}", value)
643 # also update the player's in-place config copy so object-local
644 # value reads stay in sync with raw writes
645 if (player := self.mass.players.get_player(player_id, False)) and (
646 entry := player.config.values.get(key)
647 ):
648 entry.value = value
649
650 async def _get_player_config_entries(
651 self,
652 player: Player,
653 ) -> list[ConfigEntry]:
654 """
655 Return Player(protocol) specific config entries, without any default entries.
656
657 In general this returns entries that are specific to this provider/player type only,
658 and includes audio related entries that are not part of the default set.
659
660 :param player: the player instance
661 """
662 default_entries: list[ConfigEntry]
663 is_dedicated_group_player = player.state.type in (
664 PlayerType.GROUP,
665 PlayerType.STEREO_PAIR,
666 ) and not player.player_id.startswith((UGP_PREFIX, SGP_PREFIX))
667 is_http_based_player_protocol = player.provider.domain not in NON_HTTP_PROVIDERS
668 if player.state.type == PlayerType.GROUP and not is_dedicated_group_player:
669 # no audio related entries for universal group players or sync group players
670 default_entries = []
671 elif PlayerFeature.PLAY_MEDIA not in player.supported_features:
672 # no audio related entries for players that do not support play_media
673 default_entries = []
674 else:
675 # default output/audio related entries
676 default_entries = [
677 # output channel is always configurable per player(protocol)
678 CONF_ENTRY_OUTPUT_CHANNELS
679 ]
680 if is_http_based_player_protocol:
681 # for http based players we can add the http streaming related entries
682 default_entries += [
683 CONF_ENTRY_OUTPUT_CODEC,
684 CONF_ENTRY_PREFER_WAV_FOR_LIVE_SOURCES,
685 CONF_ENTRY_HTTP_PROFILE,
686 CONF_ENTRY_ENABLE_ICY_METADATA,
687 ]
688 # only inject the sample-rates config when the player can't declare its rates itself
689 if not player.declares_supported_sample_rates:
690 default_entries.append(CONF_ENTRY_SAMPLE_RATES)
691 # add flow mode entry for http-based players that do not already enforce it
692 if not player.requires_flow_mode:
693 default_entries.append(CONF_ENTRY_FLOW_MODE)
694 default_entries.append(CONF_ENTRY_FLOW_MODE_SAMPLE_RATE)
695 else:
696 # Flow mode is enforced for this player. Clear depends_on so the
697 # UI doesn't visually disable sample rate when entry is omitted.
698 forced_sample_rate_entry = deepcopy(CONF_ENTRY_FLOW_MODE_SAMPLE_RATE)
699 forced_sample_rate_entry.depends_on = None
700 default_entries.append(forced_sample_rate_entry)
701 if PlayerFeature.GAPLESS_PLAYBACK in player.supported_features:
702 default_entries.append(CONF_ENTRY_CROSSFADE_DIFFERENT_SAMPLE_RATES)
703 # request player specific entries
704 player_entries = await player.get_config_entries()
705 players_keys = {entry.key for entry in player_entries}
706 # filter out any default entries that are already provided by the player
707 default_entries = [entry for entry in default_entries if entry.key not in players_keys]
708 return [*player_entries, *default_entries]
709
710 def _get_default_player_config_entries(self, player: Player) -> list[ConfigEntry]:
711 """
712 Return the default (generic) player config entries.
713
714 This does not return audio/protocol specific entries, those are handled elsewhere.
715 """
716 entries: list[ConfigEntry] = []
717 # default protocol-player config entries
718 if player.state.type == PlayerType.PROTOCOL:
719 # protocol players have no generic config entries
720 # only audio/protocol specific ones
721 return []
722
723 icon_entry = deepcopy(
724 CONF_ENTRY_PLAYER_ICON_GROUP
725 if player.state.type == PlayerType.GROUP
726 else CONF_ENTRY_PLAYER_ICON
727 )
728 icon_entry.default_value = player.default_icon
729 icon_entry.value = self.get_raw_player_config_value(player.player_id, CONF_ICON)
730
731 # some base entries for all player types
732 # note that these may NOT be playback/audio related
733 entries += [
734 CONF_ENTRY_TTS_PRE_ANNOUNCE,
735 ConfigEntry(
736 key=CONF_PRE_ANNOUNCE_CHIME_URL,
737 type=ConfigEntryType.STRING,
738 category="announcements",
739 required=False,
740 depends_on=CONF_ENTRY_TTS_PRE_ANNOUNCE.key,
741 depends_on_value=True,
742 validate=lambda val: validate_announcement_chime_url(cast("str", val)),
743 ),
744 # add player control entries
745 *self._create_player_control_config_entries(player),
746 # add entry to hide player in UI
747 ConfigEntry(
748 key=CONF_HIDE_IN_UI,
749 type=ConfigEntryType.BOOLEAN,
750 default_value=player.hidden_by_default,
751 category="generic",
752 advanced=False,
753 ),
754 # add entry to expose player to HA
755 ConfigEntry(
756 key=CONF_EXPOSE_PLAYER_TO_HA,
757 type=ConfigEntryType.BOOLEAN,
758 category="generic",
759 advanced=False,
760 default_value=player.expose_to_ha_by_default,
761 ),
762 ]
763 # group-player config entries
764 if player.state.type == PlayerType.GROUP:
765 entries += [
766 icon_entry,
767 ]
768 return entries
769 # normal player (or stereo pair) config entries
770 entries += [
771 icon_entry,
772 # add default entries for announce feature
773 CONF_ENTRY_ANNOUNCE_VOLUME_STRATEGY,
774 CONF_ENTRY_ANNOUNCE_VOLUME,
775 CONF_ENTRY_ANNOUNCE_VOLUME_MIN,
776 CONF_ENTRY_ANNOUNCE_VOLUME_MAX,
777 # play_media-on-self preference (only relevant to non-group players)
778 CONF_ENTRY_PLAY_MEDIA_OVERRIDES_GROUP,
779 ]
780 return entries
781
782 def _create_player_control_config_entries(self, player: Player) -> list[ConfigEntry]:
783 """Create config entries for player controls."""
784 is_group = player.state.type == PlayerType.GROUP
785 all_controls = self.mass.players.player_controls()
786 power_controls = [x for x in all_controls if x.supports_power]
787 volume_controls = [x for x in all_controls if x.supports_volume]
788 mute_controls = [x for x in all_controls if x.supports_mute]
789 auto_option = ConfigValueOption(PLAYER_CONTROL_PROTOCOL)
790 # the "native" option is always listed (disabled when the feature is unsupported) so the
791 # option set is consistent across players; the entry default skips disabled options.
792 power_options: list[ConfigValueOption] = [
793 ConfigValueOption(
794 PLAYER_CONTROL_NATIVE,
795 disabled=not player.supports_feature(PlayerFeature.POWER),
796 )
797 ]
798 has_native_volume_control = player.supports_feature(PlayerFeature.VOLUME_SET)
799 volume_options: list[ConfigValueOption] = [
800 ConfigValueOption(PLAYER_CONTROL_NATIVE, disabled=not has_native_volume_control)
801 ]
802 mute_options: list[ConfigValueOption] = [
803 ConfigValueOption(
804 PLAYER_CONTROL_NATIVE,
805 disabled=not player.supports_feature(PlayerFeature.VOLUME_MUTE),
806 )
807 ]
808 # add player protocols as volume controls if native player has no volume control
809 for linked_protocol in player.linked_output_protocols:
810 if has_native_volume_control:
811 break
812 protocol_player = self.mass.players.get_player(linked_protocol.output_protocol_id)
813 if not protocol_player or not protocol_player.available_for_playback:
814 continue
815 if protocol_player.supports_feature(PlayerFeature.VOLUME_SET):
816 if auto_option not in volume_options:
817 volume_options.append(auto_option)
818 if linked_protocol.protocol_domain in ("chromecast", "dlna"):
819 # for chromecast/dlna we can use the protocol player for volume control
820 # even if the protocol player is not the active protocol
821 volume_options.append(
822 ConfigValueOption(
823 protocol_player.player_id, title=protocol_player.provider.name
824 )
825 )
826 if protocol_player.supports_feature(PlayerFeature.VOLUME_MUTE):
827 if auto_option not in mute_options:
828 mute_options.append(auto_option)
829 if linked_protocol.protocol_domain in ("chromecast", "dlna"):
830 # for chromecast/dlna we can use the protocol player for volume control
831 # even if the protocol player is not the active protocol
832 mute_options.append(
833 ConfigValueOption(
834 protocol_player.player_id, title=protocol_player.provider.name
835 )
836 )
837
838 # append none+fake options
839 power_options += [
840 ConfigValueOption(PLAYER_CONTROL_NONE),
841 ConfigValueOption(PLAYER_CONTROL_FAKE),
842 ]
843 volume_options += [
844 ConfigValueOption(PLAYER_CONTROL_NONE),
845 ]
846 mute_options.append(ConfigValueOption(PLAYER_CONTROL_NONE))
847 # fake mute drives the volume control, so offer it when the player has any
848 # usable volume path (native or via a linked protocol player)
849 if player.supports_feature(PlayerFeature.VOLUME_SET) or auto_option in volume_options:
850 mute_options.append(ConfigValueOption(PLAYER_CONTROL_FAKE))
851
852 # return final config entries for all options
853 return [
854 # Power control config entry
855 ConfigEntry(
856 key=CONF_POWER_CONTROL,
857 type=ConfigEntryType.STRING,
858 default_value=_first_enabled_control_value(power_options),
859 required=False,
860 options=[
861 *power_options,
862 *(ConfigValueOption(x.id, title=x.name) for x in power_controls),
863 ],
864 category="player_controls",
865 ),
866 # Volume control config entry
867 ConfigEntry(
868 key=CONF_VOLUME_CONTROL,
869 type=ConfigEntryType.STRING,
870 default_value=_first_enabled_control_value(volume_options),
871 required=True,
872 options=[
873 *volume_options,
874 *(ConfigValueOption(x.id, title=x.name) for x in volume_controls),
875 ],
876 category="player_controls",
877 ),
878 # Mute control config entry
879 ConfigEntry(
880 key=CONF_MUTE_CONTROL,
881 type=ConfigEntryType.STRING,
882 default_value=_first_enabled_control_value(mute_options),
883 required=True,
884 options=[
885 *mute_options,
886 *[ConfigValueOption(x.id, title=x.name) for x in mute_controls],
887 ],
888 category="player_controls",
889 ),
890 # Volume limit entries
891 CONF_ENTRY_MIN_VOLUME,
892 CONF_ENTRY_MAX_VOLUME,
893 # auto-play on power on — only meaningful for individual players.
894 # For group players, power on/off is purely a "capture members"
895 # toggle (Fake control) and auto-starting playback there causes
896 # surprise playback when the user just wanted to pin the group.
897 *([] if is_group else [CONF_ENTRY_AUTO_PLAY]),
898 ]
899
900 async def _create_output_protocol_config_entries( # noqa: PLR0915
901 self,
902 player: Player,
903 ) -> list[ConfigEntry]:
904 """
905 Create the output protocol config entries for a player.
906
907 The preferred output protocol entry is always returned, listing outputs that can not
908 be selected right now as disabled options and hidden altogether when the player has
909 at most one output. The settings of each output whose provider is loaded follow.
910
911 :param player: The player to create the output protocol config entries for.
912 """
913 all_entries: list[ConfigEntry] = []
914 output_protocols = player.output_protocols
915
916 # Resolve derived-transport edges (e.g. a Sendspin bridge riding on the
917 # AirPlay protocol) so derived protocols render as dependent on their base.
918 base_protocols: dict[str, OutputProtocol] = {}
919 for protocol in output_protocols:
920 if protocol.is_native:
921 continue
922 underlying_id = self.get_raw_player_config_value(
923 protocol.output_protocol_id, CONF_UNDERLYING_PLAYER_ID
924 )
925 if not underlying_id:
926 continue
927 if base_protocol := next(
928 (p for p in output_protocols if p.output_protocol_id == underlying_id), None
929 ):
930 base_protocols[protocol.output_protocol_id] = base_protocol
931
932 # Build options from all output protocols, sorted by priority
933 options: list[ConfigValueOption] = []
934
935 # Add each output protocol as an option, sorted by priority. An output that can not be
936 # used right now is offered disabled with the reason why, rather than left out entirely:
937 # that keeps the device's outputs recognizable and explains what to do about it.
938 has_native = False
939 for protocol in sorted(output_protocols, key=lambda p: p.priority):
940 protocol_name = self._get_protocol_display_name(protocol.protocol_domain)
941 # Use "native" for native playback,
942 # otherwise use the protocol output id (=player id)
943 if protocol.is_native:
944 title = f"{protocol_name} (native)"
945 elif base_protocol := base_protocols.get(protocol.output_protocol_id):
946 title = (
947 f"{protocol_name} "
948 f"(over {self._get_protocol_display_name(base_protocol.protocol_domain)})"
949 )
950 else:
951 title = protocol_name
952 value = "native" if protocol.is_native else protocol.output_protocol_id
953 options.append(
954 ConfigValueOption(
955 value,
956 title=title,
957 disabled=not protocol.available,
958 # the option's value is a player id, so its reason is keyed by this slug
959 translation_key=(
960 None
961 if protocol.available
962 else self._output_protocol_unavailable_reason(player, protocol)
963 ),
964 )
965 )
966 # never default to an output that can not be selected
967 has_native = has_native or (protocol.is_native and protocol.available)
968
969 if has_native:
970 default_value = "native"
971 else:
972 # Without a native output the entry default stays "auto": runtime selection
973 # honours the player's default_output_protocol_domain (e.g. DLNA-first for a
974 # LinkPlay shell) with plain priority fallback, so the stored config default
975 # must not depend on which linked protocols happen to be available right now.
976 options.append(ConfigValueOption("auto"))
977 default_value = "auto"
978
979 all_entries.append(
980 ConfigEntry(
981 key=CONF_PREFERRED_OUTPUT_PROTOCOL,
982 type=ConfigEntryType.STRING,
983 default_value=default_value,
984 required=True,
985 options=options,
986 category="protocol_general",
987 requires_reload=False,
988 hidden=len(output_protocols) <= 1,
989 )
990 )
991
992 # Add config entries for all protocol players/outputs
993 for protocol in output_protocols:
994 domain = protocol.protocol_domain
995 protocol_name = self._get_protocol_display_name(domain)
996 protocol_player_enabled = self.get_raw_player_config_value(
997 protocol.output_protocol_id, CONF_ENABLED, True
998 )
999 provider_available = self.mass.get_provider(protocol.protocol_domain) is not None
1000 if not provider_available:
1001 # protocol provider is not available, skip adding entries
1002 continue
1003 protocol_prefix = f"{protocol.output_protocol_id}{CONF_PROTOCOL_KEY_SPLITTER}"
1004 protocol_enabled_key = f"{protocol_prefix}enabled"
1005 protocol_category = f"{CONF_PROTOCOL_CATEGORY_PREFIX}_{domain}"
1006 category_translation_key = "protocol_output_settings"
1007 category_translation_params = [protocol_name]
1008 # Derived protocols (e.g. Sendspin over AirPlay) render with their base
1009 # protocol in the label and follow its enabled state.
1010 base_protocol = base_protocols.get(protocol.output_protocol_id)
1011 base_name: str | None = None
1012 base_enabled = True
1013 if base_protocol is not None:
1014 base_name = self._get_protocol_display_name(base_protocol.protocol_domain)
1015 base_raw_conf = self.get(f"{CONF_PLAYERS}/{base_protocol.output_protocol_id}") or {}
1016 base_enabled = bool(base_raw_conf.get("enabled", True))
1017 category_translation_key = "protocol_output_settings_via"
1018 category_translation_params = [protocol_name, base_name]
1019 if not protocol.is_native:
1020 all_entries.append(
1021 ConfigEntry(
1022 key=protocol_enabled_key,
1023 type=ConfigEntryType.BOOLEAN,
1024 # the key is per-protocol (dynamic), so pin a static catalog key
1025 translation_key="protocol_enable_via" if base_name else "protocol_enable",
1026 translation_params=[base_name] if base_name else None,
1027 # a derived protocol cannot be active while its base is disabled
1028 value=bool(protocol_player_enabled) and base_enabled,
1029 read_only=not base_enabled,
1030 default_value=True,
1031 category=protocol_category,
1032 category_translation_key=category_translation_key,
1033 category_translation_params=category_translation_params,
1034 requires_reload=False,
1035 )
1036 )
1037 if protocol.is_native:
1038 # add protocol-specific entries from native player
1039 protocol_entries = await self._get_player_config_entries(player)
1040 for proto_entry in protocol_entries:
1041 # deep copy to avoid mutating shared/constant ConfigEntry objects
1042 entry = deepcopy(proto_entry)
1043 entry.category = protocol_category
1044 entry.category_translation_key = category_translation_key
1045 entry.category_translation_params = category_translation_params
1046 all_entries.append(entry)
1047
1048 elif protocol_player := self.mass.players.get_player(protocol.output_protocol_id):
1049 # we grab the config entries from the protocol player
1050 # and then prefix them to avoid key collisions
1051 protocol_entries = await self._get_player_config_entries(protocol_player)
1052 protocol_entry_keys = {entry.key for entry in protocol_entries}
1053 for proto_entry in protocol_entries:
1054 # deep copy to avoid mutating shared/constant ConfigEntry objects
1055 entry = deepcopy(proto_entry)
1056 entry.category = protocol_category
1057 entry.category_translation_key = category_translation_key
1058 entry.category_translation_params = category_translation_params
1059 # the key gets prefixed below to avoid collisions; pin the catalog key to the
1060 # original (bare) slug and the protocol's own provider so the label still
1061 # resolves against provider.<domain>.config_entries.<original_key>
1062 entry.translation_key = entry.translation_key or entry.key
1063 entry.translation_owner = protocol_player.translation_owner
1064 entry.key = f"{protocol_prefix}{entry.key}"
1065 if entry.depends_on in protocol_entry_keys:
1066 # the entry it depends on is copied into this same block, so follow it
1067 # to its prefixed key and keep the value condition that goes with it
1068 entry.depends_on = f"{protocol_prefix}{entry.depends_on}"
1069 else:
1070 # nothing of its own to depend on, so gate it on the protocol toggle.
1071 # any value condition belonged to the original key and must not carry
1072 # over, or it gets compared against the toggle's boolean instead.
1073 entry.depends_on = protocol_enabled_key
1074 entry.depends_on_value = None
1075 entry.depends_on_value_not = None
1076 entry.action = f"{protocol_prefix}{entry.action}" if entry.action else None
1077 all_entries.append(entry)
1078
1079 return all_entries
1080
1081 async def _update_output_protocol_config(
1082 self, values: dict[str, ConfigValueType]
1083 ) -> dict[str, ConfigValueType]:
1084 """
1085 Update output protocol related config for a player based on config values.
1086
1087 Returns updated values dict with output protocol related entries removed.
1088 """
1089 protocol_values: dict[str, dict[str, ConfigValueType]] = {}
1090 for key, value in list(values.items()):
1091 if CONF_PROTOCOL_KEY_SPLITTER not in key:
1092 continue
1093 # extract protocol player id and actual key
1094 protocol_player_id, actual_key = key.split(CONF_PROTOCOL_KEY_SPLITTER)
1095 if protocol_player_id not in protocol_values:
1096 protocol_values[protocol_player_id] = {}
1097 protocol_values[protocol_player_id][actual_key] = value
1098 # remove from main values dict
1099 del values[key]
1100 for protocol_player_id, proto_values in protocol_values.items():
1101 await self.save_player_config(protocol_player_id, proto_values)
1102 if proto_values.get(CONF_ENABLED):
1103 # wait max 10 seconds for protocol to become available
1104 for _ in range(10):
1105 protocol_player = self.mass.players.get_player(protocol_player_id)
1106 if protocol_player is not None:
1107 break
1108 await asyncio.sleep(1)
1109 # wait max 10 seconds for protocol
1110 return values
1111
1112 async def _get_output_protocol_config_values(
1113 self,
1114 entries: list[ConfigEntry],
1115 ) -> dict[str, ConfigValueType]:
1116 """Extract output protocol related config values for given (parent) player entries."""
1117 values: dict[str, ConfigValueType] = {}
1118 for entry in entries:
1119 if CONF_PROTOCOL_KEY_SPLITTER not in entry.key:
1120 continue
1121 protocol_player_id, actual_key = entry.key.split(CONF_PROTOCOL_KEY_SPLITTER)
1122 stored_value = self.get_raw_player_config_value(protocol_player_id, actual_key)
1123 if stored_value is None:
1124 continue
1125 values[entry.key] = stored_value
1126 return values
1127
1128 def _output_protocol_unavailable_reason(self, player: Player, protocol: OutputProtocol) -> str:
1129 """
1130 Return the translation slug telling why an output protocol can not be selected.
1131
1132 :param player: The player the output protocol belongs to.
1133 :param protocol: The output protocol that is currently unavailable.
1134 """
1135 if protocol.is_native:
1136 return "needs_setup" if player.needs_setup else "unavailable"
1137 if not self.get_raw_player_config_value(protocol.output_protocol_id, CONF_ENABLED, True):
1138 return "turned_off"
1139 protocol_player = self.mass.players.get_player(protocol.output_protocol_id)
1140 if protocol_player is not None and protocol_player.needs_setup:
1141 return "needs_setup"
1142 return "unavailable"
1143
1144 def _get_protocol_display_name(self, protocol_domain: str) -> str:
1145 """Return the display name for a protocol domain."""
1146 if provider_manifest := self.mass.get_provider_manifest(protocol_domain):
1147 return provider_manifest.name
1148 return protocol_domain.upper()
1149
1150 def _create_plugin_provider_config_entries(self, player: Player) -> list[ConfigEntry]:
1151 """
1152 Create the per-player plugin toggle entries for a player.
1153
1154 Each loaded plugin that binds its AudioSources to individual players (e.g.
1155 Spotify Connect) renders as a single boolean toggle whose value reflects
1156 whether the player is in that plugin's connected players.
1157
1158 :param player: The player to create the plugin toggle entries for.
1159 """
1160 # only players a plugin can offer as playback target get the toggles
1161 if player.state.type not in PLAYBACK_TARGET_TYPES:
1162 return []
1163 entries: list[ConfigEntry] = []
1164 plugin_providers = (
1165 provider for provider in self.mass.providers if isinstance(provider, PluginProvider)
1166 )
1167 for provider in sorted(plugin_providers, key=lambda prov: prov.name.lower()):
1168 # a player-bound plugin returns a list for any player id, unbound returns None
1169 if provider.get_player_audio_sources(player.player_id) is None:
1170 continue
1171 connected_players = cast(
1172 "list[str]",
1173 self.mass.config.get_raw_provider_config_value(
1174 provider.instance_id, CONF_CONNECTED_PLAYERS
1175 )
1176 or [],
1177 )
1178 entries.append(
1179 ConfigEntry(
1180 key=f"{provider.instance_id}{CONF_PLUGIN_KEY_SPLITTER}{CONF_ENABLED}",
1181 type=ConfigEntryType.BOOLEAN,
1182 # the key is per-plugin (dynamic), so pin a static catalog key
1183 translation_key="plugin_enable",
1184 translation_params=[provider.name],
1185 value=player.player_id in connected_players,
1186 default_value=False,
1187 category="plugins",
1188 requires_reload=False,
1189 )
1190 )
1191 return entries
1192
1193 def _get_plugin_provider_config_values(
1194 self, entries: list[ConfigEntry]
1195 ) -> dict[str, ConfigValueType]:
1196 """Extract the plugin toggle values for given (parent) player entries."""
1197 return {
1198 entry.key: entry.value for entry in entries if CONF_PLUGIN_KEY_SPLITTER in entry.key
1199 }
1200
1201 async def _update_plugin_provider_config(
1202 self, player_id: str, values: dict[str, ConfigValueType]
1203 ) -> dict[str, ConfigValueType]:
1204 """
1205 Update the connected players of per-player plugins from submitted toggle values.
1206
1207 Returns the updated values dict with the plugin toggle entries removed.
1208 """
1209 for key in [key for key in values if CONF_PLUGIN_KEY_SPLITTER in key]:
1210 enabled = bool(values.pop(key))
1211 instance_id = key.split(CONF_PLUGIN_KEY_SPLITTER, 1)[0]
1212 connected_players = cast(
1213 "list[str]",
1214 self.mass.config.get_raw_provider_config_value(instance_id, CONF_CONNECTED_PLAYERS)
1215 or [],
1216 )
1217 if enabled == (player_id in connected_players):
1218 continue
1219 if enabled:
1220 connected_players = [*connected_players, player_id]
1221 else:
1222 connected_players = [x for x in connected_players if x != player_id]
1223 # the plugin provider's config is the canonical store; updating it also
1224 # reloads the plugin so it picks up the new player set
1225 await self.mass.config._update_provider_config(
1226 instance_id, {CONF_CONNECTED_PLAYERS: connected_players}
1227 )
1228 return values
1229