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