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