ConfigEntry,
ConfigValueOption,
)
-from music_assistant_models.enums import ConfigEntryType, ContentType, MediaType
-from music_assistant_models.media_items import (
- Audiobook,
- AudioFormat,
- PodcastEpisode,
- Radio,
- Track,
-)
+from music_assistant_models.enums import ConfigEntryType, ContentType, MediaType, PlayerFeature
+from music_assistant_models.media_items import Audiobook, AudioFormat, PodcastEpisode, Radio, Track
APPLICATION_NAME: Final = "Music Assistant"
CONF_POWER_CONTROL: Final[str] = "power_control"
CONF_VOLUME_CONTROL: Final[str] = "volume_control"
CONF_MUTE_CONTROL: Final[str] = "mute_control"
+CONF_PREFERRED_OUTPUT_PROTOCOL: Final[str] = "preferred_output_protocol"
+CONF_LINKED_PROTOCOL_PLAYER_IDS: Final[str] = (
+ "linked_protocol_player_ids" # cached for fast restart
+)
+CONF_PROTOCOL_PARENT_ID: Final[str] = (
+ "protocol_parent_id" # cached native player ID for protocol player
+)
CONF_OUTPUT_CODEC: Final[str] = "output_codec"
CONF_ALLOW_AUDIO_CACHE: Final[str] = "allow_audio_cache"
CONF_SMART_FADES_MODE: Final[str] = "smart_fades_mode"
CONF_AUTH_ALLOW_SELF_REGISTRATION: Final[str] = "auth_allow_self_registration"
CONF_ZEROCONF_INTERFACES: Final[str] = "zeroconf_interfaces"
CONF_ENABLED: Final[str] = "enabled"
+CONF_PROTOCOL_KEY_SPLITTER: Final[str] = "||protocol||"
+CONF_PROTOCOL_CATEGORY_PREFIX: Final[str] = "protocol"
+CONF_DEFAULT_PROVIDERS_SETUP: Final[str] = "default_providers_setup"
+
# config default values
DEFAULT_HOST: Final[str] = "0.0.0.0"
)
VERBOSE_LOG_LEVEL: Final[int] = 5
PROVIDERS_WITH_SHAREABLE_URLS = ("spotify", "qobuz")
-SYNCGROUP_PREFIX: Final[str] = "syncgroup_"
+
####### REUSABLE CONFIG ENTRIES #######
CONF_ENTRY_VOLUME_NORMALIZATION_TARGET = ConfigEntry(
key=CONF_VOLUME_NORMALIZATION_TARGET,
type=ConfigEntryType.INTEGER,
- range=(-70, -5),
+ range=(-30, -5),
default_value=-17,
label="Target level for volume normalization",
description="Adjust average (perceived) loudness to this target level",
# for provider domains in this list, we won't show the default
# http-streaming specific config options in player settings
NON_HTTP_PROVIDERS = ("airplay", "sendspin", "snapcast")
+
+# Protocol priority values (lower = more preferred)
+PROTOCOL_PRIORITY: Final[dict[str, int]] = {
+ "sendspin": 10,
+ "squeezelite": 20,
+ "chromecast": 30,
+ "airplay": 40,
+ "dlna": 50,
+}
+
+PROTOCOL_FEATURES: Final[set[PlayerFeature]] = {
+ # Player features that may be copied from (inactive) protocol implementations
+ PlayerFeature.VOLUME_SET,
+ PlayerFeature.VOLUME_MUTE,
+ PlayerFeature.PLAY_ANNOUNCEMENT,
+ PlayerFeature.SET_MEMBERS,
+}
+
+ACTIVE_PROTOCOL_FEATURES: Final[set[PlayerFeature]] = {
+ # Player features that may be copied from the active output protocol
+ *PROTOCOL_FEATURES,
+ PlayerFeature.ENQUEUE,
+ PlayerFeature.GAPLESS_DIFFERENT_SAMPLERATE,
+ PlayerFeature.GAPLESS_PLAYBACK,
+ PlayerFeature.MULTI_DEVICE_DSP,
+ PlayerFeature.PAUSE,
+}
+
+DEFAULT_PROVIDERS: Final[set[tuple[str, bool]]] = {
+ # list of providers that are setup by default once
+ # (and they can be removed/disabled by the user if they want to)
+ # the boolean value indicates whether it needs to be discovered on mdns
+ ("airplay", False),
+ ("chromecast", False),
+ ("dlna", False),
+ ("sonos", True),
+ ("bluesound", True),
+ ("heos", True),
+}
from music_assistant.constants import (
CONF_CORE,
+ CONF_ENABLED,
CONF_ENTRY_ANNOUNCE_VOLUME,
CONF_ENTRY_ANNOUNCE_VOLUME_MAX,
CONF_ENTRY_ANNOUNCE_VOLUME_MIN,
CONF_PLAYERS,
CONF_POWER_CONTROL,
CONF_PRE_ANNOUNCE_CHIME_URL,
+ CONF_PREFERRED_OUTPUT_PROTOCOL,
+ CONF_PROTOCOL_CATEGORY_PREFIX,
+ CONF_PROTOCOL_KEY_SPLITTER,
CONF_PROVIDERS,
CONF_SERVER_ID,
- CONF_SMART_FADES_MODE,
CONF_VOLUME_CONTROL,
CONFIGURABLE_CORE_CONTROLLERS,
DEFAULT_CORE_CONFIG_ENTRIES,
DEFAULT_PROVIDER_CONFIG_ENTRIES,
ENCRYPT_SUFFIX,
NON_HTTP_PROVIDERS,
- SYNCGROUP_PREFIX,
)
from music_assistant.helpers.api import api_command
from music_assistant.helpers.json import JSON_DECODE_EXCEPTIONS, async_json_dumps, async_json_loads
from music_assistant.helpers.util import load_provider_module, validate_announcement_chime_url
from music_assistant.models import ProviderModuleType
from music_assistant.models.music_provider import MusicProvider
+from music_assistant.providers.sync_group.constants import SGP_PREFIX
+from music_assistant.providers.universal_group.constants import UGP_PREFIX
if TYPE_CHECKING:
from music_assistant import MusicAssistant
# filter out unavailable players
# (unless disabled, otherwise there is no way to re-enable them)
# note that we only check for missing players in the player controller,
- # and we do allow players that are temporary unavailable (player.available = false)
- # because this can also mean that the player needs additional configuration
- # such as airplay devices that need pairing.
- player = self.mass.players.get(raw_conf["player_id"], False)
+ # and we do allow players that are temporary unavailable
+ # (player.state.available = false) because this can also mean that the
+ # player needs additional configuration such as airplay devices that need pairing.
+ player = self.mass.players.get_player(raw_conf["player_id"], False)
if not include_unavailable and player is None and raw_conf.get("enabled", True):
continue
+ # filter out protocol players
+ # their configuration is handled differently as part of their parent player
+ if raw_conf.get("player_type") == PlayerType.PROTOCOL or (
+ player and player.state.type == PlayerType.PROTOCOL
+ ):
+ continue
# filter out disabled players
if not include_disabled and not raw_conf.get("enabled", True):
continue
result.append(await self.get_player_config(raw_conf["player_id"]))
else:
raw_conf["default_name"] = (
- player.display_name if player else raw_conf.get("default_name")
+ player.state.name if player else raw_conf.get("default_name")
)
- raw_conf["available"] = player.available if player else False
+ raw_conf["available"] = player.state.available if player else False
result.append(cast("PlayerConfig", PlayerConfig.parse([], raw_conf)))
return result
async def get_player_config(
self,
player_id: str,
- action: str | None = None,
- values: dict[str, ConfigValueType] | None = None,
) -> PlayerConfig:
"""Return (full) configuration for a single player."""
raw_conf: dict[str, Any]
if raw_conf := self.get(f"{CONF_PLAYERS}/{player_id}"):
- if player := self.mass.players.get(player_id, False):
- raw_conf["default_name"] = player.display_name
+ raw_conf = deepcopy(raw_conf)
+ if player := self.mass.players.get_player(player_id, False):
+ raw_conf["default_name"] = player.state.name
raw_conf["provider"] = player.provider.instance_id
- # pass action and values to get_config_entries
- if values is None:
- values = raw_conf.get("values", {})
- conf_entries = await self.get_player_config_entries(
- player_id, action=action, values=values
+ config_entries = await self.get_player_config_entries(
+ player_id,
)
+ # also grab (raw) values for protocol outputs
+ if protocol_values := await self._get_output_protocol_config_values(config_entries):
+ if "values" not in raw_conf:
+ raw_conf["values"] = {}
+ raw_conf["values"].update(protocol_values)
else:
# handle unavailable player and/or provider
- conf_entries = []
+ config_entries = []
raw_conf["available"] = False
raw_conf["default_name"] = raw_conf.get("default_name") or raw_conf["player_id"]
- return cast("PlayerConfig", PlayerConfig.parse(conf_entries, raw_conf))
+
+ return cast("PlayerConfig", PlayerConfig.parse(config_entries, raw_conf))
msg = f"No config found for player id {player_id}"
raise KeyError(msg)
action: [optional] action key called from config entries UI.
values: the (intermediate) raw values for config entries sent with the action.
"""
- if not (player := self.mass.players.get(player_id, False)):
+ if not (player := self.mass.players.get_player(player_id, False)):
msg = f"Player {player_id} not found"
raise KeyError(msg)
- # get player(protocol) specific entries
- player_entries = await self._get_player_config_entries(player, action=action, values=values)
- # get default entries which are common for all players
- default_entries = self._get_default_player_config_entries(player)
+
+ default_entries: list[ConfigEntry]
+ player_entries: list[ConfigEntry]
+ if player.state.type == PlayerType.PROTOCOL:
+ default_entries = []
+ player_entries = await self._get_player_config_entries(
+ player, action=action, values=values
+ )
+ else:
+ # get default entries which are common for all (non protocol)players
+ default_entries = self._get_default_player_config_entries(player)
+
+ # get player(protocol) specific entries
+ # this basically injects virtual config entries for each protocol output
+ # this feels maybe a bit of a hack to do it this way but it keeps the UI logic simple
+ # and maximizes api client compatibility because you can configure the whole player
+ # including its protocols from a single config endpoint without needing special handling
+ # for protocol players in the UI/api clients
+ if protocol_entries := await self._create_output_protocol_config_entries(
+ player, action=action, values=values
+ ):
+ player_entries = protocol_entries
+ else:
+ player_entries = await self._get_player_config_entries(
+ player, action=action, values=values
+ )
+
player_entries_keys = {entry.key for entry in player_entries}
all_entries = [
# ignore default entries that were overridden by the player specific ones
self, player_id: str, values: dict[str, ConfigValueType]
) -> PlayerConfig:
"""Save/update PlayerConfig."""
+ values = await self._update_output_protocol_config(values)
config = await self.get_player_config(player_id)
old_config = deepcopy(config)
changed_keys = config.update(values)
if not player_config:
msg = f"Player configuration for {player_id} does not exist"
raise KeyError(msg)
- if self.mass.players.get(player_id):
+ if self.mass.players.get_player(player_id):
try:
await self.mass.players.remove(player_id)
except UnsupportedFeaturedException:
"""
Create builtin ProviderConfig.
- This is meant as helper to create default configs for builtin providers.
+ This is meant as helper to create default configs for builtin/default providers.
Called by the server initialization code which load all providers at startup.
"""
for _ in await self.get_provider_configs(provider_domain=provider_domain):
# some type hints to help with the code below
instance_id: str
+ player_id: str
provider_config: dict[str, Any]
player_config: dict[str, Any]
# Older versions of MA can create corrupt entries with no domain if retrying
# logic runs after a provider has been removed. Remove those corrupt entries.
+ # TODO: remove after 2.8 release
for instance_id, provider_config in {**self._data.get(CONF_PROVIDERS, {})}.items():
if "domain" not in provider_config:
self._data[CONF_PROVIDERS].pop(instance_id, None)
changed = True
# migrate manual_ips to new format
+ # TODO: remove after 2.8 release
for instance_id, provider_config in self._data.get(CONF_PROVIDERS, {}).items():
if not (values := provider_config.get("values")):
continue
changed = True
# migrate sample_rates config entry
+ # TODO: remove after 2.8 release
for player_config in self._data.get(CONF_PLAYERS, {}).values():
if not (values := player_config.get("values")):
continue
]
changed = True
- # migrate player_group entries
- ugp_found = False
- for player_config in self._data.get(CONF_PLAYERS, {}).values():
- provider = player_config.get("provider")
- if (
- not provider
- or not isinstance(provider, str)
- or not provider.startswith("player_group")
- ):
- continue
- if not (values := player_config.get("values")):
- continue
- if (group_type := values.pop("group_type", None)) is None:
- continue
- # this is a legacy player group, migrate the values
- changed = True
- if group_type == "universal":
- player_config["provider"] = "universal_group"
- ugp_found = True
- else:
- player_config["provider"] = group_type
- for provider_config in list(self._data.get(CONF_PROVIDERS, {}).values()):
- instance_id = provider_config["instance_id"]
- if not instance_id.startswith("player_group"):
- continue
- # this is the legacy player_group provider, migrate into 'universal_group'
- changed = True
- self._data[CONF_PROVIDERS].pop(instance_id, None)
- if not ugp_found:
- continue
- provider_config["domain"] = "universal_group"
- provider_config["instance_id"] = "universal_group"
- self._data[CONF_PROVIDERS]["universal_group"] = provider_config
-
- # Migrate resonate provider to sendspin (renamed in 2.7 beta 19)
- for instance_id, provider_config in list(self._data.get(CONF_PROVIDERS, {}).items()):
- if provider_config.get("domain") == "resonate":
- self._data[CONF_PROVIDERS].pop(instance_id, None)
- provider_config["domain"] = "sendspin"
- provider_config["instance_id"] = "sendspin"
- self._data[CONF_PROVIDERS]["sendspin"] = provider_config
- changed = True
-
- # Migrate smart_fades mode value to smart_crossfade
- for player_config in self._data.get(CONF_PLAYERS, {}).values():
- if not (values := player_config.get("values")):
- continue
- if values.get(CONF_SMART_FADES_MODE) == "smart_fades":
- # Update old 'smart_fades' value to new 'smart_crossfade' value
- values[CONF_SMART_FADES_MODE] = "smart_crossfade"
- changed = True
-
# Remove obsolete builtin_player configurations (provider was deleted in 2.7)
+ # TODO: remove after 2.8 release
for player_id, player_config in list(self._data.get(CONF_PLAYERS, {}).items()):
if player_config.get("provider") != "builtin_player":
continue
changed = True
# Remove corrupt player configurations that are missing the required 'provider' key
+ # or have an invalid/removed provider
+ all_provider_ids: set[str] = set(self._data.get(CONF_PROVIDERS, {}).keys())
+ # TODO: remove after 2.8 release
for player_id, player_config in list(self._data.get(CONF_PLAYERS, {}).items()):
- if "provider" in player_config:
+ player_provider = player_config.get("provider")
+ if not player_provider:
+ LOGGER.warning("Removing corrupt player configuration: %s", player_id)
+ elif player_provider not in all_provider_ids:
+ LOGGER.warning("Removed orphaned player configuration: %s", player_id)
+ else:
continue
self._data[CONF_PLAYERS].pop(player_id, None)
# Also remove any DSP config for this player
if CONF_PLAYER_DSP in self._data:
self._data[CONF_PLAYER_DSP].pop(player_id, None)
- LOGGER.warning("Removed corrupt player configuration (missing provider): %s", player_id)
changed = True
- # migrate player configs: always use instance_id for provider
- for player_config in self._data.get(CONF_PLAYERS, {}).values():
- if "provider" not in player_config:
+ # migrate sync_group players to use the new sync_group provider
+ # TODO: remove after 2.8 release
+ for player_id, player_config in list(self._data.get(CONF_PLAYERS, {}).items()):
+ if not player_id.startswith(SGP_PREFIX):
continue
player_provider = player_config["provider"]
- try:
- if not (prov := self.mass.get_provider(player_provider)):
- continue
- except KeyError:
- # removed provider
- continue
- if player_config["provider"] == prov.instance_id:
+ if player_provider == "sync_group":
continue
- player_config["provider"] = prov.instance_id
+ player_config["provider"] = "sync_group"
changed = True
# Migrate AirPlay legacy credentials (ap_credentials) to protocol-specific keys
# The old key was used for both RAOP and AirPlay, now we have separate keys
+ # TODO: remove after 2.8 release
for player_id, player_config in self._data.get(CONF_PLAYERS, {}).items():
if player_config.get("provider") != "airplay":
continue
values: the (intermediate) raw values for config entries sent with the action.
"""
default_entries: list[ConfigEntry]
- is_dedicated_group_player = player.type in (
+ is_dedicated_group_player = player.state.type in (
PlayerType.GROUP,
PlayerType.STEREO_PAIR,
- ) and not player.player_id.startswith(("universal_", SYNCGROUP_PREFIX))
+ ) and not player.player_id.startswith((UGP_PREFIX, SGP_PREFIX))
is_http_based_player_protocol = player.provider.domain not in NON_HTTP_PROVIDERS
- if player.type == PlayerType.GROUP and not is_dedicated_group_player:
+ if player.state.type == PlayerType.GROUP and not is_dedicated_group_player:
# no audio related entries for universal group players or sync group players
default_entries = []
else:
"""
entries: list[ConfigEntry] = []
# default protocol-player config entries
- if player.type == PlayerType.PROTOCOL:
+ if player.state.type == PlayerType.PROTOCOL:
# protocol players have no generic config entries
# only audio/protocol specific ones
return []
),
]
# group-player config entries
- if player.type == PlayerType.GROUP:
+ if player.state.type == PlayerType.GROUP:
entries += [
CONF_ENTRY_PLAYER_ICON_GROUP,
]
volume_controls = [x for x in all_controls if x.supports_volume]
mute_controls = [x for x in all_controls if x.supports_mute]
# work out player supported features
- supports_power = PlayerFeature.POWER in player.supported_features
- supports_volume = PlayerFeature.VOLUME_SET in player.supported_features
- supports_mute = PlayerFeature.VOLUME_MUTE in player.supported_features
- # create base options per control type (and add defaults like native and fake)
- base_power_options: list[ConfigValueOption] = [
- ConfigValueOption(title="None", value=PLAYER_CONTROL_NONE),
- ConfigValueOption(title="Fake power control", value=PLAYER_CONTROL_FAKE),
- ]
- if supports_power:
+ base_power_options: list[ConfigValueOption] = []
+ if player.supports_feature(PlayerFeature.POWER):
base_power_options.append(
ConfigValueOption(title="Native power control", value=PLAYER_CONTROL_NATIVE),
)
- base_volume_options: list[ConfigValueOption] = [
- ConfigValueOption(title="None", value=PLAYER_CONTROL_NONE),
- ]
- if supports_volume:
+ base_volume_options: list[ConfigValueOption] = []
+ if player.supports_feature(PlayerFeature.VOLUME_SET):
base_volume_options.append(
ConfigValueOption(title="Native volume control", value=PLAYER_CONTROL_NATIVE),
)
- base_mute_options: list[ConfigValueOption] = [
+ base_mute_options: list[ConfigValueOption] = []
+ if player.supports_feature(PlayerFeature.VOLUME_MUTE):
+ base_mute_options.append(
+ ConfigValueOption(title="Native mute control", value=PLAYER_CONTROL_NATIVE),
+ )
+ # append protocol-specific volume and mute controls to the base options
+ for linked_protocol in player.linked_output_protocols:
+ if protocol_player := self.mass.players.get_player(linked_protocol.output_protocol_id):
+ if protocol_player.supports_feature(PlayerFeature.VOLUME_SET):
+ base_volume_options.append(
+ ConfigValueOption(
+ title=linked_protocol.name, value=linked_protocol.output_protocol_id
+ )
+ )
+ if protocol_player.supports_feature(PlayerFeature.VOLUME_MUTE):
+ base_mute_options.append(
+ ConfigValueOption(
+ title=linked_protocol.name,
+ value=linked_protocol.output_protocol_id,
+ )
+ )
+ # append none+fake options
+ base_power_options += [
+ ConfigValueOption(title="None", value=PLAYER_CONTROL_NONE),
+ ConfigValueOption(title="Fake power control", value=PLAYER_CONTROL_FAKE),
+ ]
+ base_volume_options += [
ConfigValueOption(title="None", value=PLAYER_CONTROL_NONE),
- ConfigValueOption(title="Fake mute control", value=PLAYER_CONTROL_FAKE),
]
- if supports_mute:
+ base_mute_options.append(ConfigValueOption(title="None", value=PLAYER_CONTROL_NONE))
+ if player.supports_feature(PlayerFeature.VOLUME_SET):
base_mute_options.append(
- ConfigValueOption(title="Native mute control", value=PLAYER_CONTROL_NATIVE),
+ ConfigValueOption(title="Fake mute control", value=PLAYER_CONTROL_FAKE)
)
+
# return final config entries for all options
return [
# Power control config entry
key=CONF_POWER_CONTROL,
type=ConfigEntryType.STRING,
label="Power Control",
- default_value=PLAYER_CONTROL_NATIVE if supports_power else PLAYER_CONTROL_NONE,
- required=True,
+ default_value=base_power_options[0].value
+ if base_power_options
+ else PLAYER_CONTROL_NONE,
+ required=False,
options=[
*base_power_options,
*(ConfigValueOption(x.name, x.id) for x in power_controls),
],
category="player_controls",
- hidden=player.type == PlayerType.GROUP,
),
# Volume control config entry
ConfigEntry(
key=CONF_VOLUME_CONTROL,
type=ConfigEntryType.STRING,
label="Volume Control",
- default_value=PLAYER_CONTROL_NATIVE if supports_volume else PLAYER_CONTROL_NONE,
+ default_value=base_volume_options[0].value
+ if base_volume_options
+ else PLAYER_CONTROL_NONE,
required=True,
options=[
*base_volume_options,
*(ConfigValueOption(x.name, x.id) for x in volume_controls),
],
category="player_controls",
- hidden=player.type == PlayerType.GROUP,
),
# Mute control config entry
ConfigEntry(
key=CONF_MUTE_CONTROL,
type=ConfigEntryType.STRING,
label="Mute Control",
- default_value=PLAYER_CONTROL_NATIVE if supports_mute else PLAYER_CONTROL_NONE,
+ default_value=base_mute_options[0].value
+ if base_mute_options
+ else PLAYER_CONTROL_NONE,
required=True,
options=[
*base_mute_options,
*[ConfigValueOption(x.name, x.id) for x in mute_controls],
],
category="player_controls",
- hidden=player.type == PlayerType.GROUP,
),
# auto-play on power on control config entry
CONF_ENTRY_AUTO_PLAY,
]
+
+ async def _create_output_protocol_config_entries( # noqa: PLR0915
+ self,
+ player: Player,
+ action: str | None = None,
+ values: dict[str, ConfigValueType] | None = None,
+ ) -> list[ConfigEntry]:
+ """
+ Create config entry for preferred output protocol.
+
+ Returns empty list if there are no output protocol options (native only or no protocols).
+ The player.output_protocols property includes native, active, and disabled protocols,
+ with the available flag indicating their status.
+ """
+ all_entries: list[ConfigEntry] = []
+ output_protocols = player.output_protocols
+
+ # Only show config if there are multiple output options
+ if len(output_protocols) <= 1:
+ return all_entries
+
+ # Build options from available output protocols, sorted by priority
+ options: list[ConfigValueOption] = []
+ default_value: str | None = None
+
+ # Add each available output protocol as an option, sorted by priority
+ for protocol in sorted(output_protocols, key=lambda p: p.priority):
+ if provider_manifest := self.mass.get_provider_manifest(protocol.protocol_domain):
+ protocol_name = provider_manifest.name
+ else:
+ protocol_name = protocol.protocol_domain.upper()
+ if protocol.available:
+ # Use "native" for native playback,
+ # otherwise use the protocol output id (=player id)
+ title = f"{protocol_name} (native)" if protocol.is_native else protocol_name
+ value = "native" if protocol.is_native else protocol.output_protocol_id
+ options.append(ConfigValueOption(title=title, value=value))
+ # First available protocol becomes the default (highest priority)
+ if default_value is None:
+ default_value = str(value)
+
+ all_entries.append(
+ ConfigEntry(
+ key=CONF_PREFERRED_OUTPUT_PROTOCOL,
+ type=ConfigEntryType.STRING,
+ label="Preferred Output Protocol",
+ description="Select the preferred protocol for audio playback to this device.",
+ default_value=default_value or "native",
+ required=True,
+ options=options,
+ category="protocol_general",
+ requires_reload=False,
+ )
+ )
+
+ # Add config entries for all protocol players/outputs
+ for protocol in output_protocols:
+ domain = protocol.protocol_domain
+ if provider_manifest := self.mass.get_provider_manifest(protocol.protocol_domain):
+ protocol_name = provider_manifest.name
+ else:
+ protocol_name = protocol.protocol_domain.upper()
+ protocol_player_enabled = self.get_raw_player_config_value(
+ protocol.output_protocol_id, CONF_ENABLED, True
+ )
+ provider_available = self.mass.get_provider(protocol.protocol_domain) is not None
+ if not provider_available:
+ # protocol provider is not available, skip adding entries
+ continue
+ protocol_prefix = f"{protocol.output_protocol_id}{CONF_PROTOCOL_KEY_SPLITTER}"
+ protocol_enabled_key = f"{protocol_prefix}enabled"
+ protocol_category = f"{CONF_PROTOCOL_CATEGORY_PREFIX}_{domain}"
+ category_translation_key = "settings.category.protocol_output_settings"
+ if not protocol.is_native:
+ all_entries.append(
+ ConfigEntry(
+ key=protocol_enabled_key,
+ type=ConfigEntryType.BOOLEAN,
+ label="Enable",
+ description="Enable or disable this output protocol for the player.",
+ value=protocol_player_enabled,
+ default_value=protocol_player_enabled,
+ category=protocol_category,
+ category_translation_key=category_translation_key,
+ category_translation_params=[protocol_name],
+ requires_reload=False,
+ )
+ )
+ if protocol.is_native:
+ # add protocol-specific entries from native player
+ protocol_entries = await self._get_player_config_entries(
+ player, action=action, values=values
+ )
+ for proto_entry in protocol_entries:
+ # deep copy to avoid mutating shared/constant ConfigEntry objects
+ entry = deepcopy(proto_entry)
+ entry.category = protocol_category
+ entry.category_translation_key = category_translation_key
+ entry.category_translation_params = [protocol_name]
+ all_entries.append(entry)
+
+ elif protocol_player := self.mass.players.get_player(protocol.output_protocol_id):
+ # we grab the config entries from the protocol player
+ # and then prefix them to avoid key collisions
+
+ if action and protocol_prefix in action:
+ protocol_action = action.replace(protocol_prefix, "")
+ else:
+ protocol_action = None
+ if values:
+ # extract only relevant values for this protocol player
+ protocol_values = {
+ key.replace(protocol_prefix, ""): val
+ for key, val in values.items()
+ if key.startswith(protocol_prefix)
+ }
+ else:
+ protocol_values = None
+ protocol_entries = await self._get_player_config_entries(
+ protocol_player, action=protocol_action, values=protocol_values
+ )
+ for proto_entry in protocol_entries:
+ # deep copy to avoid mutating shared/constant ConfigEntry objects
+ entry = deepcopy(proto_entry)
+ entry.category = protocol_category
+ entry.category_translation_key = category_translation_key
+ entry.category_translation_params = [protocol_name]
+ entry.key = f"{protocol_prefix}{entry.key}"
+ entry.depends_on = None if protocol.is_native else protocol_enabled_key
+ entry.action = f"{protocol_prefix}{entry.action}" if entry.action else None
+ all_entries.append(entry)
+
+ return all_entries
+
+ async def _update_output_protocol_config(
+ self, values: dict[str, ConfigValueType]
+ ) -> dict[str, ConfigValueType]:
+ """
+ Update output protocol related config for a player based on config values.
+
+ Returns updated values dict with output protocol related entries removed.
+ """
+ protocol_values: dict[str, dict[str, ConfigValueType]] = {}
+ for key, value in list(values.items()):
+ if CONF_PROTOCOL_KEY_SPLITTER not in key:
+ continue
+ # extract protocol player id and actual key
+ protocol_player_id, actual_key = key.split(CONF_PROTOCOL_KEY_SPLITTER)
+ if protocol_player_id not in protocol_values:
+ protocol_values[protocol_player_id] = {}
+ protocol_values[protocol_player_id][actual_key] = value
+ # remove from main values dict
+ del values[key]
+ for protocol_player_id, proto_values in protocol_values.items():
+ await self.save_player_config(protocol_player_id, proto_values)
+ if proto_values.get(CONF_ENABLED):
+ # wait max 10 seconds for protocol to become available
+ for _ in range(10):
+ protocol_player = self.mass.players.get_player(protocol_player_id)
+ if protocol_player is not None:
+ break
+ await asyncio.sleep(1)
+ # wait max 10 seconds for protocol
+ return values
+
+ async def _get_output_protocol_config_values(
+ self,
+ entries: list[ConfigEntry],
+ ) -> dict[str, ConfigValueType]:
+ """Extract output protocol related config values for given (parent) player entries."""
+ values: dict[str, ConfigValueType] = {}
+ for entry in entries:
+ if CONF_PROTOCOL_KEY_SPLITTER not in entry.key:
+ continue
+ protocol_player_id, actual_key = entry.key.split(CONF_PROTOCOL_KEY_SPLITTER)
+ stored_value = self.get_raw_player_config_value(protocol_player_id, actual_key)
+ if stored_value is None:
+ continue
+ values[entry.key] = stored_value
+ return values
EventType,
MediaType,
PlaybackState,
- PlayerFeature,
ProviderFeature,
QueueOption,
RepeatMode,
VERBOSE_LOG_LEVEL,
PlaylistPlayableItem,
)
+from music_assistant.controllers.players.controller import IN_QUEUE_COMMAND
from music_assistant.controllers.webserver.helpers.auth_middleware import get_current_user
from music_assistant.helpers.api import api_command
from music_assistant.helpers.audio import get_stream_details, get_stream_dsp_details
@api_command("player_queues/get_active_queue")
def get_active_queue(self, player_id: str) -> PlayerQueue | None:
"""Return the current active/synced queue for a player."""
- if player := self.mass.players.get(player_id):
+ if player := self.mass.players.get_player(player_id):
return self.mass.players.get_active_queue(player)
return None
if not (queue := self.get(queue_id)):
raise PlayerUnavailableError(f"Queue {queue_id} is not available")
# always fetch the underlying player so we can raise early if its not available
- queue_player = self.mass.players.get(queue_id, True)
+ queue_player = self.mass.players.get_player(queue_id, True)
assert queue_player is not None # for type checking
if queue_player.extra_data.get(ATTR_ANNOUNCEMENT_IN_PROGRESS):
self.logger.warning("Ignore queue command: An announcement is in progress")
- queue_id: queue_id of the playerqueue to handle the command.
"""
- queue_player = self.mass.players.get(queue_id, True)
+ queue_player = self.mass.players.get_player(queue_id, True)
if queue_player is None:
raise PlayerUnavailableError(f"Player {queue_id} is not available")
if (queue := self.get(queue_id)) and queue.active:
if queue.state == PlaybackState.PLAYING:
queue.resume_pos = int(queue.corrected_elapsed_time)
- # forward the actual command to the player
- if temp_player := self.mass.players.get(queue_id):
- await temp_player.stop()
+ # Set context to prevent circular call, then forward the actual command to the player
+ token = IN_QUEUE_COMMAND.set(True)
+ try:
+ await self.mass.players.cmd_stop(queue_id)
+ finally:
+ IN_QUEUE_COMMAND.reset(token)
@api_command("player_queues/play")
async def play(self, queue_id: str) -> None:
- queue_id: queue_id of the playerqueue to handle the command.
"""
- queue_player = self.mass.players.get(queue_id, True)
+ queue_player = self.mass.players.get_player(queue_id, True)
if queue_player is None:
raise PlayerUnavailableError(f"Player {queue_id} is not available")
if (
- queue_id: queue_id of the playerqueue to handle the command.
"""
- if queue := self._queues.get(queue_id):
- if queue.state == PlaybackState.PLAYING:
- queue.resume_pos = int(queue.corrected_elapsed_time)
- # forward the actual command to the player controller
- queue_player = self.mass.players.get(queue_id)
- assert queue_player is not None # for type checking
- if not (self.mass.players.get_player_provider(queue_id)):
- return # guard
-
- if PlayerFeature.PAUSE not in queue_player.supported_features:
- # if player does not support pause, we need to send stop
- await queue_player.stop()
+ if not (queue := self._queues.get(queue_id)):
return
- await queue_player.pause()
-
- async def _watch_pause() -> None:
+ queue_active = queue.active
+ if queue.active and queue.state == PlaybackState.PLAYING:
+ queue.resume_pos = int(queue.corrected_elapsed_time)
+ # forward the actual command to the player controller
+ # Set context to prevent circular call, then forward the actual command to the player
+ token = IN_QUEUE_COMMAND.set(True)
+ try:
+ await self.mass.players.cmd_pause(queue_id)
+ finally:
+ IN_QUEUE_COMMAND.reset(token)
+
+ async def _watch_pause(player: Player) -> None:
count = 0
# wait for pause
- while count < 5 and queue_player.playback_state == PlaybackState.PLAYING:
+ while count < 5 and player.state.playback_state == PlaybackState.PLAYING:
count += 1
await asyncio.sleep(1)
# wait for unpause
- if queue_player.playback_state != PlaybackState.PAUSED:
+ if player.state.playback_state != PlaybackState.PAUSED:
return
count = 0
- while count < 30 and queue_player.playback_state == PlaybackState.PAUSED:
+ while count < 30 and player.state.playback_state == PlaybackState.PAUSED:
count += 1
await asyncio.sleep(1)
# if player is still paused when the limit is reached, send stop
- if queue_player.playback_state == PlaybackState.PAUSED:
- await queue_player.stop()
+ if player.state.playback_state == PlaybackState.PAUSED:
+ await self.stop(queue_id)
# we auto stop a player from paused when its paused for 30 seconds
- if not queue_player.extra_data.get(ATTR_ANNOUNCEMENT_IN_PROGRESS):
- self.mass.create_task(_watch_pause())
+ if (
+ queue_active
+ and (queue_player := self.mass.players.get_player(queue_id))
+ and not queue_player.extra_data.get(ATTR_ANNOUNCEMENT_IN_PROGRESS)
+ ):
+ self.mass.create_task(_watch_pause(queue_player))
@api_command("player_queues/play_pause")
async def play_pause(self, queue_id: str) -> None:
- queue_id: queue_id of the queue to handle the command.
"""
if (queue := self.get(queue_id)) is None or not queue.active:
- # TODO: forward to underlying player if not active
- return
+ raise InvalidCommand(f"Queue {queue_id} is not active")
idx = self._queues[queue_id].current_index
if idx is None:
self.logger.warning("Queue %s has no current index", queue.display_name)
- queue_id: queue_id of the queue to handle the command.
"""
if (queue := self.get(queue_id)) is None or not queue.active:
- # TODO: forward to underlying player if not active
- return
+ raise InvalidCommand(f"Queue {queue_id} is not active")
current_index = self._queues[queue_id].current_index
if current_index is None:
return
- seconds: number of seconds to skip in track. Use negative value to skip back.
"""
if (queue := self.get(queue_id)) is None or not queue.active:
- # TODO: forward to underlying player if not active
- return
+ raise InvalidCommand(f"Queue {queue_id} is not active")
await self.seek(queue_id, int(self._queues[queue_id].elapsed_time + seconds))
@api_command("player_queues/seek")
- queue_id: queue_id of the queue to handle the command.
- position: position in seconds to seek to in the current playing item.
"""
- if not (queue := self.get(queue_id)):
- return
- queue_player = self.mass.players.get(queue_id, True)
+ if (queue := self.get(queue_id)) is None or not queue.active:
+ raise InvalidCommand(f"Queue {queue_id} is not active")
+ queue_player = self.mass.players.get_player(queue_id, True)
if queue_player is None:
raise PlayerUnavailableError(f"Player {queue_id} is not available")
if not queue.current_item:
- raise InvalidCommand(f"Queue {queue_player.display_name} has no item(s) loaded.")
+ raise InvalidCommand(f"Queue {queue_player.state.name} has no item(s) loaded.")
if not queue.current_item.duration:
raise InvalidCommand("Can not seek items without duration.")
position = max(0, int(position))
if position > queue.current_item.duration:
raise InvalidCommand("Can not seek outside of duration range.")
if queue.current_index is None:
- raise InvalidCommand(f"Queue {queue_player.display_name} has no current index.")
+ raise InvalidCommand(f"Queue {queue_player.state.name} has no current index.")
await self.play_index(queue_id, queue.current_index, seek_position=position)
@api_command("player_queues/resume")
resume_pos = 0
if resume_item is not None:
- queue_player = self.mass.players.get(queue_id)
+ queue_player = self.mass.players.get_player(queue_id)
if queue_player is None:
raise PlayerUnavailableError(f"Player {queue_id} is not available")
if (
fade_in is None
- and queue_player.playback_state == PlaybackState.IDLE
+ and queue_player.state.playback_state == PlaybackState.IDLE
and (time.time() - queue.elapsed_time_last_updated) > 60
):
# enable fade in effect if the player is idle for a while
self.signal_update(queue_id)
queue.index_in_buffer = index
queue.flow_mode_stream_log = []
- target_player = self.mass.players.get(queue_id)
+ target_player = self.mass.players.get_player(queue_id)
if target_player is None:
raise PlayerUnavailableError(f"Player {queue_id} is not available")
queue.next_item_id_enqueued = None
if auto_play is None:
auto_play = source_queue.state == PlaybackState.PLAYING
- target_player = self.mass.players.get(target_queue_id)
+ target_player = self.mass.players.get_player(target_queue_id)
if target_player is None:
raise PlayerUnavailableError(f"Player {target_queue_id} is not available")
- if target_player.active_group or target_player.synced_to:
+ if target_player.state.active_group or target_player.state.synced_to:
# edge case: the user wants to move playback from the group as a whole, to a single
# player in the group or it is grouped and the command targeted at the single player.
# We need to dissolve the group first.
- group_id = target_player.active_group or target_player.synced_to
+ group_id = target_player.state.active_group or target_player.state.synced_to
assert group_id is not None # checked in if condition above
await self.mass.players.cmd_ungroup(group_id)
await asyncio.sleep(3)
except Exception as err:
self.logger.warning(
"Failed to restore the queue(items) for %s - %s",
- player.display_name,
+ player.state.name,
str(err),
)
# Reset to clean state on failure
queue = PlayerQueue(
queue_id=queue_id,
active=False,
- display_name=player.display_name,
- available=player.available,
+ display_name=player.state.name,
+ available=player.state.available,
dont_stop_the_music_enabled=False,
items=0,
)
# do nothing while the announcement is in progress
return
# determine if this queue is currently active for this player
- queue.active = player.active_source in (queue.queue_id, None)
+ queue.active = player.state.active_source in (queue.queue_id, None)
if not queue.active and queue_id not in self._prev_states:
queue.state = PlaybackState.IDLE
# return early if the queue is not active and we have no previous state
# handle error or return early
raise InvalidDataError("Queue session_id is None")
media = PlayerMedia(
- uri=await self.mass.streams.resolve_stream_url(
- queue.session_id, queue_item, flow_mode=flow_mode
- ),
+ uri=queue_item.uri,
media_type=MediaType.FLOW_STREAM if flow_mode else queue_item.media_type,
title="Music Assistant" if flow_mode else queue_item.name,
image_url=MASS_LOGO_ONLINE,
duration=duration,
source_id=queue_item.queue_id,
queue_item_id=queue_item.queue_item_id,
+ custom_data={
+ "session_id": queue.session_id,
+ "original_uri": queue_item.uri,
+ "flow_mode": flow_mode,
+ },
)
if not flow_mode and queue_item.media_item:
media.title = queue_item.media_item.name
queue = self._queues[queue_id]
# basic properties
- queue.display_name = player.display_name
- queue.available = player.available
+ queue.display_name = player.state.name
+ queue.available = player.state.available
queue.items = len(self._queue_items[queue_id])
queue.state = (
- player.playback_state or PlaybackState.IDLE if queue.active else PlaybackState.IDLE
+ player.state.playback_state or PlaybackState.IDLE
+ if queue.active
+ else PlaybackState.IDLE
)
# update current item/index from player report
if queue.active and queue.state in (
current_index, elapsed_time = self._get_flow_queue_stream_index(queue, player)
elif item_id := self._parse_player_current_item_id(queue_id, player):
# normal mode, the player itself will report the current item
- elapsed_time = int(player.corrected_elapsed_time or 0)
+ elapsed_time = int(player.state.corrected_elapsed_time or 0)
current_index = self.index_by_id(queue_id, item_id)
else:
# this may happen if the player is still transitioning between tracks
output_formats = []
if output_format := player.extra_data.get("output_format"):
output_formats.append(str(output_format))
- for child_id in player.group_members:
- if (child := self.mass.players.get(child_id)) and (
+ for child_id in player.state.group_members:
+ if (child := self.mass.players.get_player(child_id)) and (
output_format := child.extra_data.get("output_format")
):
output_formats.append(str(output_format))
self, queue: PlayerQueue, player: Player
) -> tuple[int | None, int]:
"""Calculate current queue index and current track elapsed time when flow mode is active."""
- elapsed_time_queue_total = player.corrected_elapsed_time or 0
+ elapsed_time_queue_total = player.state.corrected_elapsed_time or 0
if queue.current_index is None and not queue.flow_mode_stream_log:
return queue.current_index, int(queue.elapsed_time)
track_sec_skipped = 0
track_time = elapsed_time_queue_total + track_sec_skipped - played_time
break
- if player.playback_state != PlaybackState.PLAYING:
+ if player.state.playback_state != PlaybackState.PLAYING:
# if the player is not playing, we can't be sure that the elapsed time is correct
# so we just return the queue index and the elapsed time
return queue.current_index, int(queue.elapsed_time)
def _parse_player_current_item_id(self, queue_id: str, player: Player) -> str | None:
"""Parse QueueItem ID from Player's current url."""
- if not player._current_media:
- # YES, we use player._current_media on purpose here because we need the raw metadata
+ protocol_player = player
+ if player.active_output_protocol and player.active_output_protocol != "native":
+ protocol_player = self.mass.players.get_player(player.active_output_protocol) or player
+ if not protocol_player.current_media:
+ # YES, we use player.current_media on purpose here because we need the raw metadata
return None
# prefer queue_id and queue_item_id within the current media
- if player._current_media.source_id == queue_id and player._current_media.queue_item_id:
- return player._current_media.queue_item_id
+ if (
+ protocol_player.current_media.source_id == queue_id
+ and protocol_player.current_media.queue_item_id
+ ):
+ return protocol_player.current_media.queue_item_id
# special case for sonos players
- if player._current_media.uri and player._current_media.uri.startswith(f"mass:{queue_id}"):
- if player._current_media.queue_item_id:
- return player._current_media.queue_item_id
- return player._current_media.uri.split(":")[-1]
+ if protocol_player.current_media.uri and protocol_player.current_media.uri.startswith(
+ f"mass:{queue_id}"
+ ):
+ if protocol_player.current_media.queue_item_id:
+ return protocol_player.current_media.queue_item_id
+ return protocol_player.current_media.uri.split(":")[-1]
# try to extract the item id from a mass stream url
if (
- player._current_media.uri
- and queue_id in player._current_media.uri
- and self.mass.streams.base_url in player._current_media.uri
+ protocol_player.current_media.uri
+ and queue_id in protocol_player.current_media.uri
+ and self.mass.streams.base_url in protocol_player.current_media.uri
):
- current_item_id = player._current_media.uri.rsplit("/")[-1].split(".")[0]
+ current_item_id = protocol_player.current_media.uri.rsplit("/")[-1].split(".")[0]
if self.get_item(queue_id, current_item_id):
return current_item_id
# try to extract the item id from a queue_id/item_id combi
if (
- player._current_media.uri
- and queue_id in player._current_media.uri
- and "/" in player._current_media.uri
+ protocol_player.current_media.uri
+ and queue_id in protocol_player.current_media.uri
+ and "/" in protocol_player.current_media.uri
):
- current_item_id = player._current_media.uri.split("/")[1]
+ current_item_id = protocol_player.current_media.uri.split("/")[1]
if self.get_item(queue_id, current_item_id):
return current_item_id
--- /dev/null
+# Player Controller Architecture
+
+This document provides an overview of the Music Assistant Player Controller architecture, including the Player/PlayerState model, multi-protocol player system, and universal player concept.
+
+## Table of Contents
+
+- [Overview](#overview)
+- [Player vs PlayerState](#player-vs-playerstate)
+- [Core Components](#core-components)
+- [Player Types](#player-types)
+- [Multi-Protocol Player System](#multi-protocol-player-system)
+- [Universal Player](#universal-player)
+- [Protocol Linking](#protocol-linking)
+- [Development Guide](#development-guide)
+
+## Overview
+
+The Player Controller is a core controller that manages all connected audio players from various providers. It provides:
+- Unified control interface for all players (play, pause, volume, etc.)
+- Multi-protocol player linking (combining AirPlay, Chromecast, DLNA for the same device)
+- Universal Player wrapping for devices without native vendor support
+- Sync group management for synchronized playback
+- Player state management and event broadcasting
+- User access control and permissions
+
+## Player vs PlayerState
+
+The Player Controller distinguishes between two key concepts:
+
+### Player (Internal Model)
+
+The `Player` class is the actual object provided by a Player Provider. It:
+- Incorporates the actual state of the player (volume, playback state, etc.)
+- Contains methods for controlling the player (play, pause, volume, etc.)
+- Is used internally by providers and the controller
+- May contain provider-specific implementation details
+
+### PlayerState (API Model)
+
+The `PlayerState` is a dataclass representing the final state of the player. It:
+- Includes any user customizations (custom name, hidden status, etc.)
+- Applies transformations (e.g., fake power/volume controls)
+- Is the object exposed to the outside world via the API
+- Is a snapshot created when `player.update_state()` is called
+- Contains only serializable data suitable for API consumers
+
+```
+┌─────────────────────────────────────────────────────────────────┐
+│ Player (Internal) │
+│ - Provider-specific implementation │
+│ - Control methods (play, pause, volume_set, etc.) │
+│ - Raw state (_attr_volume_level, _attr_playback_state, etc.) │
+│ - Device info and identifiers │
+└─────────────────────────────────┬───────────────────────────────┘
+ │
+ │ update_state()
+ ▼
+┌─────────────────────────────────────────────────────────────────┐
+│ PlayerState (API) │
+│ - Final display name (with user customizations) │
+│ - Transformed state (fake controls applied) │
+│ - Player controls configuration │
+│ - Serializable for API/WebSocket │
+└─────────────────────────────────────────────────────────────────┘
+```
+
+## Core Components
+
+### 1. PlayerController ([controller.py](controller.py))
+
+The main orchestrator that manages:
+- Player registration and lifecycle
+- Player commands (play, pause, stop, volume, etc.)
+- Protocol linking and evaluation
+- Universal player creation
+- Sync group coordination
+
+**Key responsibilities:**
+- Routes commands to appropriate players or protocol players
+- Manages player availability and state
+- Handles announcements and TTS playback
+- Coordinates sync groups and grouped playback
+
+### 2. ProtocolLinkingMixin ([protocol_linking.py](protocol_linking.py))
+
+Mixin class containing all protocol linking logic:
+- Matching protocol players to native players via device identifiers
+- Creating and managing Universal Players
+- Protocol link lifecycle (add, remove, cleanup)
+- Output protocol selection for playback
+
+### 3. Helper Utilities ([helpers.py](helpers.py))
+
+Contains standalone helper functions and decorators:
+- `handle_player_command` decorator for command validation
+- `AnnounceData` type definition
+
+## Player Types
+
+Players in Music Assistant have different types based on their capabilities:
+
+### PlayerType.PLAYER
+
+A regular player with native (vendor-specific) support. Examples:
+- Sonos speakers via the Sonos provider
+- Apple devices via the AirPlay provider (HomePod, Apple TV)
+- Google devices via the Chromecast provider (Nest Audio, Google Home)
+
+### PlayerType.PROTOCOL
+
+A generic protocol player without native vendor support. These are streaming endpoints discovered via generic protocols but manufactured by third parties. Examples:
+- Samsung TV discovered via AirPlay (not an Apple device)
+- Sony speaker discovered via Chromecast (not a Google device)
+- Any DLNA/UPnP device (always PROTOCOL type)
+
+**Important:** Protocol players with `PlayerType.PROTOCOL` are hidden from the UI and wrapped in a Universal Player or attached to an existing native player.
+
+### PlayerType.GROUP
+
+A group player that represents (synchronized) playback across multiple physical speakers.
+
+### PlayerType.STEREO_PAIR
+
+A dedicated stereo pair of two speakers acting as one player.
+
+## Multi-Protocol Player System
+
+Modern audio devices often support multiple streaming protocols (AirPlay, Chromecast, DLNA). The Player Controller automatically detects and links these protocols to provide a unified experience.
+
+### How It Works
+
+```
+┌─────────────────────────────────────────────────────────────────────┐
+│ Physical Device │
+│ (e.g., Samsung Soundbar) │
+├─────────────────────────────────────────────────────────────────────┤
+│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
+│ │ AirPlay │ │ Chromecast │ │ DLNA │ │
+│ │ Protocol │ │ Protocol │ │ Protocol │ │
+│ │ Player │ │ Player │ │ Player │ │
+│ │ (hidden) │ │ (hidden) │ │ (hidden) │ │
+│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │
+│ │ │ │ │
+│ └─────────────────┼─────────────────┘ │
+│ │ │
+│ ▼ │
+│ ┌─────────────────────────┐ │
+│ │ Universal Player │ │
+│ │ (visible in UI) │ │
+│ │ - Aggregates protocols │ │
+│ │ - Selects best output │ │
+│ │ - Unified control │ │
+│ └─────────────────────────┘ │
+└─────────────────────────────────────────────────────────────────────┘
+```
+
+### Device Identifier Matching
+
+Protocol players are matched to the same physical device using identifiers in order of reliability:
+
+1. **MAC_ADDRESS** - Most reliable, unique to the network interface
+2. **SERIAL_NUMBER** - Unique device serial number
+3. **UUID** - Universally unique identifier
+4. **player_id** - Fallback for players without identifiers (e.g., Sendspin)
+
+**Note:** IP_ADDRESS is intentionally NOT used for matching as it can change with DHCP and cause incorrect matches between different devices.
+
+**Fallback behavior:** Protocol players that don't expose any identifiers (like Sendspin clients) will still get wrapped in a Universal Player using their player_id as the device key. This ensures all protocol players get a consistent user-facing interface.
+
+### Output Protocol Selection
+
+When playing media, the controller selects the best output protocol:
+
+1. **Grouped protocol** - If a protocol is actively grouped/synced, use it
+2. **User preference** - Honor user's configured preferred protocol
+3. **Native playback** - Use native PLAY_MEDIA if available
+4. **Best available** - Select by protocol priority (AirPlay > Chromecast > DLNA)
+
+## Universal Player
+
+The Universal Player is a virtual player that wraps one or more protocol players when no native vendor support exists.
+
+### When Created
+
+A Universal Player is created when:
+1. A device is discovered via a protocol but has no native provider
+2. The device's protocol player has `PlayerType.PROTOCOL`
+3. There is no existing native player that matches the device identifiers
+
+### Features
+
+- **Aggregates Features** - Combines capabilities from all linked protocols
+- **No PLAY_MEDIA** - Delegates playback to protocol players
+- **Unified Control** - Single point of control for volume, power, etc.
+- **Protocol Selection** - Automatically selects best protocol for playback
+
+### Lifecycle
+
+```
+1. Protocol player registered with PlayerType.PROTOCOL
+2. Controller checks for cached parent_id from previous session:
+ - If found, restores link immediately (skips evaluation)
+ - If parent not yet registered, waits without creating universal player
+3. If no cached parent, checks for matching native player (links immediately if found)
+4. If no native player, schedules delayed evaluation:
+ - 10 seconds standard delay (allows other protocols to register)
+ - 30 seconds if previously linked to a native player (allows native provider to start)
+5. After delay, finds all matching protocol players by identifiers
+6. Creates UniversalPlayer and links all protocols
+7. Protocol players become hidden, Universal Player visible
+```
+
+## Protocol Linking
+
+### Native Player Linking
+
+When a native player (e.g., Sonos) is registered, the controller:
+1. Searches for protocol players with matching identifiers
+2. Links matching protocols to the native player
+3. Protocol players become hidden, native player gains `output_protocols`
+
+### Protocol to Universal
+
+When protocol players are registered without a native match:
+1. Each protocol player schedules a delayed evaluation
+2. After the delay, matching protocols are grouped
+3. A Universal Player is created to wrap them all
+4. All protocol players link to the Universal Player
+
+### Universal to Native Promotion
+
+When a native player appears for a device that has a Universal Player:
+1. Native player is registered
+2. Controller finds matching Universal Player
+3. All protocol links transfer to the native player
+4. Universal Player is removed
+5. Native player becomes the visible entity
+
+## Development Guide
+
+### Adding Protocol Support
+
+When implementing a new protocol provider:
+
+1. Set `_attr_type = PlayerType.PROTOCOL` for generic devices (non-vendor devices)
+2. Set `_attr_type = PlayerType.PLAYER` for devices with native support (vendor's own devices)
+3. Populate `device_info.identifiers` with MAC, UUID, etc. (see below)
+4. Filter out devices that should only be handled by native providers (e.g., passive satellites)
+5. The Player Controller handles linking automatically
+
+### Adding Native Provider Support
+
+When implementing a native provider (e.g., Sonos, Bluesound) that should link to protocol players:
+
+1. Set `_attr_type = PlayerType.PLAYER` (or the property 'type') for all devices
+2. **Populate device identifiers** - This is critical for protocol linking:
+ ```python
+ self._attr_device_info = DeviceInfo(
+ model="Device Model",
+ manufacturer="Manufacturer Name",
+ )
+ # Add identifiers in order of preference (MAC is most reliable)
+ self._attr_device_info.add_identifier(IdentifierType.MAC_ADDRESS, "AA:BB:CC:DD:EE:FF")
+ self._attr_device_info.add_identifier(IdentifierType.UUID, "device-uuid-here")
+ ```
+3. The controller will automatically:
+ - Find protocol players (AirPlay, Chromecast, DLNA) with matching identifiers
+ - Link them to your native player as `output_protocols`
+ - Replace any existing Universal Player for that device
+
+**Identifier Priority:**
+- `MAC_ADDRESS` - Most reliable, unique to network interface
+- `SERIAL_NUMBER` - Unique device serial number
+- `UUID` - Universally unique identifier
+- `player_id` - Fallback when no identifiers available
+
+**Note:** `IP_ADDRESS` is NOT used for matching as it can change with DHCP.
+
+### Testing Protocol Linking
+
+Key scenarios to test:
+
+1. **Single protocol device** - Should create Universal Player
+2. **Multi-protocol device** - All protocols linked to one Universal Player
+3. **Late protocol discovery** - New protocol added to existing Universal Player
+4. **Native player appears** - Universal Player replaced by native
+5. **Protocol disappears** - Handle graceful degradation
+
+### Configuration Storage
+
+Protocol links are persisted in player configuration:
+- `linked_protocol_player_ids` - List of protocol player IDs
+- Restored on restart for fast reconnection
+
+### Key Methods (in protocol_linking.py)
+
+- `_evaluate_protocol_links()` - Entry point for link evaluation
+- `_try_link_protocol_to_native()` - Link protocol to existing native
+- `_schedule_protocol_evaluation()` - Delay evaluation for batching
+- `_create_or_update_universal_player()` - Create/update Universal Player
+- `_check_replace_universal_player()` - Replace Universal with native
+- `_select_best_output_protocol()` - Choose protocol for playback
from __future__ import annotations
-from .player_controller import PlayerController
+from .controller import PlayerController
__all__ = ["PlayerController"]
--- /dev/null
+"""
+MusicAssistant PlayerController.
+
+Handles all logic to control supported players,
+which are provided by Player Providers.
+
+Note that the PlayerController has a concept of a 'player' and a 'playerstate'.
+The Player is the actual object that is provided by the provider,
+which incorporates the (unaltered) state of the player (e.g. volume, state, etc)
+and functions for controlling the player (e.g. play, pause, etc).
+
+The playerstate is the (final) state of the player, including any user customizations
+and transformations that are applied to the player.
+The playerstate is the object that is exposed to the outside world (via the API).
+"""
+
+from __future__ import annotations
+
+import asyncio
+import time
+from contextlib import suppress
+from contextvars import ContextVar
+from typing import TYPE_CHECKING, Any, cast
+
+from music_assistant_models.auth import UserRole
+from music_assistant_models.constants import (
+ PLAYER_CONTROL_FAKE,
+ PLAYER_CONTROL_NATIVE,
+ PLAYER_CONTROL_NONE,
+)
+from music_assistant_models.enums import (
+ EventType,
+ MediaType,
+ PlaybackState,
+ PlayerFeature,
+ PlayerType,
+ ProviderFeature,
+ ProviderType,
+)
+from music_assistant_models.errors import (
+ AlreadyRegisteredError,
+ InsufficientPermissions,
+ MusicAssistantError,
+ PlayerCommandFailed,
+ PlayerUnavailableError,
+ ProviderUnavailableError,
+ UnsupportedFeaturedException,
+)
+from music_assistant_models.player import PlayerOptionValueType # noqa: TC002
+from music_assistant_models.player_control import PlayerControl # noqa: TC002
+
+from music_assistant.constants import (
+ ANNOUNCE_ALERT_FILE,
+ ATTR_ANNOUNCEMENT_IN_PROGRESS,
+ ATTR_AVAILABLE,
+ ATTR_ELAPSED_TIME,
+ ATTR_ENABLED,
+ ATTR_FAKE_MUTE,
+ ATTR_FAKE_POWER,
+ ATTR_FAKE_VOLUME,
+ ATTR_GROUP_MEMBERS,
+ ATTR_LAST_POLL,
+ ATTR_MUTE_LOCK,
+ ATTR_PREVIOUS_VOLUME,
+ CONF_AUTO_PLAY,
+ CONF_ENTRY_ANNOUNCE_VOLUME,
+ CONF_ENTRY_ANNOUNCE_VOLUME_MAX,
+ CONF_ENTRY_ANNOUNCE_VOLUME_MIN,
+ CONF_ENTRY_ANNOUNCE_VOLUME_STRATEGY,
+ CONF_ENTRY_TTS_PRE_ANNOUNCE,
+ CONF_ENTRY_ZEROCONF_INTERFACES,
+ CONF_PLAYER_DSP,
+ CONF_PLAYERS,
+ CONF_PRE_ANNOUNCE_CHIME_URL,
+)
+from music_assistant.controllers.webserver.helpers.auth_middleware import (
+ get_current_user,
+ get_sendspin_player_id,
+)
+from music_assistant.helpers.api import api_command
+from music_assistant.helpers.tags import async_parse_tags
+from music_assistant.helpers.throttle_retry import Throttler
+from music_assistant.helpers.util import TaskManager, validate_announcement_chime_url
+from music_assistant.models.core_controller import CoreController
+from music_assistant.models.player import Player, PlayerMedia, PlayerState
+from music_assistant.models.player_provider import PlayerProvider
+from music_assistant.models.plugin import PluginProvider, PluginSource
+
+from .helpers import AnnounceData, handle_player_command
+from .protocol_linking import ProtocolLinkingMixin
+
+if TYPE_CHECKING:
+ from collections.abc import Iterator
+
+ from music_assistant_models.config_entries import (
+ ConfigEntry,
+ ConfigValueType,
+ CoreConfig,
+ PlayerConfig,
+ )
+ from music_assistant_models.player_queue import PlayerQueue
+
+ from music_assistant import MusicAssistant
+
+CACHE_CATEGORY_PLAYER_POWER = 1
+
+# Context variable to prevent circular calls between players and player_queues controllers
+IN_QUEUE_COMMAND: ContextVar[bool] = ContextVar("IN_QUEUE_COMMAND", default=False)
+
+
+class PlayerController(ProtocolLinkingMixin, CoreController):
+ """Controller holding all logic to control registered players."""
+
+ domain: str = "players"
+
+ def __init__(self, mass: MusicAssistant) -> None:
+ """Initialize core controller."""
+ super().__init__(mass)
+ self._players: dict[str, Player] = {}
+ self._controls: dict[str, PlayerControl] = {}
+ self.manifest.name = "Player Controller"
+ self.manifest.description = (
+ "Music Assistant's core controller which manages all players from all providers."
+ )
+ self.manifest.icon = "speaker-multiple"
+ self._poll_task: asyncio.Task[None] | None = None
+ self._player_throttlers: dict[str, Throttler] = {}
+ self._player_command_locks: dict[str, asyncio.Lock] = {}
+ # Lock to prevent race conditions during player registration
+ self._register_lock = asyncio.Lock()
+ # Track pending protocol player evaluations (delayed to allow all protocols to register)
+ self._pending_protocol_evaluations: dict[str, asyncio.TimerHandle] = {}
+
+ async def get_config_entries(
+ self,
+ action: str | None = None,
+ values: dict[str, ConfigValueType] | None = None,
+ ) -> tuple[ConfigEntry, ...]:
+ """Return Config Entries for the Player Controller."""
+ return (CONF_ENTRY_ZEROCONF_INTERFACES,)
+
+ async def setup(self, config: CoreConfig) -> None:
+ """Async initialize of module."""
+ self._poll_task = self.mass.create_task(self._poll_players())
+
+ async def close(self) -> None:
+ """Cleanup on exit."""
+ if self._poll_task and not self._poll_task.done():
+ self._poll_task.cancel()
+ # Cancel all pending protocol evaluations
+ for handle in self._pending_protocol_evaluations.values():
+ handle.cancel()
+ self._pending_protocol_evaluations.clear()
+
+ async def on_provider_loaded(self, provider: PlayerProvider) -> None:
+ """Handle logic when a provider is loaded."""
+
+ async def on_provider_unload(self, provider: PlayerProvider) -> None:
+ """Handle logic when a provider is (about to get) unloaded."""
+
+ @property
+ def providers(self) -> list[PlayerProvider]:
+ """Return all loaded/running MusicProviders."""
+ return cast("list[PlayerProvider]", self.mass.get_providers(ProviderType.PLAYER))
+
+ def all_players(
+ self,
+ return_unavailable: bool = True,
+ return_disabled: bool = False,
+ provider_filter: str | None = None,
+ return_protocol_players: bool = False,
+ ) -> list[Player]:
+ """
+ Return all registered players.
+
+ Note that this applies user filters for players (for non admin users).
+
+ :param return_unavailable [bool]: Include unavailable players.
+ :param return_disabled [bool]: Include disabled players.
+ :param provider_filter [str]: Optional filter by provider lookup key.
+ :param return_protocol_players [bool]: Include protocol players (hidden by default).
+
+ :return: List of Player objects.
+ """
+ current_user = get_current_user()
+ user_filter = (
+ current_user.player_filter
+ if current_user and current_user.role != UserRole.ADMIN
+ else None
+ )
+ current_sendspin_player = get_sendspin_player_id()
+ return [
+ player
+ for player in self._players.values()
+ if (player.state.available or return_unavailable)
+ and (player.state.enabled or return_disabled)
+ and (provider_filter is None or player.provider.instance_id == provider_filter)
+ and (
+ not user_filter
+ or player.player_id in user_filter
+ or player.player_id == current_sendspin_player
+ )
+ and (return_protocol_players or player.state.type != PlayerType.PROTOCOL)
+ ]
+
+ @api_command("players/all")
+ def all_player_states(
+ self,
+ return_unavailable: bool = True,
+ return_disabled: bool = False,
+ provider_filter: str | None = None,
+ return_protocol_players: bool = False,
+ ) -> list[PlayerState]:
+ """
+ Return PlayerState for all registered players.
+
+ :param return_unavailable [bool]: Include unavailable players.
+ :param return_disabled [bool]: Include disabled players.
+ :param provider_filter [str]: Optional filter by provider lookup key.
+ :param return_protocol_players [bool]: Include protocol players (hidden by default).
+
+ :return: List of PlayerState objects.
+ """
+ return [
+ player.state
+ for player in self.all_players(
+ return_unavailable=return_unavailable,
+ return_disabled=return_disabled,
+ provider_filter=provider_filter,
+ return_protocol_players=return_protocol_players,
+ )
+ ]
+
+ def get_player(
+ self,
+ player_id: str,
+ raise_unavailable: bool = False,
+ ) -> Player | None:
+ """
+ Return Player by player_id.
+
+ :param player_id [str]: ID of the player.
+ :param raise_unavailable [bool]: Raise if player is unavailable.
+
+ :raises PlayerUnavailableError: If player is unavailable and raise_unavailable is True.
+ :return: Player object or None.
+ """
+ if player := self._players.get(player_id):
+ if (not player.state.available or not player.state.enabled) and raise_unavailable:
+ msg = f"Player {player_id} is not available"
+ raise PlayerUnavailableError(msg)
+ return player
+ if raise_unavailable:
+ msg = f"Player {player_id} is not available"
+ raise PlayerUnavailableError(msg)
+ return None
+
+ @api_command("players/get")
+ def get_player_state(
+ self,
+ player_id: str,
+ raise_unavailable: bool = False,
+ ) -> PlayerState | None:
+ """
+ Return PlayerState by player_id.
+
+ :param player_id [str]: ID of the player.
+ :param raise_unavailable [bool]: Raise if player is unavailable.
+
+ :raises PlayerUnavailableError: If player is unavailable and raise_unavailable is True.
+ :return: Player object or None.
+ """
+ current_user = get_current_user()
+ user_filter = (
+ current_user.player_filter
+ if current_user and current_user.role != UserRole.ADMIN
+ else None
+ )
+ current_sendspin_player = get_sendspin_player_id()
+ if (
+ current_user
+ and user_filter
+ and player_id not in user_filter
+ and player_id != current_sendspin_player
+ ):
+ msg = f"{current_user.username} does not have access to player {player_id}"
+ raise InsufficientPermissions(msg)
+ if player := self.get_player(player_id, raise_unavailable):
+ return player.state
+ return None
+
+ def get_player_by_name(self, name: str) -> Player | None:
+ """
+ Return Player by name.
+
+ Performs case-insensitive matching against the player's state name
+ (the final name visible in clients and API).
+ If multiple players match, logs a warning and returns the first match.
+
+ :param name: Name of the player.
+ :return: Player object or None.
+ """
+ name_normalized = name.strip().lower()
+ matches: list[Player] = []
+
+ for player in self._players.values():
+ if player.state.name.strip().lower() == name_normalized:
+ matches.append(player)
+
+ if not matches:
+ return None
+
+ if len(matches) > 1:
+ player_ids = [p.player_id for p in matches]
+ self.logger.warning(
+ "players/get_by_name: Multiple players found with name '%s': %s - "
+ "returning first match (%s). "
+ "Consider using the players/get API with player_id instead "
+ "for unambiguous lookups.",
+ name,
+ player_ids,
+ matches[0].player_id,
+ )
+
+ return matches[0]
+
+ @api_command("players/get_by_name")
+ def get_player_state_by_name(self, name: str) -> PlayerState | None:
+ """
+ Return PlayerState by name.
+
+ :param name: Name of the player.
+ :return: PlayerState object or None.
+ """
+ current_user = get_current_user()
+ user_filter = (
+ current_user.player_filter
+ if current_user and current_user.role != UserRole.ADMIN
+ else None
+ )
+ current_sendspin_player = get_sendspin_player_id()
+ if player := self.get_player_by_name(name):
+ if (
+ current_user
+ and user_filter
+ and player.player_id not in user_filter
+ and player.player_id != current_sendspin_player
+ ):
+ msg = f"{current_user.username} does not have access to player {player.player_id}"
+ raise InsufficientPermissions(msg)
+ return player.state
+ return None
+
+ @api_command("players/player_controls")
+ def player_controls(
+ self,
+ ) -> list[PlayerControl]:
+ """Return all registered playercontrols."""
+ return list(self._controls.values())
+
+ @api_command("players/player_control")
+ def get_player_control(
+ self,
+ control_id: str,
+ ) -> PlayerControl | None:
+ """
+ Return PlayerControl by control_id.
+
+ :param control_id: ID of the player control.
+ :return: PlayerControl object or None.
+ """
+ if control := self._controls.get(control_id):
+ return control
+ return None
+
+ @api_command("players/plugin_sources")
+ def get_plugin_sources(self) -> list[PluginSource]:
+ """Return all available plugin sources."""
+ return [
+ plugin_prov.get_source()
+ for plugin_prov in self.mass.get_providers(ProviderType.PLUGIN)
+ if isinstance(plugin_prov, PluginProvider)
+ and ProviderFeature.AUDIO_SOURCE in plugin_prov.supported_features
+ ]
+
+ @api_command("players/plugin_source")
+ def get_plugin_source(
+ self,
+ source_id: str,
+ ) -> PluginSource | None:
+ """
+ Return PluginSource by source_id.
+
+ :param source_id: ID of the plugin source.
+ :return: PluginSource object or None.
+ """
+ for plugin_prov in self.mass.get_providers(ProviderType.PLUGIN):
+ assert isinstance(plugin_prov, PluginProvider) # for type checking
+ if ProviderFeature.AUDIO_SOURCE not in plugin_prov.supported_features:
+ continue
+ if (source := plugin_prov.get_source()) and source.id == source_id:
+ return source
+ return None
+
+ # Player commands
+
+ @api_command("players/cmd/stop")
+ @handle_player_command
+ async def cmd_stop(self, player_id: str) -> None:
+ """Send STOP command to given player.
+
+ - player_id: player_id of the player to handle the command.
+ """
+ player = self._get_player_with_redirect(player_id)
+ # Redirect to queue controller if it is active (skip if already in queue command context)
+ if not IN_QUEUE_COMMAND.get() and (active_queue := self.get_active_queue(player)):
+ await self.mass.player_queues.stop(active_queue.queue_id)
+ return
+ # Delegate to internal handler for actual implementation
+ await self._handle_cmd_stop(player.player_id)
+
+ @api_command("players/cmd/play")
+ @handle_player_command
+ async def cmd_play(self, player_id: str) -> None:
+ """Send PLAY (unpause) command to given player.
+
+ - player_id: player_id of the player to handle the command.
+ """
+ player = self._get_player_with_redirect(player_id)
+ if player.state.playback_state == PlaybackState.PLAYING:
+ self.logger.info(
+ "Ignore PLAY request to player %s: player is already playing", player.state.name
+ )
+ return
+ # player is not paused: check for queue redirect, then delegate to internal handler
+ if player.state.playback_state != PlaybackState.PAUSED:
+ source = player.state.active_source
+ if active_queue := self.mass.player_queues.get(source or player_id):
+ await self.mass.player_queues.resume(active_queue.queue_id)
+ return
+
+ # Delegate to internal handler for actual implementation
+ await self._handle_cmd_play(player.player_id)
+
+ @api_command("players/cmd/pause")
+ @handle_player_command
+ async def cmd_pause(self, player_id: str) -> None:
+ """Send PAUSE command to given player.
+
+ - player_id: player_id of the player to handle the command.
+ """
+ player = self._get_player_with_redirect(player_id)
+ # Redirect to queue controller if it is active (skip if already in queue command context)
+ if not IN_QUEUE_COMMAND.get() and (active_queue := self.get_active_queue(player)):
+ await self.mass.player_queues.pause(active_queue.queue_id)
+ return
+ # Delegate to internal handler for actual implementation
+ await self._handle_cmd_pause(player.player_id)
+
+ @api_command("players/cmd/play_pause")
+ async def cmd_play_pause(self, player_id: str) -> None:
+ """Toggle play/pause on given player.
+
+ - player_id: player_id of the player to handle the command.
+ """
+ player = self._get_player_with_redirect(player_id)
+ if player.state.playback_state == PlaybackState.PLAYING:
+ await self.cmd_pause(player.player_id)
+ else:
+ await self.cmd_play(player.player_id)
+
+ @api_command("players/cmd/resume")
+ @handle_player_command
+ async def cmd_resume(
+ self, player_id: str, source: str | None = None, media: PlayerMedia | None = None
+ ) -> None:
+ """Send RESUME command to given player.
+
+ Resume (or restart) playback on the player.
+
+ :param player_id: player_id of the player to handle the command.
+ :param source: Optional source to resume.
+ :param media: Optional media to resume.
+ """
+ await self._handle_cmd_resume(player_id, source, media)
+
+ @api_command("players/cmd/seek")
+ async def cmd_seek(self, player_id: str, position: int) -> None:
+ """Handle SEEK command for given player.
+
+ - player_id: player_id of the player to handle the command.
+ - position: position in seconds to seek to in the current playing item.
+ """
+ player = self._get_player_with_redirect(player_id)
+ # Check if a plugin source is active with a seek callback
+ if plugin_source := self._get_active_plugin_source(player):
+ if plugin_source.can_seek and plugin_source.on_seek:
+ await plugin_source.on_seek(position)
+ return
+ # Redirect to queue controller if it is active
+ if not IN_QUEUE_COMMAND.get() and (active_queue := self.get_active_queue(player)):
+ await self.mass.player_queues.seek(active_queue.queue_id, position)
+ return
+ # handle command on player/source directly
+ active_source = next((x for x in player.source_list if x.id == player.active_source), None)
+ if active_source and not active_source.can_seek:
+ msg = (
+ f"The active source ({active_source.name}) on player "
+ f"{player.display_name} does not support seeking"
+ )
+ raise PlayerCommandFailed(msg)
+ if PlayerFeature.SEEK not in player.supported_features:
+ msg = f"Player {player.display_name} does not support seeking"
+ raise UnsupportedFeaturedException(msg)
+ # handle command on player directly
+ await player.seek(position)
+
+ @api_command("players/cmd/next")
+ async def cmd_next_track(self, player_id: str) -> None:
+ """Handle NEXT TRACK command for given player."""
+ player = self._get_player_with_redirect(player_id)
+ active_source_id = player.state.active_source or player.player_id
+ # Check if a plugin source is active with a next callback
+ if plugin_source := self._get_active_plugin_source(player):
+ if plugin_source.can_next_previous and plugin_source.on_next:
+ await plugin_source.on_next()
+ return
+ # Redirect to queue controller if it is active
+ if active_queue := self.get_active_queue(player):
+ await self.mass.player_queues.next(active_queue.queue_id)
+ return
+ if PlayerFeature.NEXT_PREVIOUS in player.state.supported_features:
+ # player has some other source active and native next/previous support
+ active_source = next(
+ (x for x in player.state.source_list if x.id == active_source_id), None
+ )
+ if active_source and active_source.can_next_previous:
+ await player.next_track()
+ return
+ msg = "This action is (currently) unavailable for this source."
+ raise PlayerCommandFailed(msg)
+ # Player does not support next/previous feature
+ msg = f"Player {player.state.name} does not support skipping to the next track."
+ raise UnsupportedFeaturedException(msg)
+
+ @api_command("players/cmd/previous")
+ async def cmd_previous_track(self, player_id: str) -> None:
+ """Handle PREVIOUS TRACK command for given player."""
+ player = self._get_player_with_redirect(player_id)
+ active_source_id = player.state.active_source or player.player_id
+ # Check if a plugin source is active with a previous callback
+ if plugin_source := self._get_active_plugin_source(player):
+ if plugin_source.can_next_previous and plugin_source.on_previous:
+ await plugin_source.on_previous()
+ return
+ # Redirect to queue controller if it is active
+ if active_queue := self.get_active_queue(player):
+ await self.mass.player_queues.previous(active_queue.queue_id)
+ return
+ if PlayerFeature.NEXT_PREVIOUS in player.state.supported_features:
+ # player has some other source active and native next/previous support
+ active_source = next(
+ (x for x in player.state.source_list if x.id == active_source_id), None
+ )
+ if active_source and active_source.can_next_previous:
+ await player.previous_track()
+ return
+ msg = "This action is (currently) unavailable for this source."
+ raise PlayerCommandFailed(msg)
+ # Player does not support next/previous feature
+ msg = f"Player {player.state.name} does not support skipping to the previous track."
+ raise UnsupportedFeaturedException(msg)
+
+ @api_command("players/cmd/power")
+ @handle_player_command
+ async def cmd_power(self, player_id: str, powered: bool) -> None:
+ """Send POWER command to given player.
+
+ :param player_id: player_id of the player to handle the command.
+ :param powered: bool if player should be powered on or off.
+ """
+ await self._handle_cmd_power(player_id, powered)
+
+ @api_command("players/cmd/volume_set")
+ @handle_player_command
+ async def cmd_volume_set(self, player_id: str, volume_level: int) -> None:
+ """Send VOLUME_SET command to given player.
+
+ :param player_id: player_id of the player to handle the command.
+ :param volume_level: volume level (0..100) to set on the player.
+ """
+ await self._handle_cmd_volume_set(player_id, volume_level)
+
+ @api_command("players/cmd/volume_up")
+ @handle_player_command
+ async def cmd_volume_up(self, player_id: str) -> None:
+ """Send VOLUME_UP command to given player.
+
+ - player_id: player_id of the player to handle the command.
+ """
+ if not (player := self.get_player(player_id)):
+ return
+ current_volume = player.state.volume_level or 0
+ if current_volume < 5 or current_volume > 95:
+ step_size = 1
+ elif current_volume < 20 or current_volume > 80:
+ step_size = 2
+ else:
+ step_size = 5
+ new_volume = min(100, current_volume + step_size)
+ await self.cmd_volume_set(player_id, new_volume)
+
+ @api_command("players/cmd/volume_down")
+ @handle_player_command
+ async def cmd_volume_down(self, player_id: str) -> None:
+ """Send VOLUME_DOWN command to given player.
+
+ - player_id: player_id of the player to handle the command.
+ """
+ if not (player := self.get_player(player_id)):
+ return
+ current_volume = player.state.volume_level or 0
+ if current_volume < 5 or current_volume > 95:
+ step_size = 1
+ elif current_volume < 20 or current_volume > 80:
+ step_size = 2
+ else:
+ step_size = 5
+ new_volume = max(0, current_volume - step_size)
+ await self.cmd_volume_set(player_id, new_volume)
+
+ @api_command("players/cmd/group_volume")
+ @handle_player_command
+ async def cmd_group_volume(
+ self,
+ player_id: str,
+ volume_level: int,
+ ) -> None:
+ """
+ Handle adjusting the overall/group volume to a playergroup (or synced players).
+
+ Will set a new (overall) volume level to a group player or syncgroup.
+
+ :param player_id: Player ID of group player or syncleader to handle the command.
+ :param volume_level: Volume level (0..100) to set to the group.
+ """
+ player = self.get_player(player_id, True)
+ assert player is not None # for type checker
+ if player.state.type == PlayerType.GROUP or player.state.group_members:
+ # dedicated group player or sync leader
+ await self.set_group_volume(player, volume_level)
+ return
+ if player.state.synced_to and (sync_leader := self.get_player(player.state.synced_to)):
+ # redirect to sync leader
+ await self.set_group_volume(sync_leader, volume_level)
+ return
+ # treat as normal player volume change
+ await self.cmd_volume_set(player_id, volume_level)
+
+ @api_command("players/cmd/group_volume_up")
+ @handle_player_command
+ async def cmd_group_volume_up(self, player_id: str) -> None:
+ """Send VOLUME_UP command to given playergroup.
+
+ - player_id: player_id of the player to handle the command.
+ """
+ group_player_state = self.get_player_state(player_id, True)
+ assert group_player_state
+ cur_volume = group_player_state.group_volume
+ if cur_volume < 5 or cur_volume > 95:
+ step_size = 1
+ elif cur_volume < 20 or cur_volume > 80:
+ step_size = 2
+ else:
+ step_size = 5
+ new_volume = min(100, cur_volume + step_size)
+ await self.cmd_group_volume(player_id, new_volume)
+
+ @api_command("players/cmd/group_volume_down")
+ @handle_player_command
+ async def cmd_group_volume_down(self, player_id: str) -> None:
+ """Send VOLUME_DOWN command to given playergroup.
+
+ - player_id: player_id of the player to handle the command.
+ """
+ group_player_state = self.get_player_state(player_id, True)
+ assert group_player_state
+ cur_volume = group_player_state.group_volume
+ if cur_volume < 5 or cur_volume > 95:
+ step_size = 1
+ elif cur_volume < 20 or cur_volume > 80:
+ step_size = 2
+ else:
+ step_size = 5
+ new_volume = max(0, cur_volume - step_size)
+ await self.cmd_group_volume(player_id, new_volume)
+
+ @api_command("players/cmd/group_volume_mute")
+ @handle_player_command
+ async def cmd_group_volume_mute(self, player_id: str, muted: bool) -> None:
+ """Send VOLUME_MUTE command to all players in a group.
+
+ - player_id: player_id of the group player or sync leader.
+ - muted: bool if group should be muted.
+ """
+ player = self.get_player(player_id, True)
+ assert player is not None # for type checker
+ if player.type == PlayerType.GROUP or player.group_members:
+ # dedicated group player or sync leader
+ coros = []
+ for child_player in self.iter_group_members(
+ player, only_powered=True, exclude_self=False
+ ):
+ coros.append(self.cmd_volume_mute(child_player.player_id, muted))
+ await asyncio.gather(*coros)
+
+ @api_command("players/cmd/volume_mute")
+ @handle_player_command
+ async def cmd_volume_mute(self, player_id: str, muted: bool) -> None:
+ """Send VOLUME_MUTE command to given player.
+
+ - player_id: player_id of the player to handle the command.
+ - muted: bool if player should be muted.
+ """
+ player = self.get_player(player_id, True)
+ assert player
+
+ # Set/clear mute lock for players in a group
+ # This prevents auto-unmute when group volume changes
+ is_in_group = bool(player.state.synced_to or player.state.group_members)
+ if muted and is_in_group:
+ player.extra_data[ATTR_MUTE_LOCK] = True
+ elif not muted:
+ player.extra_data.pop(ATTR_MUTE_LOCK, None)
+
+ if player.volume_control == PLAYER_CONTROL_NONE:
+ raise UnsupportedFeaturedException(
+ f"Player {player.state.name} does not support muting"
+ )
+ if player.mute_control == PLAYER_CONTROL_NATIVE:
+ # player supports mute command natively: forward to player
+ await player.volume_mute(muted)
+ return
+ if player.mute_control == PLAYER_CONTROL_FAKE:
+ # user wants to use fake mute control - so we use volume instead
+ self.logger.debug(
+ "Using volume for muting for player %s",
+ player.state.name,
+ )
+ if muted:
+ player.extra_data[ATTR_PREVIOUS_VOLUME] = player.state.volume_level
+ player.extra_data[ATTR_FAKE_MUTE] = True
+ await self._handle_cmd_volume_set(player_id, 0)
+ player.update_state()
+ else:
+ prev_volume = player.extra_data.get(ATTR_PREVIOUS_VOLUME, 1)
+ player.extra_data[ATTR_FAKE_MUTE] = False
+ player.update_state()
+ await self._handle_cmd_volume_set(player_id, prev_volume)
+ return
+
+ # handle external player control
+ if player_control := self._controls.get(player.mute_control):
+ control_name = player_control.name if player_control else player.mute_control
+ self.logger.debug("Redirecting mute command to PlayerControl %s", control_name)
+ if not player_control or not player_control.supports_mute:
+ raise UnsupportedFeaturedException(
+ f"Player control {control_name} is not available"
+ )
+ assert player_control.mute_set is not None
+ await player_control.mute_set(muted)
+ return
+
+ # handle to protocol player as volume_mute control
+ if protocol_player := self.get_player(player.state.volume_control):
+ self.logger.debug(
+ "Redirecting mute command to protocol player %s",
+ protocol_player.provider.manifest.name,
+ )
+ await self.cmd_volume_mute(protocol_player.player_id, muted)
+ return
+
+ @api_command("players/cmd/play_announcement")
+ @handle_player_command(lock=True)
+ async def play_announcement(
+ self,
+ player_id: str,
+ url: str,
+ pre_announce: bool | None = None,
+ volume_level: int | None = None,
+ pre_announce_url: str | None = None,
+ ) -> None:
+ """
+ Handle playback of an announcement (url) on given player.
+
+ :param player_id: Player ID of the player to handle the command.
+ :param url: URL of the announcement to play.
+ :param pre_announce: Optional bool if pre-announce should be used.
+ :param volume_level: Optional volume level to set for the announcement.
+ :param pre_announce_url: Optional custom URL to use for the pre-announce chime.
+ """
+ player = self.get_player(player_id, True)
+ assert player is not None # for type checking
+ if not url.startswith("http"):
+ raise PlayerCommandFailed("Only URLs are supported for announcements")
+ if (
+ pre_announce
+ and pre_announce_url
+ and not validate_announcement_chime_url(pre_announce_url)
+ ):
+ raise PlayerCommandFailed("Invalid pre-announce chime URL specified.")
+ try:
+ # mark announcement_in_progress on player
+ player.extra_data[ATTR_ANNOUNCEMENT_IN_PROGRESS] = True
+ # determine pre-announce from (group)player config
+ if pre_announce is None and "tts" in url:
+ conf_pre_announce = self.mass.config.get_raw_player_config_value(
+ player_id,
+ CONF_ENTRY_TTS_PRE_ANNOUNCE.key,
+ CONF_ENTRY_TTS_PRE_ANNOUNCE.default_value,
+ )
+ pre_announce = cast("bool", conf_pre_announce)
+ if pre_announce_url is None:
+ if conf_pre_announce_url := self.mass.config.get_raw_player_config_value(
+ player_id,
+ CONF_PRE_ANNOUNCE_CHIME_URL,
+ ):
+ # player default custom chime url
+ pre_announce_url = cast("str", conf_pre_announce_url)
+ else:
+ # use global default chime url
+ pre_announce_url = ANNOUNCE_ALERT_FILE
+ # if player type is group with all members supporting announcements,
+ # we forward the request to each individual player
+ if player.state.type == PlayerType.GROUP and (
+ all(
+ PlayerFeature.PLAY_ANNOUNCEMENT in x.state.supported_features
+ for x in self.iter_group_members(player)
+ )
+ ):
+ # forward the request to each individual player
+ async with TaskManager(self.mass) as tg:
+ for group_member in player.state.group_members:
+ tg.create_task(
+ self.play_announcement(
+ group_member,
+ url=url,
+ pre_announce=pre_announce,
+ volume_level=volume_level,
+ pre_announce_url=pre_announce_url,
+ )
+ )
+ return
+ self.logger.info(
+ "Playback announcement to player %s (with pre-announce: %s): %s",
+ player.state.name,
+ pre_announce,
+ url,
+ )
+ # determine if the player has native announcements support
+ # or if any linked protocol has announcement support
+ if announce_player := self._get_control_target(
+ player,
+ required_feature=PlayerFeature.PLAY_ANNOUNCEMENT,
+ require_active=False,
+ allow_native=True,
+ ):
+ native_announce_support = True
+ else:
+ announce_player = player
+ # create a PlayerMedia object for the announcement so
+ # we can send a regular play-media call downstream
+ announce_data = AnnounceData(
+ announcement_url=url,
+ pre_announce=bool(pre_announce),
+ pre_announce_url=pre_announce_url,
+ )
+ announcement = PlayerMedia(
+ uri=self.mass.streams.get_announcement_url(player_id, announce_data=announce_data),
+ media_type=MediaType.ANNOUNCEMENT,
+ title="Announcement",
+ custom_data=dict(announce_data),
+ )
+ # handle native announce support (player or linked protocol)
+ if native_announce_support:
+ announcement_volume = self.get_announcement_volume(player_id, volume_level)
+ await announce_player.play_announcement(announcement, announcement_volume)
+ return
+ # use fallback/default implementation
+ await self._play_announcement(player, announcement, volume_level)
+ finally:
+ player.extra_data[ATTR_ANNOUNCEMENT_IN_PROGRESS] = False
+
+ @handle_player_command(lock=True)
+ async def play_media(self, player_id: str, media: PlayerMedia) -> None:
+ """Handle PLAY MEDIA on given player.
+
+ - player_id: player_id of the player to handle the command.
+ - media: The Media that needs to be played on the player.
+ """
+ player = self._get_player_with_redirect(player_id)
+ # Delegate to internal handler for actual implementation
+ await self._handle_play_media(player.player_id, media)
+
+ @api_command("players/cmd/select_sound_mode")
+ @handle_player_command
+ async def select_sound_mode(self, player_id: str, sound_mode: str) -> None:
+ """
+ Handle SELECT SOUND MODE command on given player.
+
+ - player_id: player_id of the player to handle the command
+ - sound_mode: The ID of the sound mode that needs to be activated/selected.
+ """
+ player = self.get_player(player_id, True)
+ assert player is not None # for type checking
+
+ if PlayerFeature.SELECT_SOUND_MODE not in player.supported_features:
+ raise UnsupportedFeaturedException(
+ f"Player {player.display_name} does not support sound mode selection"
+ )
+
+ prev_sound_mode = player.active_sound_mode
+ if sound_mode == prev_sound_mode:
+ return
+
+ # basic check if sound mode is valid for player
+ if not any(x for x in player.sound_mode_list if x.id == sound_mode):
+ raise PlayerCommandFailed(
+ f"{sound_mode} is an invalid sound_mode for player {player.display_name}"
+ )
+
+ # forward to player
+ await player.select_sound_mode(sound_mode)
+
+ @api_command("players/cmd/set_option")
+ @handle_player_command
+ async def set_option(
+ self, player_id: str, option_key: str, option_value: PlayerOptionValueType
+ ) -> None:
+ """
+ Handle SET_OPTION command on given player.
+
+ - player_id: player_id of the player to handle the command
+ - option_key: The key of the player option that needs to be activated/selected.
+ - option_value: The new value of the player option.
+ """
+ player = self.get_player(player_id, True)
+ assert player is not None # for type checking
+
+ if PlayerFeature.OPTIONS not in player.supported_features:
+ raise UnsupportedFeaturedException(
+ f"Player {player.display_name} does not support set_option"
+ )
+
+ prev_player_option = next((x for x in player.options if x.key == option_key), None)
+ if not prev_player_option:
+ return
+ if prev_player_option.value == option_value:
+ return
+
+ if prev_player_option.read_only:
+ raise UnsupportedFeaturedException(
+ f"Player {player.display_name} option {option_key} is read-only"
+ )
+
+ # forward to player
+ await player.set_option(option_key=option_key, option_value=option_value)
+
+ @api_command("players/cmd/select_source")
+ @handle_player_command
+ async def select_source(self, player_id: str, source: str | None) -> None:
+ """
+ Handle SELECT SOURCE command on given player.
+
+ - player_id: player_id of the player to handle the command.
+ - source: The ID of the source that needs to be activated/selected.
+ """
+ if source is None:
+ source = player_id # default to MA queue source
+ player = self.get_player(player_id, True)
+ assert player is not None # for type checking
+ # Check if player is currently grouped (reject for public API)
+ if player.state.synced_to or player.state.active_group:
+ raise PlayerCommandFailed(f"Player {player.state.name} is currently grouped")
+ # Delegate to internal handler for actual implementation
+ await self._handle_select_source(player_id, source)
+
+ @handle_player_command(lock=True)
+ async def enqueue_next_media(self, player_id: str, media: PlayerMedia) -> None:
+ """
+ Handle enqueuing of a next media item on the player.
+
+ :param player_id: player_id of the player to handle the command.
+ :param media: The Media that needs to be enqueued on the player.
+ :raises UnsupportedFeaturedException: if the player does not support enqueueing.
+ :raises PlayerUnavailableError: if the player is not available.
+ """
+ # Note: No group redirect needed here as enqueue doesn't use _get_player_with_redirect
+ # Delegate to internal handler for actual implementation
+ await self._handle_enqueue_next_media(player_id, media)
+
+ @api_command("players/cmd/set_members")
+ async def cmd_set_members(
+ self,
+ target_player: str,
+ player_ids_to_add: list[str] | None = None,
+ player_ids_to_remove: list[str] | None = None,
+ ) -> None:
+ """
+ Join/unjoin given player(s) to/from target player.
+
+ Will add the given player(s) to the target player (sync leader or group player).
+
+ :param target_player: player_id of the syncgroup leader or group player.
+ :param player_ids_to_add: List of player_id's to add to the target player.
+ :param player_ids_to_remove: List of player_id's to remove from the target player.
+
+ :raises UnsupportedFeaturedException: if the target player does not support grouping.
+ :raises PlayerUnavailableError: if the target player is not available.
+ """
+ parent_player: Player | None = self.get_player(target_player, True)
+ assert parent_player is not None # for type checking
+ if PlayerFeature.SET_MEMBERS not in parent_player.state.supported_features:
+ msg = f"Player {parent_player.name} does not support group commands"
+ raise UnsupportedFeaturedException(msg)
+
+ # guard edge case: player already synced to another player
+ if parent_player.state.synced_to:
+ raise PlayerCommandFailed(
+ f"Player {parent_player.name} is already synced to another player on its own, "
+ "you need to ungroup it first before you can join other players to it.",
+ )
+ # handle dissolve sync group if the target player is currently
+ # a sync leader and is being removed from itself
+ should_stop = False
+ if player_ids_to_remove and target_player in player_ids_to_remove:
+ self.logger.info(
+ "Dissolving sync group of player %s as it is being removed from itself",
+ parent_player.name,
+ )
+ player_ids_to_add = None
+ player_ids_to_remove = [
+ x for x in parent_player.state.group_members if x != target_player
+ ]
+ should_stop = True
+ # filter all player ids on compatibility and availability
+ final_player_ids_to_add: list[str] = []
+ for child_player_id in player_ids_to_add or []:
+ if child_player_id == target_player:
+ continue
+ if child_player_id in final_player_ids_to_add:
+ continue
+ if (
+ not (child_player := self.get_player(child_player_id))
+ or not child_player.state.available
+ ):
+ self.logger.warning("Player %s is not available", child_player_id)
+ continue
+
+ # check if player can be synced/grouped with the target player
+ # state.can_group_with already handles all expansion and translation
+ if child_player_id not in parent_player.state.can_group_with:
+ self.logger.warning(
+ "Player %s can not be grouped with %s",
+ child_player.name,
+ parent_player.name,
+ )
+ continue
+
+ if (
+ child_player.state.synced_to
+ and child_player.state.synced_to == target_player
+ and child_player_id in parent_player.state.group_members
+ ):
+ continue # already synced to this target
+
+ # power on the player if needed
+ if (
+ not child_player.state.powered
+ and child_player.state.power_control != PLAYER_CONTROL_NONE
+ ):
+ await self._handle_cmd_power(child_player.player_id, True)
+ # if we reach here, all checks passed
+ final_player_ids_to_add.append(child_player_id)
+
+ # process player ids to remove and filter out invalid/unavailable players and edge cases
+ final_player_ids_to_remove: list[str] = []
+ if player_ids_to_remove:
+ for child_player_id in player_ids_to_remove:
+ if child_player_id not in parent_player.state.group_members:
+ continue
+ final_player_ids_to_remove.append(child_player_id)
+
+ # Forward command to the appropriate player after all (base) sanity checks
+ # GROUP players (sync_group, universal_group) manage their own members internally
+ # and don't need protocol translation - call their set_members directly
+ if parent_player.type == PlayerType.GROUP:
+ await parent_player.set_members(
+ player_ids_to_add=final_player_ids_to_add,
+ player_ids_to_remove=final_player_ids_to_remove,
+ )
+ return
+ # For regular players, handle protocol selection and translation
+ # Store playback state before changing members to detect protocol changes
+ was_playing = parent_player.playback_state in (
+ PlaybackState.PLAYING,
+ PlaybackState.PAUSED,
+ )
+ previous_protocol = parent_player.active_output_protocol if was_playing else None
+
+ await self._handle_set_members_with_protocols(
+ parent_player, final_player_ids_to_add, final_player_ids_to_remove
+ )
+
+ if should_stop:
+ # Stop playback on the player if it is being removed from itself
+ await self._handle_cmd_stop(parent_player.player_id)
+ return
+
+ # Check if protocol changed due to member change and restart playback if needed
+ if not should_stop and was_playing:
+ # Determine which protocol would be used now with new members
+ _new_target_player, new_protocol = self._select_best_output_protocol(parent_player)
+ new_protocol_id = new_protocol.output_protocol_id if new_protocol else "native"
+ previous_protocol_id = previous_protocol or "native"
+
+ # If protocol changed, restart playback
+ if new_protocol_id != previous_protocol_id:
+ self.logger.info(
+ "Protocol changed from %s to %s due to member change, restarting playback",
+ previous_protocol_id,
+ new_protocol_id,
+ )
+ # Restart playback on the new protocol using resume
+ await self.cmd_resume(
+ parent_player.player_id,
+ parent_player.state.active_source,
+ parent_player.state.current_media,
+ )
+
+ @api_command("players/cmd/group")
+ @handle_player_command
+ async def cmd_group(self, player_id: str, target_player: str) -> None:
+ """Handle GROUP command for given player.
+
+ Join/add the given player(id) to the given (leader) player/sync group.
+ If the target player itself is already synced to another player, this may fail.
+ If the player can not be synced with the given target player, this may fail.
+
+ :param player_id: player_id of the player to handle the command.
+ :param target_player: player_id of the syncgroup leader or group player.
+
+ :raises UnsupportedFeaturedException: if the target player does not support grouping.
+ :raises PlayerCommandFailed: if the target player is already synced to another player.
+ :raises PlayerUnavailableError: if the target player is not available.
+ :raises PlayerCommandFailed: if the player is already grouped to another player.
+ """
+ await self.cmd_set_members(target_player, player_ids_to_add=[player_id])
+
+ @api_command("players/cmd/group_many")
+ async def cmd_group_many(self, target_player: str, child_player_ids: list[str]) -> None:
+ """
+ Join given player(s) to target player.
+
+ Will add the given player(s) to the target player (sync leader or group player).
+ This is a (deprecated) alias for cmd_set_members.
+ """
+ await self.cmd_set_members(target_player, player_ids_to_add=child_player_ids)
+
+ @api_command("players/cmd/ungroup")
+ @handle_player_command
+ async def cmd_ungroup(self, player_id: str) -> None:
+ """Handle UNGROUP command for given player.
+
+ Remove the given player from any (sync)groups it currently is synced to.
+ If the player is not currently grouped to any other player,
+ this will silently be ignored.
+
+ NOTE: This is a (deprecated) alias for cmd_set_members.
+ """
+ if not (player := self.get_player(player_id)):
+ self.logger.warning("Player %s is not available", player_id)
+ return
+
+ if (
+ player.state.active_group
+ and (group_player := self.get_player(player.state.active_group))
+ and (PlayerFeature.SET_MEMBERS in group_player.state.supported_features)
+ ):
+ # the player is part of a (permanent) groupplayer and the user tries to ungroup
+ if player_id in group_player.static_group_members:
+ raise UnsupportedFeaturedException(
+ f"Player {player.name} is a static member of group {group_player.name} "
+ "and cannot be removed from that group!"
+ )
+ await group_player.set_members(player_ids_to_remove=[player_id])
+ return
+
+ if player.state.synced_to and (synced_player := self.get_player(player.state.synced_to)):
+ # player is a sync member
+ await synced_player.set_members(player_ids_to_remove=[player_id])
+ return
+
+ if not (player.state.synced_to or player.state.group_members):
+ return # nothing to do
+
+ if PlayerFeature.SET_MEMBERS not in player.state.supported_features:
+ self.logger.warning("Player %s does not support (un)group commands", player.name)
+ return
+
+ # forward command to the player once all checks passed
+ await player.ungroup()
+
+ @api_command("players/cmd/ungroup_many")
+ async def cmd_ungroup_many(self, player_ids: list[str]) -> None:
+ """Handle UNGROUP command for all the given players."""
+ for player_id in list(player_ids):
+ await self.cmd_ungroup(player_id)
+
+ @api_command("players/create_group_player", required_role="admin")
+ async def create_group_player(
+ self, provider: str, name: str, members: list[str], dynamic: bool = True
+ ) -> Player:
+ """
+ Create a new (permanent) Group Player.
+
+ :param provider: The provider (id) to create the group player for.
+ :param name: Name of the new group player.
+ :param members: List of player ids to add to the group.
+ :param dynamic: Whether the group is dynamic (members can change).
+ """
+ if not (provider_instance := self.mass.get_provider(provider)):
+ raise ProviderUnavailableError(f"Provider {provider} not found")
+ provider_instance = cast("PlayerProvider", provider_instance)
+ if ProviderFeature.CREATE_GROUP_PLAYER not in provider_instance.supported_features:
+ raise UnsupportedFeaturedException(
+ f"Provider {provider} does not support creating group players"
+ )
+ return await provider_instance.create_group_player(name, members, dynamic)
+
+ @api_command("players/remove_group_player", required_role="admin")
+ async def remove_group_player(self, player_id: str) -> None:
+ """Remove a group player."""
+ if not (player := self.get_player(player_id)):
+ # we simply permanently delete the player by wiping its config
+ self.mass.config.remove(f"players/{player_id}")
+ return
+ if player.state.type != PlayerType.GROUP:
+ raise UnsupportedFeaturedException(f"Player {player.state.name} is not a group player")
+ player.provider.check_feature(ProviderFeature.REMOVE_GROUP_PLAYER)
+ await player.provider.remove_group_player(player_id)
+
+ @api_command("players/add_currently_playing_to_favorites")
+ async def add_currently_playing_to_favorites(self, player_id: str) -> None:
+ """
+ Add the currently playing item/track on given player to the favorites.
+
+ This tries to resolve the currently playing media to an actual media item
+ and add that to the favorites in the library. Will raise an error if the
+ player is not currently playing anything or if the currently playing media
+ can not be resolved to a media item.
+ """
+ player = self._get_player_with_redirect(player_id)
+ # handle mass player queue active
+ if mass_queue := self.get_active_queue(player):
+ if not (current_item := mass_queue.current_item) or not current_item.media_item:
+ raise PlayerCommandFailed("No current item to add to favorites")
+ # if we're playing a radio station, try to resolve the currently playing track
+ if current_item.media_item.media_type == MediaType.RADIO:
+ if not (
+ (streamdetails := mass_queue.current_item.streamdetails)
+ and (stream_title := streamdetails.stream_title)
+ and " - " in stream_title
+ ):
+ # no stream title available, so we can't resolve the track
+ # this can happen if the radio station does not provide metadata
+ # or there's a commercial break
+ # Possible future improvement could be to actually detect the song with a
+ # shazam-like approach.
+ raise PlayerCommandFailed("No current item to add to favorites")
+ # send the streamtitle into a global search query
+ search_artist, search_title_title = stream_title.split(" - ", 1)
+ # strip off any additional comments in the title (such as from Radio Paradise)
+ search_title_title = search_title_title.split(" | ")[0].strip()
+ if track := await self.mass.music.get_track_by_name(
+ search_title_title, search_artist
+ ):
+ # we found a track, so add it to the favorites
+ await self.mass.music.add_item_to_favorites(track)
+ return
+ # we could not resolve the track, so raise an error
+ raise PlayerCommandFailed("No current item to add to favorites")
+
+ # else: any other media item, just add it to the favorites directly
+ await self.mass.music.add_item_to_favorites(current_item.media_item)
+ return
+
+ # guard for player with no active source
+ if not player.state.active_source:
+ raise PlayerCommandFailed("Player has no active source")
+ # handle other source active using the current_media with uri
+ if current_media := player.state.current_media:
+ # prefer the uri of the current media item
+ if current_media.uri:
+ with suppress(MusicAssistantError):
+ await self.mass.music.add_item_to_favorites(current_media.uri)
+ return
+ # fallback to search based on artist and title (and album if available)
+ if current_media.artist and current_media.title:
+ if track := await self.mass.music.get_track_by_name(
+ current_media.title,
+ current_media.artist,
+ current_media.album,
+ ):
+ # we found a track, so add it to the favorites
+ await self.mass.music.add_item_to_favorites(track)
+ return
+ # if we reach here, we could not resolve the currently playing item
+ raise PlayerCommandFailed("No current item to add to favorites")
+
+ async def register(self, player: Player) -> None:
+ """Register a player on the Player Controller."""
+ if self.mass.closing:
+ return
+
+ # Use lock to prevent race conditions during concurrent player registrations
+ async with self._register_lock:
+ player_id = player.player_id
+
+ if player_id in self._players:
+ msg = f"Player {player_id} is already registered!"
+ raise AlreadyRegisteredError(msg)
+
+ # ignore disabled players
+ if not player.state.enabled:
+ return
+
+ # register throttler for this player
+ self._player_throttlers[player_id] = Throttler(1, 0.05)
+
+ # restore 'fake' power state from cache if available
+ cached_value = await self.mass.cache.get(
+ key=player.player_id,
+ provider=self.domain,
+ category=CACHE_CATEGORY_PLAYER_POWER,
+ default=False,
+ )
+ if cached_value is not None:
+ player.extra_data[ATTR_FAKE_POWER] = cached_value
+
+ # finally actually register it
+ self._players[player_id] = player
+ # update state without signaling event first (ensure all attributes are set)
+ player.update_state(signal_event=False)
+
+ # ensure we fetch and set the latest/full config for the player
+ player_config = await self.mass.config.get_player_config(player_id)
+ player.set_config(player_config)
+ # call hook after the player is registered and config is set
+ await player.on_config_updated()
+
+ # Handle protocol linking
+ # First enrich identifiers with real MAC (resolves virtual MACs via ARP)
+ await self._enrich_player_identifiers(player)
+ self._evaluate_protocol_links(player)
+
+ self.logger.info(
+ "Player (type %s) registered: %s/%s",
+ player.state.type.value,
+ player_id,
+ player.state.name,
+ )
+ # signal event that a player was added
+
+ if player.state.type != PlayerType.PROTOCOL:
+ self.mass.signal_event(
+ EventType.PLAYER_ADDED, object_id=player.player_id, data=player
+ )
+
+ # register playerqueue for this player
+ # Skip if this is a protocol player pending evaluation (queue created when promoted)
+ if (
+ player.state.type != PlayerType.PROTOCOL
+ and player.player_id not in self._pending_protocol_evaluations
+ ):
+ await self.mass.player_queues.on_player_register(player)
+
+ # always call update to fix special attributes like display name, group volume etc.
+ player.update_state()
+
+ # Schedule debounced update of all players since can_group_with values may change
+ # when a new player is added (provider IDs expand to include the new player)
+ self._schedule_update_all_players()
+
+ async def register_or_update(self, player: Player) -> None:
+ """Register a new player on the controller or update existing one."""
+ if self.mass.closing:
+ return
+
+ if player.player_id in self._players:
+ self._players[player.player_id] = player
+ player.update_state()
+ # Also schedule update when replacing existing player
+ self._schedule_update_all_players()
+ return
+
+ await self.register(player)
+
+ def trigger_player_update(
+ self, player_id: str, force_update: bool = False, debounce_delay: float = 0.25
+ ) -> None:
+ """Trigger a (debounced) update for the given player."""
+ if self.mass.closing:
+ return
+ if not (player := self.get_player(player_id)):
+ return
+ task_id = f"player_update_state_{player_id}"
+ self.mass.call_later(
+ debounce_delay,
+ player.update_state,
+ force_update=force_update,
+ task_id=task_id,
+ )
+
+ async def unregister(self, player_id: str, permanent: bool = False) -> None:
+ """
+ Unregister a player from the player controller.
+
+ Called (by a PlayerProvider) when a player is removed or no longer available
+ (for a longer period of time). This will remove the player from the player
+ controller and optionally remove the player's config from the mass config.
+ If the player is not registered, this will silently be ignored.
+
+ :param player_id: Player ID of the player to unregister.
+ :param permanent: If True, remove the player permanently by deleting its config.
+ If False, the player config will not be removed.
+ """
+ player = self._players.get(player_id)
+ if player is None:
+ return
+ await self._cleanup_player_memberships(player_id)
+ del self._players[player_id]
+ self.mass.player_queues.on_player_remove(player_id, permanent=permanent)
+ await player.on_unload()
+ if permanent:
+ # player permanent removal: cleanup protocol links, delete config
+ # and signal PLAYER_REMOVED event
+ self._cleanup_protocol_links(player)
+ self.delete_player_config(player_id)
+ self.logger.info("Player removed: %s", player.name)
+ if player.state.type != PlayerType.PROTOCOL:
+ self.mass.signal_event(EventType.PLAYER_REMOVED, player_id)
+ else:
+ # temporary unavailable: mark player as unavailable
+ # note: the player will be re-registered later if it comes back online
+ player.state.available = False
+ self.logger.info("Player unavailable: %s", player.name)
+ if player.state.type != PlayerType.PROTOCOL:
+ self.mass.signal_event(
+ EventType.PLAYER_UPDATED, object_id=player.player_id, data=player.state
+ )
+ # Schedule debounced update of all players since can_group_with values may change
+ self._schedule_update_all_players()
+
+ @api_command("players/remove", required_role="admin")
+ async def remove(self, player_id: str) -> None:
+ """
+ Remove a player from a provider.
+
+ Can only be called when a PlayerProvider supports ProviderFeature.REMOVE_PLAYER.
+ """
+ player = self.get_player(player_id)
+ if player is None:
+ # we simply permanently delete the player config since it is not registered
+ self.delete_player_config(player_id)
+ return
+ if player.state.type == PlayerType.GROUP:
+ # Handle group player removal
+ player.provider.check_feature(ProviderFeature.REMOVE_GROUP_PLAYER)
+ await player.provider.remove_group_player(player_id)
+ return
+ player.provider.check_feature(ProviderFeature.REMOVE_PLAYER)
+ await player.provider.remove_player(player_id)
+ # check for group memberships that need to be updated
+ if player.state.active_group and (
+ group_player := self.mass.players.get_player(player.state.active_group)
+ ):
+ # try to remove from the group
+ with suppress(UnsupportedFeaturedException, PlayerCommandFailed):
+ await group_player.set_members(
+ player_ids_to_remove=[player_id],
+ )
+ # We removed the player and can now clean up its config
+ self.delete_player_config(player_id)
+
+ def delete_player_config(self, player_id: str) -> None:
+ """
+ Permanently delete a player's configuration.
+
+ Should only be called for players that are not registered by the player controller.
+ """
+ # we simply permanently delete the player by wiping its config
+ conf_key = f"{CONF_PLAYERS}/{player_id}"
+ dsp_conf_key = f"{CONF_PLAYER_DSP}/{player_id}"
+ for key in (conf_key, dsp_conf_key):
+ self.mass.config.remove(key)
+
+ def signal_player_state_update(
+ self,
+ player: Player,
+ changed_values: dict[str, tuple[Any, Any]],
+ force_update: bool = False,
+ skip_forward: bool = False,
+ ) -> None:
+ """
+ Signal a player state update.
+
+ Called by a Player when its state has changed.
+ This will update the player state in the controller and signal the event bus.
+ """
+ player_id = player.player_id
+ if self.mass.closing:
+ return
+
+ # ignore updates for disabled players
+ if not player.state.enabled and ATTR_ENABLED not in changed_values:
+ return
+
+ if len(changed_values) == 0 and not force_update:
+ # nothing changed
+ return
+
+ # always signal update to the playerqueue
+ if player.state.type != PlayerType.PROTOCOL:
+ self.mass.player_queues.on_player_update(player, changed_values)
+
+ # to prevent spamming the eventbus on small changes (e.g. elapsed time),
+ # we check if there are only changes in the elapsed time
+ clean_changed_keys = set(changed_values.keys()) - {"current_media.elapsed_time"}
+ if clean_changed_keys == {ATTR_ELAPSED_TIME} and not force_update:
+ # ignore small changes in elapsed time
+ prev_value = changed_values[ATTR_ELAPSED_TIME][0] or 0
+ new_value = changed_values[ATTR_ELAPSED_TIME][1] or 0
+ if abs(prev_value - new_value) < 5:
+ return
+
+ # handle DSP reload of the leader when grouping/ungrouping
+ if ATTR_GROUP_MEMBERS in changed_values:
+ prev_group_members, new_group_members = changed_values[ATTR_GROUP_MEMBERS]
+ self._handle_group_dsp_change(player, prev_group_members or [], new_group_members)
+
+ if ATTR_GROUP_MEMBERS in changed_values:
+ # Removed group members also need to be updated since they are no longer part
+ # of this group and are available for playback again
+ prev_group_members = changed_values[ATTR_GROUP_MEMBERS][0] or []
+ new_group_members = changed_values[ATTR_GROUP_MEMBERS][1] or []
+ removed_members = set(prev_group_members) - set(new_group_members)
+ for _removed_player_id in removed_members:
+ if removed_player := self.get_player(_removed_player_id):
+ removed_player.update_state()
+
+ became_inactive = False
+ if ATTR_AVAILABLE in changed_values:
+ became_inactive = changed_values[ATTR_AVAILABLE][1] is False
+ if not became_inactive and ATTR_ENABLED in changed_values:
+ became_inactive = changed_values[ATTR_ENABLED][1] is False
+ if became_inactive and (player.state.active_group or player.state.synced_to):
+ self.mass.create_task(self._cleanup_player_memberships(player.player_id))
+
+ # signal player update on the eventbus
+ if player.state.type != PlayerType.PROTOCOL:
+ self.mass.signal_event(EventType.PLAYER_UPDATED, object_id=player_id, data=player)
+
+ # signal a separate PlayerOptionsUpdated event
+ if options := changed_values.get("options"):
+ self.mass.signal_event(
+ EventType.PLAYER_OPTIONS_UPDATED, object_id=player_id, data=options
+ )
+
+ if skip_forward and not force_update:
+ return
+
+ # update/signal group player(s) child's when group updates
+ for child_player in self.iter_group_members(player, exclude_self=True):
+ self.trigger_player_update(child_player.player_id)
+ # update/signal group player(s) when child updates
+ for group_player in self._get_player_groups(player, powered_only=False):
+ self.trigger_player_update(group_player.player_id)
+ # update/signal manually synced to player when child updates
+ if (synced_to := player.state.synced_to) and (
+ synced_to_player := self.get_player(synced_to)
+ ):
+ self.trigger_player_update(synced_to_player.player_id)
+ # update/signal active groups when a group member updates
+ if (active_group := player.state.active_group) and (
+ active_group_player := self.get_player(active_group)
+ ):
+ self.trigger_player_update(active_group_player.player_id)
+ # If this is a protocol player, forward the state update to the parent player
+ if player.protocol_parent_id and (
+ parent_player := self.mass.players.get_player(player.protocol_parent_id)
+ ):
+ self.trigger_player_update(parent_player.player_id)
+ # If this is a parent player with linked protocols, forward state updates
+ # to linked protocol players so their state reflects parent dependencies
+ if player.state.type != PlayerType.PROTOCOL and player.linked_output_protocols:
+ for linked in player.linked_output_protocols:
+ if protocol_player := self.mass.players.get_player(linked.output_protocol_id):
+ self.mass.players.trigger_player_update(protocol_player.player_id)
+ # trigger update of all players in a provider if group related fields changed
+ if any(key in changed_values for key in ("group_members", "synced_to", "available")):
+ for prov_player in player.provider.players:
+ self.trigger_player_update(prov_player.player_id)
+
+ async def register_player_control(self, player_control: PlayerControl) -> None:
+ """Register a new PlayerControl on the controller."""
+ if self.mass.closing:
+ return
+ control_id = player_control.id
+
+ if control_id in self._controls:
+ msg = f"PlayerControl {control_id} is already registered"
+ raise AlreadyRegisteredError(msg)
+
+ # make sure that the playercontrol's provider is set to the instance_id
+ prov = self.mass.get_provider(player_control.provider)
+ if not prov or prov.instance_id != player_control.provider:
+ raise RuntimeError(f"Invalid provider ID given: {player_control.provider}")
+
+ self._controls[control_id] = player_control
+
+ self.logger.info(
+ "PlayerControl registered: %s/%s",
+ control_id,
+ player_control.name,
+ )
+
+ # always call update to update any attached players etc.
+ self.update_player_control(player_control.id)
+
+ async def register_or_update_player_control(self, player_control: PlayerControl) -> None:
+ """Register a new playercontrol on the controller or update existing one."""
+ if self.mass.closing:
+ return
+ if player_control.id in self._controls:
+ self._controls[player_control.id] = player_control
+ self.update_player_control(player_control.id)
+ return
+ await self.register_player_control(player_control)
+
+ def update_player_control(self, control_id: str) -> None:
+ """Update playercontrol state."""
+ if self.mass.closing:
+ return
+ # update all players that are using this control
+ for player in self._players.values():
+ if control_id in (
+ player.state.power_control,
+ player.state.volume_control,
+ player.state.mute_control,
+ ):
+ self.mass.loop.call_soon(player.update_state)
+
+ def remove_player_control(self, control_id: str) -> None:
+ """Remove a player_control from the player manager."""
+ control = self._controls.pop(control_id, None)
+ if control is None:
+ return
+ self._controls.pop(control_id, None)
+ self.logger.info("PlayerControl removed: %s", control.name)
+
+ def get_player_provider(self, player_id: str) -> PlayerProvider:
+ """Return PlayerProvider for given player."""
+ player = self._players[player_id]
+ assert player # for type checker
+ return player.provider
+
+ def get_active_queue(self, player: Player) -> PlayerQueue | None:
+ """Return the current active queue for a player (if any)."""
+ # account for player that is synced (sync child)
+ if player.synced_to and player.synced_to != player.player_id:
+ if sync_leader := self.get_player(player.synced_to):
+ return self.get_active_queue(sync_leader)
+ # handle active group player
+ if player.state.active_group and player.state.active_group != player.player_id:
+ if group_player := self.get_player(player.state.active_group):
+ return self.get_active_queue(group_player)
+ # active_source may be filled queue id (or None)
+ active_source = player.state.active_source or player.player_id
+ if active_queue := self.mass.player_queues.get(active_source):
+ return active_queue
+ # handle active protocol player with parent player queue
+ if player.type == PlayerType.PROTOCOL and player.protocol_parent_id:
+ if parent_player := self.mass.players.get_player(player.protocol_parent_id):
+ return self.get_active_queue(parent_player)
+ return None
+
+ async def set_group_volume(self, group_player: Player, volume_level: int) -> None:
+ """Handle adjusting the overall/group volume to a playergroup (or synced players)."""
+ cur_volume = group_player.state.group_volume
+ volume_dif = volume_level - cur_volume
+ coros = []
+ # handle group volume by only applying the volume to powered members
+ for child_player in self.iter_group_members(
+ group_player, only_powered=True, exclude_self=False
+ ):
+ if child_player.state.volume_control == PLAYER_CONTROL_NONE:
+ continue
+ cur_child_volume = child_player.state.volume_level or 0
+ new_child_volume = int(cur_child_volume + volume_dif)
+ new_child_volume = max(0, new_child_volume)
+ new_child_volume = min(100, new_child_volume)
+ # Use private method to skip permission check - already validated on group
+ # ATTR_MUTE_LOCK on muted players prevents auto-unmute during group volume changes
+ coros.append(self._handle_cmd_volume_set(child_player.player_id, new_child_volume))
+ await asyncio.gather(*coros)
+
+ def get_announcement_volume(self, player_id: str, volume_override: int | None) -> int | None:
+ """Get the (player specific) volume for a announcement."""
+ volume_strategy = self.mass.config.get_raw_player_config_value(
+ player_id,
+ CONF_ENTRY_ANNOUNCE_VOLUME_STRATEGY.key,
+ CONF_ENTRY_ANNOUNCE_VOLUME_STRATEGY.default_value,
+ )
+ volume_strategy_volume = self.mass.config.get_raw_player_config_value(
+ player_id,
+ CONF_ENTRY_ANNOUNCE_VOLUME.key,
+ CONF_ENTRY_ANNOUNCE_VOLUME.default_value,
+ )
+ if volume_strategy == "none":
+ return None
+ volume_level = volume_override
+ if volume_level is None and volume_strategy == "absolute":
+ volume_level = int(cast("float", volume_strategy_volume))
+ elif volume_level is None and volume_strategy == "relative":
+ if (player := self.get_player(player_id)) and player.state.volume_level is not None:
+ volume_level = int(
+ player.state.volume_level + cast("float", volume_strategy_volume)
+ )
+ elif volume_level is None and volume_strategy == "percentual":
+ if (player := self.get_player(player_id)) and player.state.volume_level is not None:
+ percentual = (player.state.volume_level / 100) * cast(
+ "float", volume_strategy_volume
+ )
+ volume_level = int(player.state.volume_level + percentual)
+ if volume_level is not None:
+ announce_volume_min = cast(
+ "float",
+ self.mass.config.get_raw_player_config_value(
+ player_id,
+ CONF_ENTRY_ANNOUNCE_VOLUME_MIN.key,
+ CONF_ENTRY_ANNOUNCE_VOLUME_MIN.default_value,
+ ),
+ )
+ volume_level = max(int(announce_volume_min), volume_level)
+ announce_volume_max = cast(
+ "float",
+ self.mass.config.get_raw_player_config_value(
+ player_id,
+ CONF_ENTRY_ANNOUNCE_VOLUME_MAX.key,
+ CONF_ENTRY_ANNOUNCE_VOLUME_MAX.default_value,
+ ),
+ )
+ volume_level = min(int(announce_volume_max), volume_level)
+ return None if volume_level is None else int(volume_level)
+
+ def iter_group_members(
+ self,
+ group_player: Player,
+ only_powered: bool = False,
+ only_playing: bool = False,
+ active_only: bool = False,
+ exclude_self: bool = True,
+ ) -> Iterator[Player]:
+ """Get (child) players attached to a group player or syncgroup."""
+ for child_id in list(group_player.state.group_members):
+ if child_player := self.get_player(child_id, False):
+ if not child_player.state.available or not child_player.state.enabled:
+ continue
+ if only_powered and child_player.state.powered is False:
+ continue
+ if active_only and child_player.state.active_group != group_player.player_id:
+ continue
+ if exclude_self and child_player.player_id == group_player.player_id:
+ continue
+ if only_playing and child_player.state.playback_state not in (
+ PlaybackState.PLAYING,
+ PlaybackState.PAUSED,
+ ):
+ continue
+ yield child_player
+
+ async def wait_for_state(
+ self,
+ player: Player,
+ wanted_state: PlaybackState,
+ timeout: float = 60.0,
+ minimal_time: float = 0,
+ ) -> None:
+ """Wait for the given player to reach the given state."""
+ start_timestamp = time.time()
+ self.logger.debug(
+ "Waiting for player %s to reach state %s", player.state.name, wanted_state
+ )
+ try:
+ async with asyncio.timeout(timeout):
+ while player.state.playback_state != wanted_state:
+ await asyncio.sleep(0.1)
+
+ except TimeoutError:
+ self.logger.debug(
+ "Player %s did not reach state %s within the timeout of %s seconds",
+ player.state.name,
+ wanted_state,
+ timeout,
+ )
+ elapsed_time = round(time.time() - start_timestamp, 2)
+ if elapsed_time < minimal_time:
+ self.logger.debug(
+ "Player %s reached state %s too soon (%s vs %s seconds) - add fallback sleep...",
+ player.state.name,
+ wanted_state,
+ elapsed_time,
+ minimal_time,
+ )
+ await asyncio.sleep(minimal_time - elapsed_time)
+ else:
+ self.logger.debug(
+ "Player %s reached state %s within %s seconds",
+ player.state.name,
+ wanted_state,
+ elapsed_time,
+ )
+
+ async def on_player_config_change(self, config: PlayerConfig, changed_keys: set[str]) -> None:
+ """Call (by config manager) when the configuration of a player changes."""
+ player = self.get_player(config.player_id)
+ player_provider = self.mass.get_provider(config.provider)
+ player_disabled = ATTR_ENABLED in changed_keys and not config.enabled
+ player_enabled = ATTR_ENABLED in changed_keys and config.enabled
+
+ if player_disabled and player and player.state.available:
+ # edge case: ensure that the player is powered off if the player gets disabled
+ if player.state.power_control != PLAYER_CONTROL_NONE:
+ await self._handle_cmd_power(config.player_id, False)
+ elif player.state.playback_state != PlaybackState.IDLE:
+ await self.cmd_stop(config.player_id)
+
+ # signal player provider that the player got enabled/disabled
+ if (player_enabled or player_disabled) and player_provider:
+ assert isinstance(player_provider, PlayerProvider) # for type checking
+ if player_disabled:
+ player_provider.on_player_disabled(config.player_id)
+ elif player_enabled:
+ player_provider.on_player_enabled(config.player_id)
+ return # enabling/disabling a player will be handled by the provider
+
+ if not player:
+ return # guard against player not being registered (yet)
+
+ resume_queue: PlayerQueue | None = (
+ self.mass.player_queues.get(player.state.active_source)
+ if player.state.active_source
+ else None
+ )
+
+ # ensure player state gets updated with any updated config
+ player.set_config(config)
+ await player.on_config_updated()
+ player.update_state()
+ # if the PlayerQueue was playing, restart playback
+ if resume_queue and resume_queue.state == PlaybackState.PLAYING:
+ requires_restart = any(
+ v for v in config.values.values() if v.key in changed_keys and v.requires_reload
+ )
+ if requires_restart:
+ # always stop first to ensure the player uses the new config
+ await self.mass.player_queues.stop(resume_queue.queue_id)
+ self.mass.call_later(
+ 1, self.mass.player_queues.resume, resume_queue.queue_id, False
+ )
+
+ async def on_player_dsp_change(self, player_id: str) -> None:
+ """Call (by config manager) when the DSP settings of a player change."""
+ # signal player provider that the config changed
+ if not (player := self.get_player(player_id)):
+ return
+ if player.state.playback_state == PlaybackState.PLAYING:
+ self.logger.info("Restarting playback of Player %s after DSP change", player_id)
+ # this will restart the queue stream/playback
+ if player.mass_queue_active:
+ self.mass.call_later(
+ 0, self.mass.player_queues.resume, player.state.active_source, False
+ )
+ return
+ # if the player is not using a queue, we need to stop and start playback
+ await self.cmd_stop(player_id)
+ await self.cmd_play(player_id)
+
+ async def _cleanup_player_memberships(self, player_id: str) -> None:
+ """Ensure a player is detached from any groups or syncgroups."""
+ if not (player := self.get_player(player_id)):
+ return
+
+ if (
+ player.state.active_group
+ and (group := self.get_player(player.state.active_group))
+ and group.supports_feature(PlayerFeature.SET_MEMBERS)
+ ):
+ # Ungroup the player if its part of an active group, this will ignore
+ # static_group_members since that is only checked when using cmd_set_members
+ with suppress(UnsupportedFeaturedException, PlayerCommandFailed):
+ await group.set_members(player_ids_to_remove=[player_id])
+ elif player.state.synced_to and player.supports_feature(PlayerFeature.SET_MEMBERS):
+ # Remove the player if it was synced, otherwise it will still show as
+ # synced to the other player after it gets registered again
+ with suppress(UnsupportedFeaturedException, PlayerCommandFailed):
+ await player.ungroup()
+
+ def _get_player_with_redirect(self, player_id: str) -> Player:
+ """Get player with check if playback related command should be redirected."""
+ player = self.get_player(player_id, True)
+ assert player is not None # for type checking
+ if player.state.synced_to and (sync_leader := self.get_player(player.state.synced_to)):
+ self.logger.info(
+ "Player %s is synced to %s and can not accept "
+ "playback related commands itself, "
+ "redirected the command to the sync leader.",
+ player.name,
+ sync_leader.name,
+ )
+ return sync_leader
+ if player.state.active_group and (
+ active_group := self.get_player(player.state.active_group)
+ ):
+ self.logger.info(
+ "Player %s is part of a playergroup and can not accept "
+ "playback related commands itself, "
+ "redirected the command to the group leader.",
+ player.name,
+ )
+ return active_group
+ return player
+
+ def _get_active_plugin_source(self, player: Player) -> PluginSource | None:
+ """Get the active PluginSource for a player if any."""
+ # Check if any plugin source is in use by this player
+ for plugin_source in self.get_plugin_sources():
+ if plugin_source.in_use_by == player.player_id:
+ return plugin_source
+ if player.state.active_source == plugin_source.id:
+ return plugin_source
+ return None
+
+ def _get_player_groups(
+ self, player: Player, available_only: bool = True, powered_only: bool = False
+ ) -> Iterator[Player]:
+ """Return all groupplayers the given player belongs to."""
+ for _player in self.all_players(return_unavailable=not available_only):
+ if _player.player_id == player.player_id:
+ continue
+ if _player.state.type != PlayerType.GROUP:
+ continue
+ if powered_only and _player.state.powered is False:
+ continue
+ if player.player_id in _player.state.group_members:
+ yield _player
+
+ # Protocol linking methods are provided by ProtocolLinkingMixin (protocol_linking.py)
+
+ async def _play_announcement( # noqa: PLR0915
+ self,
+ player: Player,
+ announcement: PlayerMedia,
+ volume_level: int | None = None,
+ ) -> None:
+ """Handle (default/fallback) implementation of the play announcement feature.
+
+ This default implementation will;
+ - stop playback of the current media (if needed)
+ - power on the player (if needed)
+ - raise the volume a bit
+ - play the announcement (from given url)
+ - wait for the player to finish playing
+ - restore the previous power and volume
+ - restore playback (if needed and if possible)
+
+ This default implementation will only be used if the player
+ (provider) has no native support for the PLAY_ANNOUNCEMENT feature.
+ """
+ prev_state = player.state.playback_state
+ prev_power = player.state.powered or prev_state != PlaybackState.IDLE
+ prev_synced_to = player.state.synced_to
+ prev_group = (
+ self.get_player(player.state.active_group) if player.state.active_group else None
+ )
+ prev_source = player.state.active_source
+ prev_media = player.state.current_media
+ prev_media_name = prev_media.title or prev_media.uri if prev_media else None
+ if prev_synced_to:
+ # ungroup player if its currently synced
+ self.logger.debug(
+ "Announcement to player %s - ungrouping player from %s...",
+ player.state.name,
+ prev_synced_to,
+ )
+ await self.cmd_ungroup(player.player_id)
+ elif prev_group:
+ # if the player is part of a group player, we need to ungroup it
+ if PlayerFeature.SET_MEMBERS in prev_group.supported_features:
+ self.logger.debug(
+ "Announcement to player %s - ungrouping from group player %s...",
+ player.state.name,
+ prev_group.display_name,
+ )
+ await prev_group.set_members(player_ids_to_remove=[player.player_id])
+ else:
+ # if the player is part of a group player that does not support ungrouping,
+ # we need to power off the groupplayer instead
+ self.logger.debug(
+ "Announcement to player %s - turning off group player %s...",
+ player.state.name,
+ prev_group.display_name,
+ )
+ await self._handle_cmd_power(player.player_id, False)
+ elif prev_state in (PlaybackState.PLAYING, PlaybackState.PAUSED):
+ # normal/standalone player: stop player if its currently playing
+ self.logger.debug(
+ "Announcement to player %s - stop existing content (%s)...",
+ player.state.name,
+ prev_media_name,
+ )
+ await self.cmd_stop(player.player_id)
+ # wait for the player to stop
+ await self.wait_for_state(player, PlaybackState.IDLE, 10, 0.4)
+ # adjust volume if needed
+ # in case of a (sync) group, we need to do this for all child players
+ prev_volumes: dict[str, int] = {}
+ async with TaskManager(self.mass) as tg:
+ for volume_player_id in player.state.group_members or (player.player_id,):
+ if not (volume_player := self.get_player(volume_player_id)):
+ continue
+ # catch any players that have a different source active
+ if (
+ volume_player.state.active_source
+ not in (
+ player.state.active_source,
+ volume_player.player_id,
+ None,
+ )
+ and volume_player.state.playback_state == PlaybackState.PLAYING
+ ):
+ self.logger.warning(
+ "Detected announcement to playergroup %s while group member %s is playing "
+ "other content, this may lead to unexpected behavior.",
+ player.state.name,
+ volume_player.state.name,
+ )
+ tg.create_task(self.cmd_stop(volume_player.player_id))
+ if volume_player.state.volume_control == PLAYER_CONTROL_NONE:
+ continue
+ if (prev_volume := volume_player.state.volume_level) is None:
+ continue
+ announcement_volume = self.get_announcement_volume(volume_player_id, volume_level)
+ if announcement_volume is None:
+ continue
+ temp_volume = announcement_volume or player.state.volume_level
+ if temp_volume != prev_volume:
+ prev_volumes[volume_player_id] = prev_volume
+ self.logger.debug(
+ "Announcement to player %s - setting temporary volume (%s)...",
+ volume_player.state.name,
+ announcement_volume,
+ )
+ tg.create_task(
+ self._handle_cmd_volume_set(volume_player.player_id, announcement_volume)
+ )
+ # play the announcement
+ self.logger.debug(
+ "Announcement to player %s - playing the announcement on the player...",
+ player.state.name,
+ )
+ await self.play_media(player_id=player.player_id, media=announcement)
+ # wait for the player(s) to play
+ await self.wait_for_state(player, PlaybackState.PLAYING, 10, minimal_time=0.1)
+ # wait for the player to stop playing
+ if not announcement.duration:
+ if not announcement.custom_data:
+ raise ValueError("Announcement missing duration and custom_data")
+ media_info = await async_parse_tags(
+ announcement.custom_data["announcement_url"], require_duration=True
+ )
+ announcement.duration = int(media_info.duration) if media_info.duration else None
+
+ if announcement.duration is None:
+ raise ValueError("Announcement duration could not be determined")
+
+ await self.wait_for_state(
+ player,
+ PlaybackState.IDLE,
+ timeout=announcement.duration + 10,
+ minimal_time=float(announcement.duration) + 2,
+ )
+ self.logger.debug(
+ "Announcement to player %s - restore previous state...", player.state.name
+ )
+ # restore volume
+ async with TaskManager(self.mass) as tg:
+ for volume_player_id, prev_volume in prev_volumes.items():
+ tg.create_task(self._handle_cmd_volume_set(volume_player_id, prev_volume))
+ await asyncio.sleep(0.2)
+ # either power off the player or resume playing
+ if not prev_power:
+ if player.state.power_control != PLAYER_CONTROL_NONE:
+ self.logger.debug(
+ "Announcement to player %s - turning player off again...", player.state.name
+ )
+ await self._handle_cmd_power(player.player_id, False)
+ # nothing to do anymore, player was not previously powered
+ # and does not support power control
+ return
+ if prev_synced_to:
+ self.logger.debug(
+ "Announcement to player %s - syncing back to %s...",
+ player.state.name,
+ prev_synced_to,
+ )
+ await self.cmd_set_members(prev_synced_to, player_ids_to_add=[player.player_id])
+ elif prev_group:
+ if PlayerFeature.SET_MEMBERS in prev_group.supported_features:
+ self.logger.debug(
+ "Announcement to player %s - grouping back to group player %s...",
+ player.state.name,
+ prev_group.display_name,
+ )
+ await prev_group.set_members(player_ids_to_add=[player.player_id])
+ elif prev_state == PlaybackState.PLAYING:
+ # if the player is part of a group player that does not support set_members,
+ # we need to restart the groupplayer
+ self.logger.debug(
+ "Announcement to player %s - restarting playback on group player %s...",
+ player.state.name,
+ prev_group.display_name,
+ )
+ await self.cmd_play(prev_group.player_id)
+ elif prev_state == PlaybackState.PLAYING:
+ # player was playing something before the announcement - try to resume that here
+ await self._handle_cmd_resume(player.player_id, prev_source, prev_media)
+
+ async def _poll_players(self) -> None:
+ """Background task that polls players for updates."""
+ while True:
+ for player in list(self._players.values()):
+ # if the player is playing, update elapsed time every tick
+ # to ensure the queue has accurate details
+ player_playing = player.state.playback_state == PlaybackState.PLAYING
+ if player_playing:
+ self.mass.loop.call_soon(
+ self.mass.player_queues.on_player_update,
+ player,
+ {"corrected_elapsed_time": (None, player.corrected_elapsed_time)},
+ )
+ # Poll player;
+ if not player.needs_poll:
+ continue
+ try:
+ last_poll: float = player.extra_data[ATTR_LAST_POLL]
+ except KeyError:
+ last_poll = 0.0
+ if (self.mass.loop.time() - last_poll) < player.poll_interval:
+ continue
+ player.extra_data[ATTR_LAST_POLL] = self.mass.loop.time()
+ try:
+ await player.poll()
+ except Exception as err:
+ self.logger.warning(
+ "Error while requesting latest state from player %s: %s",
+ player.state.name,
+ str(err),
+ exc_info=err if self.logger.isEnabledFor(10) else None,
+ )
+ # Yield to event loop to prevent blocking
+ await asyncio.sleep(0)
+ await asyncio.sleep(1)
+
+ async def _handle_select_plugin_source(
+ self, player: Player, plugin_prov: PluginProvider
+ ) -> None:
+ """Handle playback/select of given plugin source on player."""
+ plugin_source = plugin_prov.get_source()
+ if plugin_source.in_use_by and plugin_source.in_use_by != player.player_id:
+ self.logger.debug(
+ "Plugin source %s is already in use by player %s, stopping playback there first.",
+ plugin_source.name,
+ plugin_source.in_use_by,
+ )
+ with suppress(PlayerCommandFailed):
+ await self.cmd_stop(plugin_source.in_use_by)
+ stream_url = await self.mass.streams.get_plugin_source_url(plugin_source, player.player_id)
+ plugin_source.in_use_by = player.player_id
+ # Call on_select callback if available
+ if plugin_source.on_select:
+ await plugin_source.on_select()
+ await self.play_media(
+ player_id=player.player_id,
+ media=PlayerMedia(
+ uri=stream_url,
+ media_type=MediaType.PLUGIN_SOURCE,
+ title=plugin_source.name,
+ custom_data={
+ "provider": plugin_prov.instance_id,
+ "source_id": plugin_source.id,
+ "player_id": player.player_id,
+ "audio_format": plugin_source.audio_format,
+ },
+ ),
+ )
+ # trigger player update to ensure the source is set
+ self.trigger_player_update(player.player_id)
+
+ def _handle_group_dsp_change(
+ self, player: Player, prev_group_members: list[str], new_group_members: list[str]
+ ) -> None:
+ """Handle DSP reload when group membership changes."""
+ prev_child_count = len(prev_group_members)
+ new_child_count = len(new_group_members)
+ is_player_group = player.state.type == PlayerType.GROUP
+
+ # handle special case for PlayerGroups: since there are no leaders,
+ # DSP still always work with a single player in the group.
+ multi_device_dsp_threshold = 1 if is_player_group else 0
+
+ prev_is_multiple_devices = prev_child_count > multi_device_dsp_threshold
+ new_is_multiple_devices = new_child_count > multi_device_dsp_threshold
+
+ if prev_is_multiple_devices == new_is_multiple_devices:
+ return # no change in multi-device status
+
+ supports_multi_device_dsp = (
+ PlayerFeature.MULTI_DEVICE_DSP in player.state.supported_features
+ )
+
+ dsp_enabled: bool
+ if player.state.type == PlayerType.GROUP:
+ # Since player groups do not have leaders, we will use the only child
+ # that was in the group before and after the change
+ if prev_is_multiple_devices:
+ if childs := new_group_members:
+ # We shrank the group from multiple players to a single player
+ # So the now only child will control the DSP
+ dsp_enabled = self.mass.config.get_player_dsp_config(childs[0]).enabled
+ else:
+ dsp_enabled = False
+ elif childs := prev_group_members:
+ # We grew the group from a single player to multiple players,
+ # let's see if the previous single player had DSP enabled
+ dsp_enabled = self.mass.config.get_player_dsp_config(childs[0]).enabled
+ else:
+ dsp_enabled = False
+ else:
+ dsp_enabled = self.mass.config.get_player_dsp_config(player.player_id).enabled
+
+ if dsp_enabled and not supports_multi_device_dsp:
+ # We now know that the group configuration has changed so:
+ # - multi-device DSP is not supported
+ # - we switched from a group with multiple players to a single player
+ # (or vice versa)
+ # - the leader has DSP enabled
+ self.mass.create_task(self.mass.players.on_player_dsp_change(player.player_id))
+
+ def _schedule_update_all_players(self, delay: float = 2.0) -> None:
+ """
+ Schedule a debounced update of all players' state.
+
+ Used when a new player is registered to ensure all existing players
+ update their dynamic properties (like can_group_with) that may have changed.
+
+ :param delay: Delay in seconds before triggering updates (default 2.0).
+ """
+ if self.mass.closing:
+ return
+
+ async def _update_all_players() -> None:
+ if self.mass.closing:
+ return
+
+ for player in self.all_players(
+ return_unavailable=True,
+ return_disabled=False,
+ return_protocol_players=True,
+ ):
+ # Use call_soon to schedule updates without blocking
+ # This spreads the updates across event loop iterations
+ self.mass.loop.call_soon(player.update_state)
+
+ # Use mass.call_later with task_id for automatic debouncing
+ # Each call resets the timer, so rapid registrations only trigger one update
+ task_id = "update_all_players_on_registration"
+ self.mass.call_later(delay, _update_all_players, task_id=task_id)
+
+ async def _handle_set_members_with_protocols(
+ self,
+ parent_player: Player,
+ player_ids_to_add: list[str],
+ player_ids_to_remove: list[str],
+ ) -> None:
+ """
+ Handle set_members considering protocol and native members.
+
+ Translates visible player IDs to protocol player IDs when appropriate,
+ and forwards to the correct player's set_members.
+
+ :param parent_player: The parent player to add/remove members to/from.
+ :param player_ids_to_add: List of visible player IDs to add as members.
+ :param player_ids_to_remove: List of visible player IDs to remove from members.
+ """
+ # Get parent's active protocol domain and player if available
+ parent_protocol_domain = None
+ parent_protocol_player = None
+ if (
+ parent_player.active_output_protocol
+ and parent_player.active_output_protocol != "native"
+ ):
+ parent_protocol_player = self.get_player(parent_player.active_output_protocol)
+ if parent_protocol_player:
+ parent_protocol_domain = parent_protocol_player.provider.domain
+
+ self.logger.debug(
+ "set_members on %s: active_protocol=%s, adding=%s, removing=%s",
+ parent_player.state.name,
+ parent_protocol_domain or "none",
+ player_ids_to_add,
+ player_ids_to_remove,
+ )
+
+ # Translate members to add
+ (
+ protocol_members_to_add,
+ native_members_to_add,
+ parent_protocol_player,
+ parent_protocol_domain,
+ ) = self._translate_members_for_protocols(
+ parent_player, player_ids_to_add, parent_protocol_player, parent_protocol_domain
+ )
+
+ self.logger.debug(
+ "Translated members: protocol=%s (domain=%s), native=%s",
+ protocol_members_to_add,
+ parent_protocol_domain,
+ native_members_to_add,
+ )
+
+ # Translate members to remove
+ protocol_members_to_remove, native_members_to_remove = (
+ self._translate_members_to_remove_for_protocols(
+ parent_player, player_ids_to_remove, parent_protocol_player, parent_protocol_domain
+ )
+ )
+
+ # Forward protocol members to protocol player's set_members
+ if (protocol_members_to_add or protocol_members_to_remove) and parent_protocol_player:
+ await self._forward_protocol_set_members(
+ parent_player,
+ parent_protocol_player,
+ protocol_members_to_add,
+ protocol_members_to_remove,
+ )
+
+ # Forward native members to parent player's set_members
+ if native_members_to_add or native_members_to_remove:
+ filtered_native_add = self._filter_native_members(native_members_to_add, parent_player)
+ filtered_native_remove = [
+ pid
+ for pid in native_members_to_remove
+ if (p := self.get_player(pid)) and p.type != PlayerType.PROTOCOL
+ ]
+ self.logger.debug(
+ "Native grouping on %s: filtered_add=%s, filtered_remove=%s",
+ parent_player.state.name,
+ filtered_native_add,
+ filtered_native_remove,
+ )
+ if filtered_native_add or filtered_native_remove:
+ self.logger.info(
+ "Calling set_members on native player %s with add=%s, remove=%s",
+ parent_player.state.name,
+ filtered_native_add,
+ filtered_native_remove,
+ )
+ await parent_player.set_members(
+ player_ids_to_add=filtered_native_add or None,
+ player_ids_to_remove=filtered_native_remove or None,
+ )
+
+ # Private command handlers (no permission checks)
+
+ async def _handle_cmd_resume(
+ self, player_id: str, source: str | None = None, media: PlayerMedia | None = None
+ ) -> None:
+ """
+ Handle resume playback command.
+
+ Skips the permission checks (internal use only).
+ """
+ player = self._get_player_with_redirect(player_id)
+ source = source or player.state.active_source
+ media = media or player.state.current_media
+ # power on the player if needed
+ if not player.state.powered and player.state.power_control != PLAYER_CONTROL_NONE:
+ await self._handle_cmd_power(player.player_id, True)
+ # Redirect to queue controller if it is active
+ if active_queue := self.mass.player_queues.get(source or player_id):
+ await self.mass.player_queues.resume(active_queue.queue_id)
+ return
+ # try to handle command on player directly
+ # TODO: check if player has an active source with native resume support
+ active_source = next((x for x in player.state.source_list if x.id == source), None)
+ if (
+ player.state.playback_state in (PlaybackState.IDLE, PlaybackState.PAUSED)
+ and active_source
+ and active_source.can_play_pause
+ ):
+ # player has some other source active and native resume support
+ await player.play()
+ return
+ if active_source and not active_source.passive:
+ await self.select_source(player_id, active_source.id)
+ return
+ if media:
+ # try to re-play the current media item
+ await player.play_media(media)
+ return
+ # fallback: just send play command - which will fail if nothing can be played
+ await player.play()
+
+ async def _handle_cmd_power(self, player_id: str, powered: bool) -> None:
+ """
+ Handle player power on/off command.
+
+ Skips the permission checks (internal use only).
+ """
+ player = self.get_player(player_id, True)
+ assert player is not None # for type checking
+ player_state = player.state
+
+ if player_state.powered == powered:
+ self.logger.debug(
+ "Ignoring power %s command for player %s: already in state %s",
+ "ON" if powered else "OFF",
+ player_state.name,
+ "ON" if player_state.powered else "OFF",
+ )
+ return # nothing to do
+
+ # ungroup player at power off
+ player_was_synced = player.state.synced_to is not None
+ if player.type == PlayerType.PLAYER and not powered:
+ # ungroup player if it is synced (or is a sync leader itself)
+ # NOTE: ungroup will be ignored if the player is not grouped or synced
+ await self.cmd_ungroup(player_id)
+
+ # always stop player at power off
+ if (
+ not powered
+ and not player_was_synced
+ and player_state.playback_state in (PlaybackState.PLAYING, PlaybackState.PAUSED)
+ ):
+ await self.cmd_stop(player_id)
+ # short sleep: allow the stop command to process and prevent race conditions
+ await asyncio.sleep(0.2)
+
+ # power off all synced childs when player is a sync leader
+ elif not powered and player_state.type == PlayerType.PLAYER and player_state.group_members:
+ async with TaskManager(self.mass) as tg:
+ for member in self.iter_group_members(player, True):
+ if member.power_control == PLAYER_CONTROL_NONE:
+ continue
+ tg.create_task(self._handle_cmd_power(member.player_id, False))
+
+ # handle actual power command
+ if player_state.power_control == PLAYER_CONTROL_NONE:
+ raise UnsupportedFeaturedException(
+ f"Player {player.state.name} does not support power control"
+ )
+ if player_state.power_control == PLAYER_CONTROL_NATIVE:
+ # player supports power command natively: forward to player provider
+ await player.power(powered)
+ elif player_state.power_control == PLAYER_CONTROL_FAKE:
+ # user wants to use fake power control - so we (optimistically) update the state
+ # and store the state in the cache
+ player.extra_data[ATTR_FAKE_POWER] = powered
+ player.update_state() # trigger update of the player state
+ await self.mass.cache.set(
+ key=player_id,
+ data=powered,
+ provider=self.domain,
+ category=CACHE_CATEGORY_PLAYER_POWER,
+ )
+ else:
+ # handle external player control
+ player_control = self._controls.get(player.state.power_control)
+ control_name = player_control.name if player_control else player.state.power_control
+ self.logger.debug("Redirecting power command to PlayerControl %s", control_name)
+ if not player_control or not player_control.supports_power:
+ raise UnsupportedFeaturedException(
+ f"Player control {control_name} is not available"
+ )
+ if powered:
+ assert player_control.power_on is not None # for type checking
+ await player_control.power_on()
+ else:
+ assert player_control.power_off is not None # for type checking
+ await player_control.power_off()
+
+ # always trigger a state update to update the UI
+ player.update_state()
+
+ # handle 'auto play on power on' feature
+ if (
+ not player_state.active_group
+ and not player_state.synced_to
+ and powered
+ and player.config.get_value(CONF_AUTO_PLAY)
+ and player_state.active_source in (None, player_id)
+ and not player.extra_data.get(ATTR_ANNOUNCEMENT_IN_PROGRESS)
+ ):
+ await self.mass.player_queues.resume(player_id)
+
+ async def _handle_cmd_volume_set(self, player_id: str, volume_level: int) -> None:
+ """
+ Handle Player volume set command.
+
+ Skips the permission checks (internal use only).
+ """
+ player = self.get_player(player_id, True)
+ assert player is not None # for type checker
+ if player.type == PlayerType.GROUP:
+ # redirect to special group volume control
+ await self.cmd_group_volume(player_id, volume_level)
+ return
+
+ # Check if player has mute lock (set when individually muted in a group)
+ # If locked, don't auto-unmute when volume changes
+ has_mute_lock = player.extra_data.get(ATTR_MUTE_LOCK, False)
+ if (
+ not has_mute_lock
+ # use player.state here to get accumulated mute control from any linked protocol players
+ and player.state.mute_control not in (PLAYER_CONTROL_NONE, PLAYER_CONTROL_FAKE)
+ and player.state.volume_muted
+ ):
+ # if player is muted and not locked, we unmute it first
+ # skip this for fake mute since it uses volume to simulate mute
+ self.logger.debug(
+ "Unmuting player %s before setting volume",
+ player.state.name,
+ )
+ await self.cmd_volume_mute(player_id, False)
+
+ # Check if a plugin source is active with a volume callback
+ if plugin_source := self._get_active_plugin_source(player):
+ if plugin_source.on_volume:
+ await plugin_source.on_volume(volume_level)
+ # Handle native volume control support
+ if player.volume_control == PLAYER_CONTROL_NATIVE:
+ # player supports volume command natively: forward to player
+ await player.volume_set(volume_level)
+ return
+ # Handle fake volume control support
+ if player.volume_control == PLAYER_CONTROL_FAKE:
+ # user wants to use fake volume control - so we (optimistically) update the state
+ # and store the state in the cache
+ player.extra_data[ATTR_FAKE_VOLUME] = volume_level
+ # trigger update
+ player.update_state()
+ return
+ # player has no volume support at all
+ if player.volume_control == PLAYER_CONTROL_NONE:
+ raise UnsupportedFeaturedException(
+ f"Player {player.state.name} does not support volume control"
+ )
+ # handle external player control
+ if player_control := self._controls.get(player.state.volume_control):
+ control_name = player_control.name if player_control else player.state.volume_control
+ self.logger.debug("Redirecting volume command to PlayerControl %s", control_name)
+ if not player_control or not player_control.supports_volume:
+ raise UnsupportedFeaturedException(
+ f"Player control {control_name} is not available"
+ )
+ assert player_control.volume_set is not None
+ await player_control.volume_set(volume_level)
+ return
+ if protocol_player := self.get_player(player.state.volume_control):
+ # redirect to protocol player volume control
+ self.logger.debug(
+ "Redirecting volume command to protocol player %s",
+ protocol_player.provider.manifest.name,
+ )
+ await self._handle_cmd_volume_set(protocol_player.player_id, volume_level)
+ return
+
+ async def _handle_play_media(self, player_id: str, media: PlayerMedia) -> None:
+ """
+ Handle play media command without group redirect.
+
+ Skips permission checks and all redirect logic (internal use only).
+
+ :param player_id: player_id of the player to handle the command.
+ :param media: The Media that needs to be played on the player.
+ """
+ player = self.get_player(player_id, raise_unavailable=True)
+ assert player is not None
+ # set active source if media has a source_id (e.g. plugin source or mass queue source)
+ if media.source_id:
+ player.set_active_mass_source(media.source_id)
+
+ # Select best output protocol for playback
+ target_player, output_protocol = self._select_best_output_protocol(player)
+
+ if target_player.player_id != player.player_id:
+ # Playing via linked protocol - update active output protocol
+ # output_protocol is guaranteed to be non-None when target_player != player
+ assert output_protocol is not None
+ self.logger.debug(
+ "Starting playback on %s via protocol %s (target=%s), group_members=%s",
+ player.state.name,
+ output_protocol.output_protocol_id,
+ target_player.display_name,
+ target_player.state.group_members,
+ )
+ player.set_active_output_protocol(output_protocol.output_protocol_id)
+ # if the (protocol)player has power control and is currently powered off,
+ # we need to power it on before playback
+ if (
+ target_player.state.powered is False
+ and target_player.power_control != PLAYER_CONTROL_NONE
+ ):
+ await self._handle_cmd_power(target_player.player_id, True)
+ # forward play media command to protocol player
+ await target_player.play_media(media)
+ # notify the native player that protocol playback started
+ await player.on_protocol_playback(output_protocol=output_protocol)
+ else:
+ # Native playback
+ self.logger.debug(
+ "Starting playback on %s via native, group_members=%s",
+ player.state.name,
+ player.state.group_members,
+ )
+ player.set_active_output_protocol("native")
+ await player.play_media(media)
+
+ async def _handle_enqueue_next_media(self, player_id: str, media: PlayerMedia) -> None:
+ """
+ Handle enqueue next media command without group redirect.
+
+ Skips permission checks and all redirect logic (internal use only).
+
+ :param player_id: player_id of the player to handle the command.
+ :param media: The Media that needs to be enqueued on the player.
+ """
+ player = self.get_player(player_id, raise_unavailable=True)
+ assert player is not None
+ if target_player := self._get_control_target(
+ player, required_feature=PlayerFeature.ENQUEUE, require_active=True, allow_native=False
+ ):
+ self.logger.debug(
+ "Redirecting enqueue command to protocol player %s",
+ target_player.provider.manifest.name,
+ )
+ await self._handle_enqueue_next_media(target_player.player_id, media)
+ return
+
+ if PlayerFeature.ENQUEUE not in player.state.supported_features:
+ raise UnsupportedFeaturedException(
+ f"Player {player.state.name} does not support enqueueing"
+ )
+ await player.enqueue_next_media(media)
+
+ async def _handle_select_source(self, player_id: str, source: str | None) -> None:
+ """
+ Handle select source command without group redirect.
+
+ Skips permission checks and all redirect logic (internal use only).
+
+ :param player_id: player_id of the player to handle the command.
+ :param source: The ID of the source that needs to be activated/selected.
+ """
+ if source is None:
+ source = player_id # default to MA queue source
+ player = self.get_player(player_id, True)
+ assert player is not None
+ # check if player is already playing and source is different
+ # in that case we need to stop the player first
+ prev_source = player.state.active_source
+ if prev_source and source != prev_source:
+ with suppress(PlayerCommandFailed, RuntimeError):
+ # just try to stop (regardless of state)
+ await self._handle_cmd_stop(player_id)
+ await asyncio.sleep(2) # small delay to allow stop to process
+ # check if source is a pluginsource
+ # in that case the source id is the instance_id of the plugin provider
+ if plugin_prov := self.mass.get_provider(source):
+ player.set_active_mass_source(source)
+ await self._handle_select_plugin_source(player, cast("PluginProvider", plugin_prov))
+ return
+ # check if source is a mass queue
+ # this can be used to restore the queue after a source switch
+ if self.mass.player_queues.get(source):
+ player.set_active_mass_source(source)
+ return
+ # basic check if player supports source selection
+ if PlayerFeature.SELECT_SOURCE not in player.state.supported_features:
+ raise UnsupportedFeaturedException(
+ f"Player {player.state.name} does not support source selection"
+ )
+ # basic check if source is valid for player
+ if not any(x for x in player.state.source_list if x.id == source):
+ raise PlayerCommandFailed(
+ f"{source} is an invalid source for player {player.state.name}"
+ )
+ # forward to player
+ await player.select_source(source)
+
+ async def _handle_cmd_stop(self, player_id: str) -> None:
+ """
+ Handle stop command without any redirects.
+
+ Skips permission checks and all redirect logic (internal use only).
+
+ :param player_id: player_id of the player to handle the command.
+ """
+ player = self.get_player(player_id, raise_unavailable=True)
+ assert player is not None
+ player.mark_stop_called()
+ # Delegate to active protocol player if one is active
+ target_player = player
+ if (
+ player.active_output_protocol
+ and player.active_output_protocol != "native"
+ and (protocol_player := self.get_player(player.active_output_protocol))
+ ):
+ target_player = protocol_player
+ if PlayerFeature.POWER in target_player.supported_features:
+ # if protocol player supports/requires power,
+ # we power it off instead of just stopping (which also stops playback)
+ await self._handle_cmd_power(target_player.player_id, False)
+ return
+
+ # handle command on player(protocol) directly
+ await target_player.stop()
+
+ async def _handle_cmd_play(self, player_id: str) -> None:
+ """
+ Handle play command without group redirect.
+
+ Skips permission checks and all redirect logic (internal use only).
+
+ :param player_id: player_id of the player to handle the command.
+ """
+ player = self.get_player(player_id, raise_unavailable=True)
+ assert player is not None
+ if player.state.playback_state == PlaybackState.PLAYING:
+ self.logger.info(
+ "Ignore PLAY request to player %s: player is already playing", player.state.name
+ )
+ return
+ # Check if a plugin source is active with a play callback
+ if plugin_source := self._get_active_plugin_source(player):
+ if plugin_source.can_play_pause and plugin_source.on_play:
+ await plugin_source.on_play()
+ return
+ # handle unpause (=play if player is paused)
+ if player.state.playback_state == PlaybackState.PAUSED:
+ active_source = next(
+ (x for x in player.state.source_list if x.id == player.state.active_source), None
+ )
+ # raise if active source does not support play/pause
+ if active_source and not active_source.can_play_pause:
+ msg = (
+ f"The active source ({active_source.name}) on player "
+ f"{player.state.name} does not support play/pause"
+ )
+ raise PlayerCommandFailed(msg)
+ # Delegate to active protocol player if one is active
+ if target_player := self._get_control_target(player, PlayerFeature.PAUSE, True):
+ await target_player.play()
+ return
+
+ # player is not paused: try to resume the player
+ # Note: We handle resume inline here without calling _handle_cmd_resume
+ source = player.state.active_source
+ media = player.state.current_media
+ # power on the player if needed
+ if not player.state.powered and player.state.power_control != PLAYER_CONTROL_NONE:
+ await self._handle_cmd_power(player.player_id, True)
+ # try to handle command on player directly
+ active_source = next((x for x in player.state.source_list if x.id == source), None)
+ if (
+ player.state.playback_state in (PlaybackState.IDLE, PlaybackState.PAUSED)
+ and active_source
+ and active_source.can_play_pause
+ ):
+ # player has some other source active and native resume support
+ await player.play()
+ return
+ if active_source and not active_source.passive:
+ await self._handle_select_source(player_id, active_source.id)
+ return
+ if media:
+ # try to re-play the current media item
+ await player.play_media(media)
+ return
+ # fallback: just send play command - which will fail if nothing can be played
+ await player.play()
+
+ async def _handle_cmd_pause(self, player_id: str) -> None:
+ """
+ Handle pause command without any redirects.
+
+ Skips permission checks and all redirect logic (internal use only).
+
+ :param player_id: player_id of the player to handle the command.
+ """
+ player = self.get_player(player_id, raise_unavailable=True)
+ assert player is not None
+ # Check if a plugin source is active with a pause callback
+ if plugin_source := self._get_active_plugin_source(player):
+ if plugin_source.can_play_pause and plugin_source.on_pause:
+ await plugin_source.on_pause()
+ return
+ # handle command on player/source directly
+ active_source = next(
+ (x for x in player.state.source_list if x.id == player.state.active_source), None
+ )
+ if active_source and not active_source.can_play_pause:
+ # raise if active source does not support play/pause
+ msg = (
+ f"The active source ({active_source.name}) on player "
+ f"{player.state.name} does not support play/pause"
+ )
+ raise PlayerCommandFailed(msg)
+ # Delegate to active protocol player if one is active
+ if not (target_player := self._get_control_target(player, PlayerFeature.PAUSE, True)):
+ # if player(protocol) does not support pause, we need to send stop
+ self.logger.debug(
+ "Player/protocol %s does not support pause, using STOP instead",
+ player.state.name,
+ )
+ await self._handle_cmd_stop(player.player_id)
+ return
+ # handle command on player(protocol) directly
+ await target_player.pause()
+
+ def __iter__(self) -> Iterator[Player]:
+ """Iterate over all players."""
+ return iter(self._players.values())
--- /dev/null
+"""
+Helper utilities for the Player Controller.
+
+Contains decorators, type definitions, and utility functions used by the
+PlayerController that don't need direct access to the controller class.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import functools
+from collections.abc import Awaitable, Callable, Coroutine
+from typing import TYPE_CHECKING, Any, Concatenate, TypedDict, overload
+
+from music_assistant_models.errors import InsufficientPermissions, PlayerCommandFailed
+
+from music_assistant.controllers.webserver.helpers.auth_middleware import get_current_user
+
+if TYPE_CHECKING:
+ from .controller import PlayerController
+
+
+class AnnounceData(TypedDict):
+ """Announcement data for play_announcement command."""
+
+ announcement_url: str
+ pre_announce: bool
+ pre_announce_url: str
+
+
+@overload
+def handle_player_command[PlayerControllerT: "PlayerController", **P, R](
+ func: Callable[Concatenate[PlayerControllerT, P], Awaitable[R]],
+) -> Callable[Concatenate[PlayerControllerT, P], Coroutine[Any, Any, R | None]]: ...
+
+
+@overload
+def handle_player_command[PlayerControllerT: "PlayerController", **P, R](
+ func: None = None,
+ *,
+ lock: bool = False,
+) -> Callable[
+ [Callable[Concatenate[PlayerControllerT, P], Awaitable[R]]],
+ Callable[Concatenate[PlayerControllerT, P], Coroutine[Any, Any, R | None]],
+]: ...
+
+
+def handle_player_command[PlayerControllerT: "PlayerController", **P, R](
+ func: Callable[Concatenate[PlayerControllerT, P], Awaitable[R]] | None = None,
+ *,
+ lock: bool = False,
+) -> (
+ Callable[Concatenate[PlayerControllerT, P], Coroutine[Any, Any, R | None]]
+ | Callable[
+ [Callable[Concatenate[PlayerControllerT, P], Awaitable[R]]],
+ Callable[Concatenate[PlayerControllerT, P], Coroutine[Any, Any, R | None]],
+ ]
+):
+ """
+ Decorator to check and log commands to players.
+
+ Validates that the player exists and is available before executing the command.
+ Also checks user permissions and optionally acquires a per-player lock.
+
+ :param func: The function to wrap (when used without parentheses).
+ :param lock: If True, acquire a lock per player_id and function name before executing.
+ """ # noqa: D401
+
+ def decorator(
+ fn: Callable[Concatenate[PlayerControllerT, P], Awaitable[R]],
+ ) -> Callable[Concatenate[PlayerControllerT, P], Coroutine[Any, Any, R | None]]:
+ @functools.wraps(fn)
+ async def wrapper(self: PlayerControllerT, *args: P.args, **kwargs: P.kwargs) -> None:
+ """Log and handle_player_command commands to players."""
+ player_id = kwargs.get("player_id") or args[0]
+ assert isinstance(player_id, str) # for type checking
+ if (player := self._players.get(player_id)) is None or not player.available:
+ self.logger.warning(
+ "Ignoring command %s for unavailable player %s",
+ fn.__name__,
+ player_id,
+ )
+ return
+
+ current_user = get_current_user()
+ if (
+ current_user
+ and current_user.player_filter
+ and player.player_id not in current_user.player_filter
+ ):
+ msg = (
+ f"{current_user.username} does not have access to player {player.display_name}"
+ )
+ raise InsufficientPermissions(msg)
+
+ self.logger.debug(
+ "Handling command %s for player %s (%s)",
+ fn.__name__,
+ player.display_name,
+ f"by user {current_user.username}" if current_user else "unauthenticated",
+ )
+
+ async def execute() -> None:
+ async with self._player_throttlers[player_id]:
+ try:
+ await fn(self, *args, **kwargs)
+ except Exception as err:
+ raise PlayerCommandFailed(str(err)) from err
+
+ if lock:
+ # Acquire a lock specific to player_id and function name
+ lock_key = f"{fn.__name__}_{player_id}"
+ if lock_key not in self._player_command_locks:
+ self._player_command_locks[lock_key] = asyncio.Lock()
+ async with self._player_command_locks[lock_key]:
+ await execute()
+ else:
+ await execute()
+
+ return wrapper
+
+ # Support both @handle_player_command and @handle_player_command(lock=True)
+ if func is not None:
+ return decorator(func)
+ return decorator
+++ /dev/null
-"""
-MusicAssistant PlayerController.
-
-Handles all logic to control supported players,
-which are provided by Player Providers.
-
-Note that the PlayerController has a concept of a 'player' and a 'playerstate'.
-The Player is the actual object that is provided by the provider,
-which incorporates the actual state of the player (e.g. volume, state, etc)
-and functions for controlling the player (e.g. play, pause, etc).
-
-The playerstate is the (final) state of the player, including any user customizations
-and transformations that are applied to the player.
-The playerstate is the object that is exposed to the outside world (via the API).
-"""
-
-from __future__ import annotations
-
-import asyncio
-import functools
-import time
-from collections.abc import Awaitable, Callable, Coroutine
-from contextlib import suppress
-from typing import TYPE_CHECKING, Any, Concatenate, TypedDict, cast, overload
-
-from music_assistant_models.auth import UserRole
-from music_assistant_models.constants import (
- PLAYER_CONTROL_FAKE,
- PLAYER_CONTROL_NATIVE,
- PLAYER_CONTROL_NONE,
-)
-from music_assistant_models.enums import (
- EventType,
- MediaType,
- PlaybackState,
- PlayerFeature,
- PlayerType,
- ProviderFeature,
- ProviderType,
-)
-from music_assistant_models.errors import (
- AlreadyRegisteredError,
- InsufficientPermissions,
- MusicAssistantError,
- PlayerCommandFailed,
- PlayerUnavailableError,
- ProviderUnavailableError,
- UnsupportedFeaturedException,
-)
-from music_assistant_models.player import PlayerOptionValueType # noqa: TC002
-from music_assistant_models.player_control import PlayerControl # noqa: TC002
-
-from music_assistant.constants import (
- ANNOUNCE_ALERT_FILE,
- ATTR_ANNOUNCEMENT_IN_PROGRESS,
- ATTR_AVAILABLE,
- ATTR_ELAPSED_TIME,
- ATTR_ENABLED,
- ATTR_FAKE_MUTE,
- ATTR_FAKE_POWER,
- ATTR_FAKE_VOLUME,
- ATTR_GROUP_MEMBERS,
- ATTR_LAST_POLL,
- ATTR_MUTE_LOCK,
- ATTR_PREVIOUS_VOLUME,
- CONF_AUTO_PLAY,
- CONF_ENTRY_ANNOUNCE_VOLUME,
- CONF_ENTRY_ANNOUNCE_VOLUME_MAX,
- CONF_ENTRY_ANNOUNCE_VOLUME_MIN,
- CONF_ENTRY_ANNOUNCE_VOLUME_STRATEGY,
- CONF_ENTRY_TTS_PRE_ANNOUNCE,
- CONF_ENTRY_ZEROCONF_INTERFACES,
- CONF_PLAYER_DSP,
- CONF_PLAYERS,
- CONF_PRE_ANNOUNCE_CHIME_URL,
- SYNCGROUP_PREFIX,
-)
-from music_assistant.controllers.webserver.helpers.auth_middleware import (
- get_current_user,
- get_sendspin_player_id,
-)
-from music_assistant.helpers.api import api_command
-from music_assistant.helpers.tags import async_parse_tags
-from music_assistant.helpers.throttle_retry import Throttler
-from music_assistant.helpers.util import TaskManager, validate_announcement_chime_url
-from music_assistant.models.core_controller import CoreController
-from music_assistant.models.player import Player, PlayerMedia, PlayerState
-from music_assistant.models.player_provider import PlayerProvider
-from music_assistant.models.plugin import PluginProvider, PluginSource
-
-from .sync_groups import SyncGroupController, SyncGroupPlayer
-
-if TYPE_CHECKING:
- from collections.abc import Iterator
-
- from music_assistant_models.config_entries import (
- ConfigEntry,
- ConfigValueType,
- CoreConfig,
- PlayerConfig,
- )
- from music_assistant_models.player_queue import PlayerQueue
-
- from music_assistant import MusicAssistant
-
-CACHE_CATEGORY_PLAYER_POWER = 1
-
-
-class AnnounceData(TypedDict):
- """Announcement data."""
-
- announcement_url: str
- pre_announce: bool
- pre_announce_url: str
-
-
-@overload
-def handle_player_command[PlayerControllerT: "PlayerController", **P, R](
- func: Callable[Concatenate[PlayerControllerT, P], Awaitable[R]],
-) -> Callable[Concatenate[PlayerControllerT, P], Coroutine[Any, Any, R | None]]: ...
-
-
-@overload
-def handle_player_command[PlayerControllerT: "PlayerController", **P, R](
- func: None = None,
- *,
- lock: bool = False,
-) -> Callable[
- [Callable[Concatenate[PlayerControllerT, P], Awaitable[R]]],
- Callable[Concatenate[PlayerControllerT, P], Coroutine[Any, Any, R | None]],
-]: ...
-
-
-def handle_player_command[PlayerControllerT: "PlayerController", **P, R](
- func: Callable[Concatenate[PlayerControllerT, P], Awaitable[R]] | None = None,
- *,
- lock: bool = False,
-) -> (
- Callable[Concatenate[PlayerControllerT, P], Coroutine[Any, Any, R | None]]
- | Callable[
- [Callable[Concatenate[PlayerControllerT, P], Awaitable[R]]],
- Callable[Concatenate[PlayerControllerT, P], Coroutine[Any, Any, R | None]],
- ]
-):
- """Check and log commands to players.
-
- :param func: The function to wrap (when used without parentheses).
- :param lock: If True, acquire a lock per player_id and function name before executing.
- """
-
- def decorator(
- fn: Callable[Concatenate[PlayerControllerT, P], Awaitable[R]],
- ) -> Callable[Concatenate[PlayerControllerT, P], Coroutine[Any, Any, R | None]]:
- @functools.wraps(fn)
- async def wrapper(self: PlayerControllerT, *args: P.args, **kwargs: P.kwargs) -> None:
- """Log and handle_player_command commands to players."""
- player_id = kwargs.get("player_id") or args[0]
- assert isinstance(player_id, str) # for type checking
- if (player := self._players.get(player_id)) is None or not player.available:
- # player not existent
- self.logger.warning(
- "Ignoring command %s for unavailable player %s",
- fn.__name__,
- player_id,
- )
- return
-
- current_user = get_current_user()
- current_sendspin_player = get_sendspin_player_id()
- if (
- current_user
- and current_user.player_filter
- and player.player_id not in current_user.player_filter
- and player.player_id != current_sendspin_player
- ):
- msg = (
- f"{current_user.username} does not have access to player {player.display_name}"
- )
- raise InsufficientPermissions(msg)
-
- self.logger.debug(
- "Handling command %s for player %s (%s)",
- fn.__name__,
- player.display_name,
- f"by user {current_user.username}" if current_user else "unauthenticated",
- )
-
- async def execute() -> None:
- try:
- await fn(self, *args, **kwargs)
- except Exception as err:
- raise PlayerCommandFailed(str(err)) from err
-
- if lock:
- # Acquire a lock specific to player_id and function name
- lock_key = f"{fn.__name__}_{player_id}"
- if lock_key not in self._player_command_locks:
- self._player_command_locks[lock_key] = asyncio.Lock()
- async with self._player_command_locks[lock_key]:
- await execute()
- else:
- await execute()
-
- return wrapper
-
- # Support both @handle_player_command and @handle_player_command(lock=True)
- if func is not None:
- return decorator(func)
- return decorator
-
-
-class PlayerController(CoreController):
- """Controller holding all logic to control registered players."""
-
- domain: str = "players"
-
- def __init__(self, mass: MusicAssistant) -> None:
- """Initialize core controller."""
- super().__init__(mass)
- self._players: dict[str, Player] = {}
- self._controls: dict[str, PlayerControl] = {}
- self.manifest.name = "Player Controller"
- self.manifest.description = (
- "Music Assistant's core controller which manages all players from all providers."
- )
- self.manifest.icon = "speaker-multiple"
- self._poll_task: asyncio.Task[None] | None = None
- self._player_throttlers: dict[str, Throttler] = {}
- self._player_command_locks: dict[str, asyncio.Lock] = {}
- self._sync_groups: SyncGroupController = SyncGroupController(self)
-
- async def get_config_entries(
- self,
- action: str | None = None,
- values: dict[str, ConfigValueType] | None = None,
- ) -> tuple[ConfigEntry, ...]:
- """Return Config Entries for the Player Controller."""
- return (CONF_ENTRY_ZEROCONF_INTERFACES,)
-
- async def setup(self, config: CoreConfig) -> None:
- """Async initialize of module."""
- self._poll_task = self.mass.create_task(self._poll_players())
-
- async def close(self) -> None:
- """Cleanup on exit."""
- if self._poll_task and not self._poll_task.done():
- self._poll_task.cancel()
-
- async def on_provider_loaded(self, provider: PlayerProvider) -> None:
- """Handle logic when a provider is loaded."""
- if ProviderFeature.SYNC_PLAYERS in provider.supported_features:
- await self._sync_groups.on_provider_loaded(provider)
-
- async def on_provider_unload(self, provider: PlayerProvider) -> None:
- """Handle logic when a provider is (about to get) unloaded."""
- if ProviderFeature.SYNC_PLAYERS in provider.supported_features:
- await self._sync_groups.on_provider_unload(provider)
-
- @property
- def providers(self) -> list[PlayerProvider]:
- """Return all loaded/running MusicProviders."""
- return cast("list[PlayerProvider]", self.mass.get_providers(ProviderType.PLAYER))
-
- def all(
- self,
- return_unavailable: bool = True,
- return_disabled: bool = False,
- provider_filter: str | None = None,
- return_sync_groups: bool = True,
- ) -> list[Player]:
- """
- Return all registered players.
-
- Note that this applies user filters for players (for non admin users).
-
- :param return_unavailable [bool]: Include unavailable players.
- :param return_disabled [bool]: Include disabled players.
- :param provider_filter [str]: Optional filter by provider lookup key.
-
- :return: List of Player objects.
- """
- current_user = get_current_user()
- user_filter = (
- current_user.player_filter
- if current_user and current_user.role != UserRole.ADMIN
- else None
- )
- current_sendspin_player = get_sendspin_player_id()
- return [
- player
- for player in self._players.values()
- if (player.available or return_unavailable)
- and (player.enabled or return_disabled)
- and (provider_filter is None or player.provider.instance_id == provider_filter)
- and (
- not user_filter
- or player.player_id in user_filter
- or player.player_id == current_sendspin_player
- )
- and (return_sync_groups or not isinstance(player, SyncGroupPlayer))
- ]
-
- @api_command("players/all")
- def all_states(
- self,
- return_unavailable: bool = True,
- return_disabled: bool = False,
- provider_filter: str | None = None,
- ) -> list[PlayerState]:
- """
- Return PlayerState for all registered players.
-
- :param return_unavailable [bool]: Include unavailable players.
- :param return_disabled [bool]: Include disabled players.
- :param provider_filter [str]: Optional filter by provider lookup key.
-
- :return: List of PlayerState objects.
- """
- return [
- player.state
- for player in self.all(
- return_unavailable=return_unavailable,
- return_disabled=return_disabled,
- provider_filter=provider_filter,
- )
- ]
-
- def get(
- self,
- player_id: str,
- raise_unavailable: bool = False,
- ) -> Player | None:
- """
- Return Player by player_id.
-
- :param player_id [str]: ID of the player.
- :param raise_unavailable [bool]: Raise if player is unavailable.
-
- :raises PlayerUnavailableError: If player is unavailable and raise_unavailable is True.
- :return: Player object or None.
- """
- if player := self._players.get(player_id):
- if (not player.available or not player.enabled) and raise_unavailable:
- msg = f"Player {player_id} is not available"
- raise PlayerUnavailableError(msg)
- return player
- if raise_unavailable:
- msg = f"Player {player_id} is not available"
- raise PlayerUnavailableError(msg)
- return None
-
- @api_command("players/get")
- def get_state(
- self,
- player_id: str,
- raise_unavailable: bool = False,
- ) -> PlayerState | None:
- """
- Return PlayerState by player_id.
-
- :param player_id [str]: ID of the player.
- :param raise_unavailable [bool]: Raise if player is unavailable.
-
- :raises PlayerUnavailableError: If player is unavailable and raise_unavailable is True.
- :return: Player object or None.
- """
- current_user = get_current_user()
- user_filter = (
- current_user.player_filter
- if current_user and current_user.role != UserRole.ADMIN
- else None
- )
- current_sendspin_player = get_sendspin_player_id()
- if (
- current_user
- and user_filter
- and player_id not in user_filter
- and player_id != current_sendspin_player
- ):
- msg = f"{current_user.username} does not have access to player {player_id}"
- raise InsufficientPermissions(msg)
- if player := self.get(player_id, raise_unavailable):
- return player.state
- return None
-
- def get_player_by_name(self, name: str) -> Player | None:
- """
- Return Player by name.
-
- Performs case-insensitive matching against the player's state name
- (the final name visible in clients and API).
- If multiple players match, logs a warning and returns the first match.
-
- :param name: Name of the player.
- :return: Player object or None.
- """
- name_normalized = name.strip().lower()
- matches: list[Player] = []
-
- for player in self._players.values():
- if player.state.name.strip().lower() == name_normalized:
- matches.append(player)
-
- if not matches:
- return None
-
- if len(matches) > 1:
- player_ids = [p.player_id for p in matches]
- self.logger.warning(
- "players/get_by_name: Multiple players found with name '%s': %s - "
- "returning first match (%s). "
- "Consider using the players/get API with player_id instead "
- "for unambiguous lookups.",
- name,
- player_ids,
- matches[0].player_id,
- )
-
- return matches[0]
-
- @api_command("players/get_by_name")
- def get_player_state_by_name(self, name: str) -> PlayerState | None:
- """
- Return PlayerState by name.
-
- :param name: Name of the player.
- :return: PlayerState object or None.
- """
- current_user = get_current_user()
- user_filter = (
- current_user.player_filter
- if current_user and current_user.role != UserRole.ADMIN
- else None
- )
- current_sendspin_player = get_sendspin_player_id()
- if player := self.get_player_by_name(name):
- if (
- current_user
- and user_filter
- and player.player_id not in user_filter
- and player.player_id != current_sendspin_player
- ):
- msg = f"{current_user.username} does not have access to player {player.player_id}"
- raise InsufficientPermissions(msg)
- return player.state
- return None
-
- @api_command("players/player_controls")
- def player_controls(
- self,
- ) -> list[PlayerControl]:
- """Return all registered playercontrols."""
- return list(self._controls.values())
-
- @api_command("players/player_control")
- def get_player_control(
- self,
- control_id: str,
- ) -> PlayerControl | None:
- """
- Return PlayerControl by control_id.
-
- :param control_id: ID of the player control.
- :return: PlayerControl object or None.
- """
- if control := self._controls.get(control_id):
- return control
- return None
-
- @api_command("players/plugin_sources")
- def get_plugin_sources(self) -> list[PluginSource]:
- """Return all available plugin sources."""
- return [
- plugin_prov.get_source()
- for plugin_prov in self.mass.get_providers(ProviderType.PLUGIN)
- if isinstance(plugin_prov, PluginProvider)
- and ProviderFeature.AUDIO_SOURCE in plugin_prov.supported_features
- ]
-
- @api_command("players/plugin_source")
- def get_plugin_source(
- self,
- source_id: str,
- ) -> PluginSource | None:
- """
- Return PluginSource by source_id.
-
- :param source_id: ID of the plugin source.
- :return: PluginSource object or None.
- """
- for plugin_prov in self.mass.get_providers(ProviderType.PLUGIN):
- assert isinstance(plugin_prov, PluginProvider) # for type checking
- if ProviderFeature.AUDIO_SOURCE not in plugin_prov.supported_features:
- continue
- if (source := plugin_prov.get_source()) and source.id == source_id:
- return source
- return None
-
- # Player commands
-
- @api_command("players/cmd/stop")
- @handle_player_command
- async def cmd_stop(self, player_id: str) -> None:
- """Send STOP command to given player.
-
- - player_id: player_id of the player to handle the command.
- """
- player = self._get_player_with_redirect(player_id)
- player.mark_stop_called()
- # Redirect to queue controller if it is active
- if active_queue := self.get_active_queue(player):
- await self.mass.player_queues.stop(active_queue.queue_id)
- else:
- # handle command on player directly
- async with self._player_throttlers[player.player_id]:
- await player.stop()
-
- @api_command("players/cmd/play")
- @handle_player_command
- async def cmd_play(self, player_id: str) -> None:
- """Send PLAY (unpause) command to given player.
-
- - player_id: player_id of the player to handle the command.
- """
- player = self._get_player_with_redirect(player_id)
- if player.playback_state == PlaybackState.PLAYING:
- self.logger.info(
- "Ignore PLAY request to player %s: player is already playing", player.display_name
- )
- return
-
- # Check if a plugin source is active with a play callback
- if plugin_source := self._get_active_plugin_source(player):
- if plugin_source.can_play_pause and plugin_source.on_play:
- await plugin_source.on_play()
- return
-
- if player.playback_state == PlaybackState.PAUSED:
- # handle command on player/source directly
- active_source = next(
- (x for x in player.source_list if x.id == player.active_source), None
- )
- if active_source and not active_source.can_play_pause:
- raise PlayerCommandFailed(
- "The active source (%s) on player %s does not support play/pause",
- active_source.name,
- player.display_name,
- )
- async with self._player_throttlers[player.player_id]:
- await player.play()
- else:
- # try to resume the player
- await self._handle_cmd_resume(player.player_id)
-
- @api_command("players/cmd/pause")
- @handle_player_command
- async def cmd_pause(self, player_id: str) -> None:
- """Send PAUSE command to given player.
-
- - player_id: player_id of the player to handle the command.
- """
- player = self._get_player_with_redirect(player_id)
-
- # Check if a plugin source is active with a pause callback
- if plugin_source := self._get_active_plugin_source(player):
- if plugin_source.can_play_pause and plugin_source.on_pause:
- await plugin_source.on_pause()
- return
-
- # Redirect to queue controller if it is active
- if active_queue := self.get_active_queue(player):
- await self.mass.player_queues.pause(active_queue.queue_id)
- return
-
- # handle command on player/source directly
- active_source = next((x for x in player.source_list if x.id == player.active_source), None)
- if active_source and not active_source.can_play_pause:
- raise PlayerCommandFailed(
- "The active source (%s) on player %s does not support play/pause",
- active_source.name,
- player.display_name,
- )
- if PlayerFeature.PAUSE not in player.supported_features:
- # if player does not support pause, we need to send stop
- self.logger.debug(
- "Player %s does not support pause, using STOP instead",
- player.display_name,
- )
- await self.cmd_stop(player.player_id)
- return
- # handle command on player directly
- await player.pause()
-
- @api_command("players/cmd/play_pause")
- async def cmd_play_pause(self, player_id: str) -> None:
- """Toggle play/pause on given player.
-
- - player_id: player_id of the player to handle the command.
- """
- player = self._get_player_with_redirect(player_id)
- if player.playback_state == PlaybackState.PLAYING:
- await self.cmd_pause(player.player_id)
- else:
- await self.cmd_play(player.player_id)
-
- @api_command("players/cmd/resume")
- @handle_player_command
- async def cmd_resume(
- self, player_id: str, source: str | None = None, media: PlayerMedia | None = None
- ) -> None:
- """Send RESUME command to given player.
-
- Resume (or restart) playback on the player.
-
- :param player_id: player_id of the player to handle the command.
- :param source: Optional source to resume.
- :param media: Optional media to resume.
- """
- await self._handle_cmd_resume(player_id, source, media)
-
- @api_command("players/cmd/seek")
- async def cmd_seek(self, player_id: str, position: int) -> None:
- """Handle SEEK command for given player.
-
- - player_id: player_id of the player to handle the command.
- - position: position in seconds to seek to in the current playing item.
- """
- player = self._get_player_with_redirect(player_id)
-
- # Check if a plugin source is active with a seek callback
- if plugin_source := self._get_active_plugin_source(player):
- if plugin_source.can_seek and plugin_source.on_seek:
- await plugin_source.on_seek(position)
- return
-
- # Redirect to queue controller if it is active
- if active_queue := self.get_active_queue(player):
- await self.mass.player_queues.seek(active_queue.queue_id, position)
- return
-
- # handle command on player/source directly
- active_source = next((x for x in player.source_list if x.id == player.active_source), None)
- if active_source and not active_source.can_seek:
- raise PlayerCommandFailed(
- "The active source (%s) on player %s does not support seeking",
- active_source.name,
- player.display_name,
- )
- if PlayerFeature.SEEK not in player.supported_features:
- msg = f"Player {player.display_name} does not support seeking"
- raise UnsupportedFeaturedException(msg)
- # handle command on player directly
- await player.seek(position)
-
- @api_command("players/cmd/next")
- async def cmd_next_track(self, player_id: str) -> None:
- """Handle NEXT TRACK command for given player."""
- player = self._get_player_with_redirect(player_id)
- active_source_id = player.active_source or player.player_id
-
- # Check if a plugin source is active with a next callback
- if plugin_source := self._get_active_plugin_source(player):
- if plugin_source.can_next_previous and plugin_source.on_next:
- await plugin_source.on_next()
- return
-
- # Redirect to queue controller if it is active
- if active_queue := self.get_active_queue(player):
- await self.mass.player_queues.next(active_queue.queue_id)
- return
-
- if PlayerFeature.NEXT_PREVIOUS in player.supported_features:
- # player has some other source active and native next/previous support
- active_source = next((x for x in player.source_list if x.id == active_source_id), None)
- if active_source and active_source.can_next_previous:
- await player.next_track()
- return
- msg = "This action is (currently) unavailable for this source."
- raise PlayerCommandFailed(msg)
-
- msg = f"Player {player.display_name} does not support skipping to the next track."
- raise UnsupportedFeaturedException(msg)
-
- @api_command("players/cmd/previous")
- async def cmd_previous_track(self, player_id: str) -> None:
- """Handle PREVIOUS TRACK command for given player."""
- player = self._get_player_with_redirect(player_id)
- active_source_id = player.active_source or player.player_id
-
- # Check if a plugin source is active with a previous callback
- if plugin_source := self._get_active_plugin_source(player):
- if plugin_source.can_next_previous and plugin_source.on_previous:
- await plugin_source.on_previous()
- return
-
- # Redirect to queue controller if it is active
- if active_queue := self.get_active_queue(player):
- await self.mass.player_queues.previous(active_queue.queue_id)
- return
-
- if PlayerFeature.NEXT_PREVIOUS in player.supported_features:
- # player has some other source active and native next/previous support
- active_source = next((x for x in player.source_list if x.id == active_source_id), None)
- if active_source and active_source.can_next_previous:
- await player.previous_track()
- return
- msg = "This action is (currently) unavailable for this source."
- raise PlayerCommandFailed(msg)
-
- msg = f"Player {player.display_name} does not support skipping to the previous track."
- raise UnsupportedFeaturedException(msg)
-
- @api_command("players/cmd/power")
- @handle_player_command
- async def cmd_power(self, player_id: str, powered: bool) -> None:
- """Send POWER command to given player.
-
- :param player_id: player_id of the player to handle the command.
- :param powered: bool if player should be powered on or off.
- """
- await self._handle_cmd_power(player_id, powered)
-
- @api_command("players/cmd/volume_set")
- @handle_player_command
- async def cmd_volume_set(self, player_id: str, volume_level: int) -> None:
- """Send VOLUME_SET command to given player.
-
- :param player_id: player_id of the player to handle the command.
- :param volume_level: volume level (0..100) to set on the player.
- """
- await self._handle_cmd_volume_set(player_id, volume_level)
-
- @api_command("players/cmd/volume_up")
- @handle_player_command
- async def cmd_volume_up(self, player_id: str) -> None:
- """Send VOLUME_UP command to given player.
-
- - player_id: player_id of the player to handle the command.
- """
- if not (player := self.get(player_id)):
- return
- current_volume = player.volume_level or 0
- if current_volume < 5 or current_volume > 95:
- step_size = 1
- elif current_volume < 20 or current_volume > 80:
- step_size = 2
- else:
- step_size = 5
- new_volume = min(100, current_volume + step_size)
- await self.cmd_volume_set(player_id, new_volume)
-
- @api_command("players/cmd/volume_down")
- @handle_player_command
- async def cmd_volume_down(self, player_id: str) -> None:
- """Send VOLUME_DOWN command to given player.
-
- - player_id: player_id of the player to handle the command.
- """
- if not (player := self.get(player_id)):
- return
- current_volume = player.volume_level or 0
- if current_volume < 5 or current_volume > 95:
- step_size = 1
- elif current_volume < 20 or current_volume > 80:
- step_size = 2
- else:
- step_size = 5
- new_volume = max(0, current_volume - step_size)
- await self.cmd_volume_set(player_id, new_volume)
-
- @api_command("players/cmd/group_volume")
- @handle_player_command
- async def cmd_group_volume(
- self,
- player_id: str,
- volume_level: int,
- ) -> None:
- """
- Handle adjusting the overall/group volume to a playergroup (or synced players).
-
- Will set a new (overall) volume level to a group player or syncgroup.
-
- :param group_player: dedicated group player or syncleader to handle the command.
- :param volume_level: volume level (0..100) to set to the group.
- """
- player = self.get(player_id, True)
- assert player is not None # for type checker
- if player.type == PlayerType.GROUP or player.group_members:
- # dedicated group player or sync leader
- await self.set_group_volume(player, volume_level)
- return
- if player.synced_to and (sync_leader := self.get(player.synced_to)):
- # redirect to sync leader
- await self.set_group_volume(sync_leader, volume_level)
- return
- # treat as normal player volume change
- await self.cmd_volume_set(player_id, volume_level)
-
- @api_command("players/cmd/group_volume_up")
- @handle_player_command
- async def cmd_group_volume_up(self, player_id: str) -> None:
- """Send VOLUME_UP command to given playergroup.
-
- - player_id: player_id of the player to handle the command.
- """
- group_player = self.get(player_id, True)
- assert group_player
- cur_volume = group_player.group_volume
- if cur_volume < 5 or cur_volume > 95:
- step_size = 1
- elif cur_volume < 20 or cur_volume > 80:
- step_size = 2
- else:
- step_size = 5
- new_volume = min(100, cur_volume + step_size)
- await self.cmd_group_volume(player_id, new_volume)
-
- @api_command("players/cmd/group_volume_down")
- @handle_player_command
- async def cmd_group_volume_down(self, player_id: str) -> None:
- """Send VOLUME_DOWN command to given playergroup.
-
- - player_id: player_id of the player to handle the command.
- """
- group_player = self.get(player_id, True)
- assert group_player
- cur_volume = group_player.group_volume
- if cur_volume < 5 or cur_volume > 95:
- step_size = 1
- elif cur_volume < 20 or cur_volume > 80:
- step_size = 2
- else:
- step_size = 5
- new_volume = max(0, cur_volume - step_size)
- await self.cmd_group_volume(player_id, new_volume)
-
- @api_command("players/cmd/group_volume_mute")
- @handle_player_command
- async def cmd_group_volume_mute(self, player_id: str, muted: bool) -> None:
- """Send VOLUME_MUTE command to all players in a group.
-
- - player_id: player_id of the group player or sync leader.
- - muted: bool if group should be muted.
- """
- player = self.get(player_id, True)
- assert player is not None # for type checker
- if player.type == PlayerType.GROUP or player.group_members:
- # dedicated group player or sync leader
- coros = []
- for child_player in self.iter_group_members(
- player, only_powered=True, exclude_self=False
- ):
- coros.append(self.cmd_volume_mute(child_player.player_id, muted))
- await asyncio.gather(*coros)
-
- @api_command("players/cmd/volume_mute")
- @handle_player_command
- async def cmd_volume_mute(self, player_id: str, muted: bool) -> None:
- """Send VOLUME_MUTE command to given player.
-
- - player_id: player_id of the player to handle the command.
- - muted: bool if player should be muted.
- """
- player = self.get(player_id, True)
- assert player
-
- # Set/clear mute lock for players in a group
- # This prevents auto-unmute when group volume changes
- is_in_group = bool(player.synced_to or player.group_members)
- if muted and is_in_group:
- player.extra_data[ATTR_MUTE_LOCK] = True
- elif not muted:
- player.extra_data.pop(ATTR_MUTE_LOCK, None)
-
- if player.mute_control == PLAYER_CONTROL_NONE:
- raise UnsupportedFeaturedException(
- f"Player {player.display_name} does not support muting"
- )
- if player.mute_control == PLAYER_CONTROL_NATIVE:
- # player supports mute command natively: forward to player
- async with self._player_throttlers[player_id]:
- await player.volume_mute(muted)
- elif player.mute_control == PLAYER_CONTROL_FAKE:
- # user wants to use fake mute control - so we use volume instead
- self.logger.debug(
- "Using volume for muting for player %s",
- player.display_name,
- )
- if muted:
- player.extra_data[ATTR_PREVIOUS_VOLUME] = player.volume_level
- player.extra_data[ATTR_FAKE_MUTE] = True
- await self._handle_cmd_volume_set(player_id, 0)
- player.update_state()
- else:
- prev_volume = player.extra_data.get(ATTR_PREVIOUS_VOLUME, 1)
- player.extra_data[ATTR_FAKE_MUTE] = False
- player.update_state()
- await self._handle_cmd_volume_set(player_id, prev_volume)
- else:
- # handle external player control
- player_control = self._controls.get(player.mute_control)
- control_name = player_control.name if player_control else player.mute_control
- self.logger.debug("Redirecting mute command to PlayerControl %s", control_name)
- if not player_control or not player_control.supports_mute:
- raise UnsupportedFeaturedException(
- f"Player control {control_name} is not available"
- )
- async with self._player_throttlers[player_id]:
- assert player_control.mute_set is not None
- await player_control.mute_set(muted)
-
- @api_command("players/cmd/play_announcement")
- @handle_player_command(lock=True)
- async def play_announcement(
- self,
- player_id: str,
- url: str,
- pre_announce: bool | None = None,
- volume_level: int | None = None,
- pre_announce_url: str | None = None,
- ) -> None:
- """
- Handle playback of an announcement (url) on given player.
-
- - player_id: player_id of the player to handle the command.
- - url: URL of the announcement to play.
- - pre_announce: optional bool if pre-announce should be used.
- - volume_level: optional volume level to set for the announcement.
- - pre_announce_url: optional custom URL to use for the pre-announce chime.
- """
- player = self.get(player_id, True)
- assert player is not None # for type checking
- if not url.startswith("http"):
- raise PlayerCommandFailed("Only URLs are supported for announcements")
- if (
- pre_announce
- and pre_announce_url
- and not validate_announcement_chime_url(pre_announce_url)
- ):
- raise PlayerCommandFailed("Invalid pre-announce chime URL specified.")
- try:
- # mark announcement_in_progress on player
- player.extra_data[ATTR_ANNOUNCEMENT_IN_PROGRESS] = True
- # determine if the player has native announcements support
- native_announce_support = PlayerFeature.PLAY_ANNOUNCEMENT in player.supported_features
- # determine pre-announce from (group)player config
- if pre_announce is None and "tts" in url:
- conf_pre_announce = self.mass.config.get_raw_player_config_value(
- player_id,
- CONF_ENTRY_TTS_PRE_ANNOUNCE.key,
- CONF_ENTRY_TTS_PRE_ANNOUNCE.default_value,
- )
- pre_announce = cast("bool", conf_pre_announce)
- if pre_announce_url is None:
- if conf_pre_announce_url := self.mass.config.get_raw_player_config_value(
- player_id,
- CONF_PRE_ANNOUNCE_CHIME_URL,
- ):
- # player default custom chime url
- pre_announce_url = cast("str", conf_pre_announce_url)
- else:
- # use global default chime url
- pre_announce_url = ANNOUNCE_ALERT_FILE
- # if player type is group with all members supporting announcements,
- # we forward the request to each individual player
- if player.type == PlayerType.GROUP and (
- all(
- PlayerFeature.PLAY_ANNOUNCEMENT in x.supported_features
- for x in self.iter_group_members(player)
- )
- ):
- # forward the request to each individual player
- async with TaskManager(self.mass) as tg:
- for group_member in player.group_members:
- tg.create_task(
- self.play_announcement(
- group_member,
- url=url,
- pre_announce=pre_announce,
- volume_level=volume_level,
- pre_announce_url=pre_announce_url,
- )
- )
- return
- self.logger.info(
- "Playback announcement to player %s (with pre-announce: %s): %s",
- player.display_name,
- pre_announce,
- url,
- )
- # create a PlayerMedia object for the announcement so
- # we can send a regular play-media call downstream
- announce_data = AnnounceData(
- announcement_url=url,
- pre_announce=bool(pre_announce),
- pre_announce_url=pre_announce_url,
- )
- announcement = PlayerMedia(
- uri=self.mass.streams.get_announcement_url(player_id, announce_data=announce_data),
- media_type=MediaType.ANNOUNCEMENT,
- title="Announcement",
- custom_data=dict(announce_data),
- )
- # handle native announce support
- if native_announce_support:
- announcement_volume = self.get_announcement_volume(player_id, volume_level)
- await player.play_announcement(announcement, announcement_volume)
- return
- # use fallback/default implementation
- await self._play_announcement(player, announcement, volume_level)
- finally:
- player.extra_data[ATTR_ANNOUNCEMENT_IN_PROGRESS] = False
-
- @handle_player_command(lock=True)
- async def play_media(self, player_id: str, media: PlayerMedia) -> None:
- """Handle PLAY MEDIA on given player.
-
- - player_id: player_id of the player to handle the command.
- - media: The Media that needs to be played on the player.
- """
- player = self._get_player_with_redirect(player_id)
- # power on the player if needed
- if player.powered is False and player.power_control != PLAYER_CONTROL_NONE:
- await self._handle_cmd_power(player.player_id, True)
- if media.source_id:
- player.set_active_mass_source(media.source_id)
- await player.play_media(media)
-
- @api_command("players/cmd/select_sound_mode")
- @handle_player_command
- async def select_sound_mode(self, player_id: str, sound_mode: str) -> None:
- """
- Handle SELECT SOUND MODE command on given player.
-
- - player_id: player_id of the player to handle the command
- - sound_mode: The ID of the sound mode that needs to be activated/selected.
- """
- player = self.get(player_id, True)
- assert player is not None # for type checking
-
- if PlayerFeature.SELECT_SOUND_MODE not in player.supported_features:
- raise UnsupportedFeaturedException(
- f"Player {player.display_name} does not support sound mode selection"
- )
-
- prev_sound_mode = player.active_sound_mode
- if sound_mode == prev_sound_mode:
- return
-
- # basic check if sound mode is valid for player
- if not any(x for x in player.sound_mode_list if x.id == sound_mode):
- raise PlayerCommandFailed(
- f"{sound_mode} is an invalid sound_mode for player {player.display_name}"
- )
-
- # forward to player
- await player.select_sound_mode(sound_mode)
-
- @api_command("players/cmd/set_option")
- @handle_player_command
- async def set_option(
- self, player_id: str, option_key: str, option_value: PlayerOptionValueType
- ) -> None:
- """
- Handle SET_OPTION command on given player.
-
- - player_id: player_id of the player to handle the command
- - option_key: The key of the player option that needs to be activated/selected.
- - option_value: The new value of the player option.
- """
- player = self.get(player_id, True)
- assert player is not None # for type checking
-
- if PlayerFeature.OPTIONS not in player.supported_features:
- raise UnsupportedFeaturedException(
- f"Player {player.display_name} does not support set_option"
- )
-
- prev_player_option = next((x for x in player.options if x.key == option_key), None)
- if not prev_player_option:
- return
- if prev_player_option.value == option_value:
- return
-
- if prev_player_option.read_only:
- raise UnsupportedFeaturedException(
- f"Player {player.display_name} option {option_key} is read-only"
- )
-
- # forward to player
- await player.set_option(option_key=option_key, option_value=option_value)
-
- @api_command("players/cmd/select_source")
- @handle_player_command
- async def select_source(self, player_id: str, source: str | None) -> None:
- """
- Handle SELECT SOURCE command on given player.
-
- - player_id: player_id of the player to handle the command.
- - source: The ID of the source that needs to be activated/selected.
- """
- if source is None:
- source = player_id # default to MA queue source
- player = self.get(player_id, True)
- assert player is not None # for type checking
- if player.synced_to or player.active_group:
- raise PlayerCommandFailed(f"Player {player.display_name} is currently grouped")
- # check if player is already playing and source is different
- # in that case we need to stop the player first
- prev_source = player.active_source
- if prev_source and source != prev_source:
- with suppress(PlayerCommandFailed, RuntimeError):
- # just try to stop (regardless of state)
- await self.cmd_stop(player_id)
- await asyncio.sleep(2) # small delay to allow stop to process
- # check if source is a pluginsource
- # in that case the source id is the instance_id of the plugin provider
- if plugin_prov := self.mass.get_provider(source):
- player.set_active_mass_source(source)
- await self._handle_select_plugin_source(player, cast("PluginProvider", plugin_prov))
- return
- # check if source is a mass queue
- # this can be used to restore the queue after a source switch
- if self.mass.player_queues.get(source):
- player.set_active_mass_source(source)
- return
- # basic check if player supports source selection
- if PlayerFeature.SELECT_SOURCE not in player.supported_features:
- raise UnsupportedFeaturedException(
- f"Player {player.display_name} does not support source selection"
- )
- # basic check if source is valid for player
- if not any(x for x in player.source_list if x.id == source):
- raise PlayerCommandFailed(
- f"{source} is an invalid source for player {player.display_name}"
- )
- # forward to player
- await player.select_source(source)
-
- @handle_player_command(lock=True)
- async def enqueue_next_media(self, player_id: str, media: PlayerMedia) -> None:
- """
- Handle enqueuing of a next media item on the player.
-
- :param player_id: player_id of the player to handle the command.
- :param media: The Media that needs to be enqueued on the player.
- :raises UnsupportedFeaturedException: if the player does not support enqueueing.
- :raises PlayerUnavailableError: if the player is not available.
- """
- player = self.get(player_id, raise_unavailable=True)
- assert player is not None # for type checking
- if PlayerFeature.ENQUEUE not in player.supported_features:
- raise UnsupportedFeaturedException(
- f"Player {player.display_name} does not support enqueueing"
- )
- async with self._player_throttlers[player_id]:
- await player.enqueue_next_media(media)
-
- @api_command("players/cmd/set_members")
- async def cmd_set_members(
- self,
- target_player: str,
- player_ids_to_add: list[str] | None = None,
- player_ids_to_remove: list[str] | None = None,
- ) -> None:
- """
- Join/unjoin given player(s) to/from target player.
-
- Will add the given player(s) to the target player (sync leader or group player).
-
- :param target_player: player_id of the syncgroup leader or group player.
- :param player_ids_to_add: List of player_id's to add to the target player.
- :param player_ids_to_remove: List of player_id's to remove from the target player.
-
- :raises UnsupportedFeaturedException: if the target player does not support grouping.
- :raises PlayerUnavailableError: if the target player is not available.
- """
- parent_player: Player | None = self.get(target_player, True)
- assert parent_player is not None # for type checking
- if PlayerFeature.SET_MEMBERS not in parent_player.supported_features:
- msg = f"Player {parent_player.name} does not support group commands"
- raise UnsupportedFeaturedException(msg)
-
- if parent_player.synced_to:
- # guard edge case: player already synced to another player
- raise PlayerCommandFailed(
- f"Player {parent_player.name} is already synced to another player on its own, "
- "you need to ungroup it first before you can join other players to it.",
- )
-
- # filter all player ids on compatibility and availability
- final_player_ids_to_add: list[str] = []
- for child_player_id in player_ids_to_add or []:
- if child_player_id == target_player:
- continue
- if child_player_id in final_player_ids_to_add:
- continue
- if not (child_player := self.get(child_player_id)) or not child_player.available:
- self.logger.warning("Player %s is not available", child_player_id)
- continue
-
- # check if player can be synced/grouped with the target player
- if not (
- child_player_id in parent_player.can_group_with
- or child_player.provider.instance_id in parent_player.can_group_with
- or "*" in parent_player.can_group_with
- ):
- raise UnsupportedFeaturedException(
- f"Player {child_player.name} can not be grouped with {parent_player.name}"
- )
-
- if (
- child_player.synced_to
- and child_player.synced_to == target_player
- and child_player_id in parent_player.group_members
- ):
- continue # already synced to this target
-
- # Check if player is already part of another group and try to automatically ungroup it
- # first. If that fails, power off the group
- if child_player.active_group and child_player.active_group != target_player:
- if (
- other_group := self.get(child_player.active_group)
- ) and PlayerFeature.SET_MEMBERS in other_group.supported_features:
- self.logger.warning(
- "Player %s is already part of another group (%s), "
- "removing from that group first",
- child_player.name,
- child_player.active_group,
- )
- if child_player.player_id in other_group.static_group_members:
- self.logger.warning(
- "Player %s is a static member of group %s: removing is not possible, "
- "powering the group off instead",
- child_player.name,
- child_player.active_group,
- )
- await self._handle_cmd_power(child_player.active_group, False)
- else:
- await other_group.set_members(player_ids_to_remove=[child_player.player_id])
- else:
- self.logger.warning(
- "Player %s is already part of another group (%s), powering it off first",
- child_player.name,
- child_player.active_group,
- )
- await self._handle_cmd_power(child_player.active_group, False)
- elif child_player.synced_to and child_player.synced_to != target_player:
- self.logger.warning(
- "Player %s is already synced to another player, ungrouping first",
- child_player.name,
- )
- await self.cmd_ungroup(child_player.player_id)
-
- # power on the player if needed
- if not child_player.powered and child_player.power_control != PLAYER_CONTROL_NONE:
- await self._handle_cmd_power(child_player.player_id, True)
- # if we reach here, all checks passed
- final_player_ids_to_add.append(child_player_id)
-
- final_player_ids_to_remove: list[str] = []
- if player_ids_to_remove:
- static_members = set(parent_player.static_group_members)
- for child_player_id in player_ids_to_remove:
- if child_player_id == target_player:
- raise UnsupportedFeaturedException(
- f"Cannot remove {parent_player.name} from itself as a member!"
- )
- if child_player_id not in parent_player.group_members:
- continue
- if child_player_id in static_members:
- raise UnsupportedFeaturedException(
- f"Cannot remove {child_player_id} from {parent_player.name} "
- "as it is a static member of this group"
- )
- final_player_ids_to_remove.append(child_player_id)
-
- # forward command to the player after all (base) sanity checks
- async with self._player_throttlers[target_player]:
- await parent_player.set_members(
- player_ids_to_add=final_player_ids_to_add or None,
- player_ids_to_remove=final_player_ids_to_remove or None,
- )
-
- @api_command("players/cmd/group")
- @handle_player_command
- async def cmd_group(self, player_id: str, target_player: str) -> None:
- """Handle GROUP command for given player.
-
- Join/add the given player(id) to the given (leader) player/sync group.
- If the target player itself is already synced to another player, this may fail.
- If the player can not be synced with the given target player, this may fail.
-
- :param player_id: player_id of the player to handle the command.
- :param target_player: player_id of the syncgroup leader or group player.
-
- :raises UnsupportedFeaturedException: if the target player does not support grouping.
- :raises PlayerCommandFailed: if the target player is already synced to another player.
- :raises PlayerUnavailableError: if the target player is not available.
- :raises PlayerCommandFailed: if the player is already grouped to another player.
- """
- await self.cmd_set_members(target_player, player_ids_to_add=[player_id])
-
- @api_command("players/cmd/group_many")
- async def cmd_group_many(self, target_player: str, child_player_ids: list[str]) -> None:
- """
- Join given player(s) to target player.
-
- Will add the given player(s) to the target player (sync leader or group player).
- NOTE: This is a (deprecated) alias for cmd_set_members.
- """
- await self.cmd_set_members(target_player, player_ids_to_add=child_player_ids)
-
- @api_command("players/cmd/ungroup")
- @handle_player_command
- async def cmd_ungroup(self, player_id: str) -> None:
- """Handle UNGROUP command for given player.
-
- Remove the given player from any (sync)groups it currently is synced to.
- If the player is not currently grouped to any other player,
- this will silently be ignored.
-
- NOTE: This is a (deprecated) alias for cmd_set_members.
- """
- if not (player := self.get(player_id)):
- self.logger.warning("Player %s is not available", player_id)
- return
-
- if (
- player.active_group
- and (group_player := self.get(player.active_group))
- and (PlayerFeature.SET_MEMBERS in group_player.supported_features)
- ):
- # the player is part of a (permanent) groupplayer and the user tries to ungroup
- if player_id in group_player.static_group_members:
- raise UnsupportedFeaturedException(
- f"Player {player.name} is a static member of group {group_player.name} "
- "and cannot be removed from that group!"
- )
- await group_player.set_members(player_ids_to_remove=[player_id])
- return
-
- if player.synced_to and (synced_player := self.get(player.synced_to)):
- # player is a sync member
- await synced_player.set_members(player_ids_to_remove=[player_id])
- return
-
- if not (player.synced_to or player.group_members):
- return # nothing to do
-
- if PlayerFeature.SET_MEMBERS not in player.supported_features:
- self.logger.warning("Player %s does not support (un)group commands", player.name)
- return
-
- # forward command to the player once all checks passed
- await player.ungroup()
-
- @api_command("players/cmd/ungroup_many")
- async def cmd_ungroup_many(self, player_ids: list[str]) -> None:
- """Handle UNGROUP command for all the given players."""
- for player_id in list(player_ids):
- await self.cmd_ungroup(player_id)
-
- @api_command("players/create_group_player", required_role="admin")
- async def create_group_player(
- self, provider: str, name: str, members: list[str], dynamic: bool = True
- ) -> Player:
- """
- Create a new (permanent) Group Player.
-
- :param provider: The provider(id) to create the group player for
- :param name: Name of the new group player
- :param members: List of player ids to add to the group
- :param dynamic: Whether the group is dynamic (members can change)
- """
- if not (provider_instance := self.mass.get_provider(provider)):
- raise ProviderUnavailableError(f"Provider {provider} not found")
- provider_instance = cast("PlayerProvider", provider_instance)
- if ProviderFeature.CREATE_GROUP_PLAYER in provider_instance.supported_features:
- return await provider_instance.create_group_player(name, members, dynamic)
- if ProviderFeature.SYNC_PLAYERS in provider_instance.supported_features:
- # provider supports syncing but not dedicated group players
- # create a sync group instead
- return await self._sync_groups.create_group_player(
- provider_instance, name, members, dynamic=dynamic
- )
- raise UnsupportedFeaturedException(
- f"Provider {provider} does not support creating group players"
- )
-
- @api_command("players/remove_group_player", required_role="admin")
- async def remove_group_player(self, player_id: str) -> None:
- """
- Remove a group player.
-
- :param player_id: ID of the group player to remove.
- """
- if not (player := self.get(player_id)):
- # we simply permanently delete the player by wiping its config
- self.mass.config.remove(f"players/{player_id}")
- return
- if player.type != PlayerType.GROUP:
- raise UnsupportedFeaturedException(
- f"Player {player.display_name} is not a group player"
- )
- player.provider.check_feature(ProviderFeature.REMOVE_GROUP_PLAYER)
- await player.provider.remove_group_player(player_id)
-
- @api_command("players/add_currently_playing_to_favorites")
- async def add_currently_playing_to_favorites(self, player_id: str) -> None:
- """
- Add the currently playing item/track on given player to the favorites.
-
- This tries to resolve the currently playing media to an actual media item
- and add that to the favorites in the library.
-
- Will raise an error if the player is not currently playing anything
- or if the currently playing media can not be resolved to a media item.
- """
- player = self._get_player_with_redirect(player_id)
- # handle mass player queue active
- if mass_queue := self.get_active_queue(player):
- if not (current_item := mass_queue.current_item) or not current_item.media_item:
- raise PlayerCommandFailed("No current item to add to favorites")
- # if we're playing a radio station, try to resolve the currently playing track
- if current_item.media_item.media_type == MediaType.RADIO:
- if not (
- (streamdetails := mass_queue.current_item.streamdetails)
- and (stream_title := streamdetails.stream_title)
- and " - " in stream_title
- ):
- # no stream title available, so we can't resolve the track
- # this can happen if the radio station does not provide metadata
- # or there's a commercial break
- # Possible future improvement could be to actually detect the song with a
- # shazam-like approach.
- raise PlayerCommandFailed("No current item to add to favorites")
- # send the streamtitle into a global search query
- search_artist, search_title_title = stream_title.split(" - ", 1)
- # strip off any additional comments in the title (such as from Radio Paradise)
- search_title_title = search_title_title.split(" | ")[0].strip()
- if track := await self.mass.music.get_track_by_name(
- search_title_title, search_artist
- ):
- # we found a track, so add it to the favorites
- await self.mass.music.add_item_to_favorites(track)
- return
- # we could not resolve the track, so raise an error
- raise PlayerCommandFailed("No current item to add to favorites")
-
- # else: any other media item, just add it to the favorites directly
- await self.mass.music.add_item_to_favorites(current_item.media_item)
- return
-
- # guard for player with no active source
- if not player.active_source:
- raise PlayerCommandFailed("Player has no active source")
- # handle other source active using the current_media with uri
- if current_media := player.current_media:
- # prefer the uri of the current media item
- if current_media.uri:
- with suppress(MusicAssistantError):
- await self.mass.music.add_item_to_favorites(current_media.uri)
- return
- # fallback to search based on artist and title (and album if available)
- if current_media.artist and current_media.title:
- if track := await self.mass.music.get_track_by_name(
- current_media.title,
- current_media.artist,
- current_media.album,
- ):
- # we found a track, so add it to the favorites
- await self.mass.music.add_item_to_favorites(track)
- return
- # if we reach here, we could not resolve the currently playing item
- raise PlayerCommandFailed("No current item to add to favorites")
-
- async def register(self, player: Player) -> None:
- """Register a player on the Player Controller."""
- if self.mass.closing:
- return
- player_id = player.player_id
-
- if player_id in self._players:
- msg = f"Player {player_id} is already registered!"
- raise AlreadyRegisteredError(msg)
-
- # ignore disabled players
- if not player.enabled:
- return
-
- # register throttler for this player
- self._player_throttlers[player_id] = Throttler(1, 0.05)
-
- # restore 'fake' power state from cache if available
- cached_value = await self.mass.cache.get(
- key=player.player_id,
- provider=self.domain,
- category=CACHE_CATEGORY_PLAYER_POWER,
- default=False,
- )
- if cached_value is not None:
- player.extra_data[ATTR_FAKE_POWER] = cached_value
-
- # finally actually register it
- self._players[player_id] = player
-
- # ensure we fetch and set the latest/full config for the player
- player_config = await self.mass.config.get_player_config(player_id)
- player.set_config(player_config)
- # call hook after the player is registered and config is set
- await player.on_config_updated()
-
- self.logger.info(
- "Player registered: %s/%s",
- player_id,
- player.display_name,
- )
- # signal event that a player was added
- # update state without signaling event first (to ensure all attributes are set correctly)
- player.update_state(signal_event=False)
- self.mass.signal_event(EventType.PLAYER_ADDED, object_id=player.player_id, data=player)
-
- # register playerqueue for this player
- await self.mass.player_queues.on_player_register(player)
- # always call update to fix special attributes like display name, group volume etc.
- player.update_state()
-
- async def register_or_update(self, player: Player) -> None:
- """Register a new player on the controller or update existing one."""
- if self.mass.closing:
- return
-
- if player.player_id in self._players:
- self._players[player.player_id] = player
- player.update_state()
- return
-
- await self.register(player)
-
- def trigger_player_update(self, player_id: str, force_update: bool = False) -> None:
- """Trigger an update for the given player."""
- if self.mass.closing:
- return
- if not (player := self.get(player_id)):
- return
- self.mass.loop.call_soon(player.update_state, force_update)
-
- async def unregister(self, player_id: str, permanent: bool = False) -> None:
- """
- Unregister a player from the player controller.
-
- Called (by a PlayerProvider) when a player is removed
- or no longer available (for a longer period of time).
-
- This will remove the player from the player controller and
- optionally remove the player's config from the mass config.
-
- - player_id: player_id of the player to unregister.
- - permanent: if True, remove the player permanently by deleting
- the player's config from the mass config. If False, the player config will not be removed,
- allowing for re-registration (with the same config) later.
-
- If the player is not registered, this will silently be ignored.
- """
- player = self._players.get(player_id)
- if player is None:
- return
- await self._cleanup_player_memberships(player_id)
- del self._players[player_id]
- self.mass.player_queues.on_player_remove(player_id, permanent=permanent)
- await player.on_unload()
- if permanent:
- # player permanent removal: delete its config
- # and signal PLAYER_REMOVED event
- self.delete_player_config(player_id)
- self.logger.info("Player removed: %s", player.name)
- self.mass.signal_event(EventType.PLAYER_REMOVED, player_id)
- else:
- # temporary unavailable: mark player as unavailable
- # note: the player will be re-registered later if it comes back online
- player.state.available = False
- self.logger.info("Player unavailable: %s", player.name)
- self.mass.signal_event(
- EventType.PLAYER_UPDATED, object_id=player.player_id, data=player.state
- )
-
- @api_command("players/remove", required_role="admin")
- async def remove(self, player_id: str) -> None:
- """
- Remove a player from a provider.
-
- Can only be called when a PlayerProvider supports ProviderFeature.REMOVE_PLAYER.
- """
- player = self.get(player_id)
- if player is None:
- # we simply permanently delete the player config since it is not registered
- self.delete_player_config(player_id)
- return
- if player.type == PlayerType.GROUP and player_id.startswith(SYNCGROUP_PREFIX):
- await self._sync_groups.remove_group_player(player_id)
- return
- if player.type == PlayerType.GROUP:
- # Handle group player removal
- player.provider.check_feature(ProviderFeature.REMOVE_GROUP_PLAYER)
- await player.provider.remove_group_player(player_id)
- return
- player.provider.check_feature(ProviderFeature.REMOVE_PLAYER)
- await player.provider.remove_player(player_id)
- # check for group memberships that need to be updated
- if player.active_group and (group_player := self.mass.players.get(player.active_group)):
- # try to remove from the group
- with suppress(UnsupportedFeaturedException, PlayerCommandFailed):
- await group_player.set_members(
- player_ids_to_remove=[player_id],
- )
- # We removed the player and can now clean up its config
- self.delete_player_config(player_id)
-
- def delete_player_config(self, player_id: str) -> None:
- """
- Permanently delete a player's configuration.
-
- Should only be called for players that are not registered by the player controller.
- """
- # we simply permanently delete the player by wiping its config
- conf_key = f"{CONF_PLAYERS}/{player_id}"
- dsp_conf_key = f"{CONF_PLAYER_DSP}/{player_id}"
- for key in (conf_key, dsp_conf_key):
- self.mass.config.remove(key)
-
- def signal_player_state_update(
- self,
- player: Player,
- changed_values: dict[str, tuple[Any, Any]],
- force_update: bool = False,
- skip_forward: bool = False,
- ) -> None:
- """
- Signal a player state update.
-
- Called by a Player when its state has changed.
- This will update the player state in the controller and signal the event bus.
- """
- player_id = player.player_id
- if self.mass.closing:
- return
-
- # ignore updates for disabled players
- if not player.enabled and ATTR_ENABLED not in changed_values:
- return
-
- if len(changed_values) == 0 and not force_update:
- # nothing changed
- return
-
- # always signal update to the playerqueue
- self.mass.player_queues.on_player_update(player, changed_values)
-
- if changed_values.keys() == {ATTR_ELAPSED_TIME} and not force_update:
- # ignore small changes in elapsed time
- prev_value = changed_values[ATTR_ELAPSED_TIME][0] or 0
- new_value = changed_values[ATTR_ELAPSED_TIME][1] or 0
- if abs(prev_value - new_value) < 5:
- return
-
- # handle DSP reload of the leader when grouping/ungrouping
- if ATTR_GROUP_MEMBERS in changed_values:
- prev_group_members, new_group_members = changed_values[ATTR_GROUP_MEMBERS]
- self._handle_group_dsp_change(player, prev_group_members or [], new_group_members)
-
- if ATTR_GROUP_MEMBERS in changed_values:
- # Removed group members also need to be updated since they are no longer part
- # of this group and are available for playback again
- prev_group_members = changed_values[ATTR_GROUP_MEMBERS][0] or []
- new_group_members = changed_values[ATTR_GROUP_MEMBERS][1] or []
- removed_members = set(prev_group_members) - set(new_group_members)
- for _removed_player_id in removed_members:
- if removed_player := self.get(_removed_player_id):
- removed_player.update_state()
-
- became_inactive = False
- if ATTR_AVAILABLE in changed_values:
- became_inactive = changed_values[ATTR_AVAILABLE][1] is False
- if not became_inactive and ATTR_ENABLED in changed_values:
- became_inactive = changed_values[ATTR_ENABLED][1] is False
- if became_inactive and (player.active_group or player.synced_to):
- self.mass.create_task(self._cleanup_player_memberships(player.player_id))
-
- # signal player update on the eventbus
- self.mass.signal_event(EventType.PLAYER_UPDATED, object_id=player_id, data=player)
-
- # signal a separate PlayerOptionsUpdated event
- if options := changed_values.get("options"):
- self.mass.signal_event(
- EventType.PLAYER_OPTIONS_UPDATED, object_id=player_id, data=options
- )
-
- if skip_forward and not force_update:
- return
-
- # update/signal group player(s) child's when group updates
- for child_player in self.iter_group_members(player, exclude_self=True):
- child_player.update_state()
- # update/signal group player(s) when child updates
- for group_player in self._get_player_groups(player, powered_only=False):
- group_player.update_state()
- # update/signal manually synced to player when child updates
- if (synced_to := player.synced_to) and (synced_to_player := self.get(synced_to)):
- synced_to_player.update_state()
- # update/signal active groups when a group member updates
- if (active_group := player.active_group) and (
- active_group_player := self.get(active_group)
- ):
- active_group_player.update_state()
-
- async def register_player_control(self, player_control: PlayerControl) -> None:
- """Register a new PlayerControl on the controller."""
- if self.mass.closing:
- return
- control_id = player_control.id
-
- if control_id in self._controls:
- msg = f"PlayerControl {control_id} is already registered"
- raise AlreadyRegisteredError(msg)
-
- # make sure that the playercontrol's provider is set to the instance_id
- prov = self.mass.get_provider(player_control.provider)
- if not prov or prov.instance_id != player_control.provider:
- raise RuntimeError(f"Invalid provider ID given: {player_control.provider}")
-
- self._controls[control_id] = player_control
-
- self.logger.info(
- "PlayerControl registered: %s/%s",
- control_id,
- player_control.name,
- )
-
- # always call update to update any attached players etc.
- self.update_player_control(player_control.id)
-
- async def register_or_update_player_control(self, player_control: PlayerControl) -> None:
- """Register a new playercontrol on the controller or update existing one."""
- if self.mass.closing:
- return
- if player_control.id in self._controls:
- self._controls[player_control.id] = player_control
- self.update_player_control(player_control.id)
- return
- await self.register_player_control(player_control)
-
- def update_player_control(self, control_id: str) -> None:
- """Update playercontrol state."""
- if self.mass.closing:
- return
- # update all players that are using this control
- for player in self._players.values():
- if control_id in (player.power_control, player.volume_control, player.mute_control):
- self.mass.loop.call_soon(player.update_state)
-
- def remove_player_control(self, control_id: str) -> None:
- """Remove a player_control from the player manager."""
- control = self._controls.pop(control_id, None)
- if control is None:
- return
- self._controls.pop(control_id, None)
- self.logger.info("PlayerControl removed: %s", control.name)
-
- def get_player_provider(self, player_id: str) -> PlayerProvider:
- """Return PlayerProvider for given player."""
- player = self._players[player_id]
- assert player # for type checker
- return player.provider
-
- def get_active_queue(self, player: Player) -> PlayerQueue | None:
- """Return the current active queue for a player (if any)."""
- # account for player that is synced (sync child)
- if player.synced_to and player.synced_to != player.player_id:
- if sync_leader := self.get(player.synced_to):
- return self.get_active_queue(sync_leader)
- # handle active group player
- if player.active_group and player.active_group != player.player_id:
- if group_player := self.get(player.active_group):
- return self.get_active_queue(group_player)
- # active_source may be filled queue id (or None)
- active_source = player.active_source or player.player_id
- if active_queue := self.mass.player_queues.get(active_source):
- return active_queue
- return None
-
- async def set_group_volume(self, group_player: Player, volume_level: int) -> None:
- """Handle adjusting the overall/group volume to a playergroup (or synced players)."""
- cur_volume = group_player.state.group_volume
- volume_dif = volume_level - cur_volume
- coros = []
- # handle group volume by only applying the volume to powered members
- for child_player in self.iter_group_members(
- group_player, only_powered=True, exclude_self=False
- ):
- if child_player.volume_control == PLAYER_CONTROL_NONE:
- continue
- cur_child_volume = child_player.volume_level or 0
- new_child_volume = int(cur_child_volume + volume_dif)
- new_child_volume = max(0, new_child_volume)
- new_child_volume = min(100, new_child_volume)
- # Use private method to skip permission check - already validated on group
- # ATTR_MUTE_LOCK on muted players prevents auto-unmute during group volume changes
- coros.append(self._handle_cmd_volume_set(child_player.player_id, new_child_volume))
- await asyncio.gather(*coros)
-
- def get_announcement_volume(self, player_id: str, volume_override: int | None) -> int | None:
- """Get the (player specific) volume for a announcement."""
- volume_strategy = self.mass.config.get_raw_player_config_value(
- player_id,
- CONF_ENTRY_ANNOUNCE_VOLUME_STRATEGY.key,
- CONF_ENTRY_ANNOUNCE_VOLUME_STRATEGY.default_value,
- )
- volume_strategy_volume = self.mass.config.get_raw_player_config_value(
- player_id,
- CONF_ENTRY_ANNOUNCE_VOLUME.key,
- CONF_ENTRY_ANNOUNCE_VOLUME.default_value,
- )
- if volume_strategy == "none":
- return None
- volume_level = volume_override
- if volume_level is None and volume_strategy == "absolute":
- volume_level = int(cast("float", volume_strategy_volume))
- elif volume_level is None and volume_strategy == "relative":
- if (player := self.get(player_id)) and player.volume_level is not None:
- volume_level = int(player.volume_level + cast("float", volume_strategy_volume))
- elif volume_level is None and volume_strategy == "percentual":
- if (player := self.get(player_id)) and player.volume_level is not None:
- percentual = (player.volume_level / 100) * cast("float", volume_strategy_volume)
- volume_level = int(player.volume_level + percentual)
- if volume_level is not None:
- announce_volume_min = cast(
- "float",
- self.mass.config.get_raw_player_config_value(
- player_id,
- CONF_ENTRY_ANNOUNCE_VOLUME_MIN.key,
- CONF_ENTRY_ANNOUNCE_VOLUME_MIN.default_value,
- ),
- )
- volume_level = max(int(announce_volume_min), volume_level)
- announce_volume_max = cast(
- "float",
- self.mass.config.get_raw_player_config_value(
- player_id,
- CONF_ENTRY_ANNOUNCE_VOLUME_MAX.key,
- CONF_ENTRY_ANNOUNCE_VOLUME_MAX.default_value,
- ),
- )
- volume_level = min(int(announce_volume_max), volume_level)
- return None if volume_level is None else int(volume_level)
-
- def iter_group_members(
- self,
- group_player: Player,
- only_powered: bool = False,
- only_playing: bool = False,
- active_only: bool = False,
- exclude_self: bool = True,
- ) -> Iterator[Player]:
- """Get (child) players attached to a group player or syncgroup."""
- for child_id in list(group_player.group_members):
- if child_player := self.get(child_id, False):
- if not child_player.available or not child_player.enabled:
- continue
- if only_powered and child_player.powered is False:
- continue
- if active_only and child_player.active_group != group_player.player_id:
- continue
- if exclude_self and child_player.player_id == group_player.player_id:
- continue
- if only_playing and child_player.playback_state not in (
- PlaybackState.PLAYING,
- PlaybackState.PAUSED,
- ):
- continue
- yield child_player
-
- async def wait_for_state(
- self,
- player: Player,
- wanted_state: PlaybackState,
- timeout: float = 60.0,
- minimal_time: float = 0,
- ) -> None:
- """Wait for the given player to reach the given state."""
- start_timestamp = time.time()
- self.logger.debug(
- "Waiting for player %s to reach state %s", player.display_name, wanted_state
- )
- try:
- async with asyncio.timeout(timeout):
- while player.playback_state != wanted_state:
- await asyncio.sleep(0.1)
-
- except TimeoutError:
- self.logger.debug(
- "Player %s did not reach state %s within the timeout of %s seconds",
- player.display_name,
- wanted_state,
- timeout,
- )
- elapsed_time = round(time.time() - start_timestamp, 2)
- if elapsed_time < minimal_time:
- self.logger.debug(
- "Player %s reached state %s too soon (%s vs %s seconds) - add fallback sleep...",
- player.display_name,
- wanted_state,
- elapsed_time,
- minimal_time,
- )
- await asyncio.sleep(minimal_time - elapsed_time)
- else:
- self.logger.debug(
- "Player %s reached state %s within %s seconds",
- player.display_name,
- wanted_state,
- elapsed_time,
- )
-
- async def on_player_config_change(self, config: PlayerConfig, changed_keys: set[str]) -> None:
- """Call (by config manager) when the configuration of a player changes."""
- player = self.get(config.player_id)
- player_provider = self.mass.get_provider(config.provider)
- player_disabled = ATTR_ENABLED in changed_keys and not config.enabled
- player_enabled = ATTR_ENABLED in changed_keys and config.enabled
-
- if player_disabled and player and player.available:
- # edge case: ensure that the player is powered off if the player gets disabled
- if player.power_control != PLAYER_CONTROL_NONE:
- await self._handle_cmd_power(config.player_id, False)
- elif player.playback_state != PlaybackState.IDLE:
- await self.cmd_stop(config.player_id)
-
- # signal player provider that the player got enabled/disabled
- if (player_enabled or player_disabled) and player_provider:
- assert isinstance(player_provider, PlayerProvider) # for type checking
- if player_disabled:
- player_provider.on_player_disabled(config.player_id)
- elif player_enabled:
- player_provider.on_player_enabled(config.player_id)
- return # enabling/disabling a player will be handled by the provider
-
- if not player:
- return # guard against player not being registered (yet)
-
- resume_queue: PlayerQueue | None = (
- self.mass.player_queues.get(player.active_source) if player.active_source else None
- )
-
- # ensure player state gets updated with any updated config
- player.set_config(config)
- await player.on_config_updated()
- player.update_state()
- # if the PlayerQueue was playing, restart playback
- if resume_queue and resume_queue.state == PlaybackState.PLAYING:
- requires_restart = any(
- v for v in config.values.values() if v.key in changed_keys and v.requires_reload
- )
- if requires_restart:
- # always stop first to ensure the player uses the new config
- await self.mass.player_queues.stop(resume_queue.queue_id)
- self.mass.call_later(
- 1, self.mass.player_queues.resume, resume_queue.queue_id, False
- )
-
- async def on_player_dsp_change(self, player_id: str) -> None:
- """Call (by config manager) when the DSP settings of a player change."""
- # signal player provider that the config changed
- if not (player := self.get(player_id)):
- return
- if player.playback_state == PlaybackState.PLAYING:
- self.logger.info("Restarting playback of Player %s after DSP change", player_id)
- # this will restart the queue stream/playback
- if player.mass_queue_active:
- self.mass.call_later(0, self.mass.player_queues.resume, player.active_source, False)
- return
- # if the player is not using a queue, we need to stop and start playback
- await self.cmd_stop(player_id)
- await self.cmd_play(player_id)
-
- async def _cleanup_player_memberships(self, player_id: str) -> None:
- """Ensure a player is detached from any groups or syncgroups."""
- if not (player := self.get(player_id)):
- return
-
- if (
- player.active_group
- and (group := self.get(player.active_group))
- and group.supports_feature(PlayerFeature.SET_MEMBERS)
- ):
- # Ungroup the player if its part of an active group, this will ignore
- # static_group_members since that is only checked when using cmd_set_members
- with suppress(UnsupportedFeaturedException, PlayerCommandFailed):
- await group.set_members(player_ids_to_remove=[player_id])
- elif player.synced_to and player.supports_feature(PlayerFeature.SET_MEMBERS):
- # Remove the player if it was synced, otherwise it will still show as
- # synced to the other player after it gets registered again
- with suppress(UnsupportedFeaturedException, PlayerCommandFailed):
- await player.ungroup()
-
- def _get_player_with_redirect(self, player_id: str) -> Player:
- """Get player with check if playback related command should be redirected."""
- player = self.get(player_id, True)
- assert player is not None # for type checking
- if player.synced_to and (sync_leader := self.get(player.synced_to)):
- self.logger.info(
- "Player %s is synced to %s and can not accept "
- "playback related commands itself, "
- "redirected the command to the sync leader.",
- player.name,
- sync_leader.name,
- )
- return sync_leader
- if player.active_group and (active_group := self.get(player.active_group)):
- self.logger.info(
- "Player %s is part of a playergroup and can not accept "
- "playback related commands itself, "
- "redirected the command to the group leader.",
- player.name,
- )
- return active_group
- return player
-
- def _get_active_plugin_source(self, player: Player) -> PluginSource | None:
- """Get the active PluginSource for a player if any."""
- # Check if any plugin source is in use by this player
- for plugin_source in self.get_plugin_sources():
- if plugin_source.in_use_by == player.player_id:
- return plugin_source
- if player.active_source == plugin_source.id:
- return plugin_source
- return None
-
- def _get_player_groups(
- self, player: Player, available_only: bool = True, powered_only: bool = False
- ) -> Iterator[Player]:
- """Return all groupplayers the given player belongs to."""
- for _player in self.all(return_unavailable=not available_only):
- if _player.player_id == player.player_id:
- continue
- if _player.type != PlayerType.GROUP:
- continue
- if powered_only and _player.powered is False:
- continue
- if player.player_id in _player.group_members:
- yield _player
-
- async def _play_announcement( # noqa: PLR0915
- self,
- player: Player,
- announcement: PlayerMedia,
- volume_level: int | None = None,
- ) -> None:
- """Handle (default/fallback) implementation of the play announcement feature.
-
- This default implementation will;
- - stop playback of the current media (if needed)
- - power on the player (if needed)
- - raise the volume a bit
- - play the announcement (from given url)
- - wait for the player to finish playing
- - restore the previous power and volume
- - restore playback (if needed and if possible)
-
- This default implementation will only be used if the player
- (provider) has no native support for the PLAY_ANNOUNCEMENT feature.
- """
- prev_state = player.playback_state
- prev_power = player.powered or prev_state != PlaybackState.IDLE
- prev_synced_to = player.synced_to
- prev_group = self.get(player.active_group) if player.active_group else None
- prev_source = player.active_source
- prev_media = player.current_media
- prev_media_name = prev_media.title or prev_media.uri if prev_media else None
- if prev_synced_to:
- # ungroup player if its currently synced
- self.logger.debug(
- "Announcement to player %s - ungrouping player from %s...",
- player.display_name,
- prev_synced_to,
- )
- await self.cmd_ungroup(player.player_id)
- elif prev_group:
- # if the player is part of a group player, we need to ungroup it
- if PlayerFeature.SET_MEMBERS in prev_group.supported_features:
- self.logger.debug(
- "Announcement to player %s - ungrouping from group player %s...",
- player.display_name,
- prev_group.display_name,
- )
- await prev_group.set_members(player_ids_to_remove=[player.player_id])
- else:
- # if the player is part of a group player that does not support ungrouping,
- # we need to power off the groupplayer instead
- self.logger.debug(
- "Announcement to player %s - turning off group player %s...",
- player.display_name,
- prev_group.display_name,
- )
- await self._handle_cmd_power(player.player_id, False)
- elif prev_state in (PlaybackState.PLAYING, PlaybackState.PAUSED):
- # normal/standalone player: stop player if its currently playing
- self.logger.debug(
- "Announcement to player %s - stop existing content (%s)...",
- player.display_name,
- prev_media_name,
- )
- await self.cmd_stop(player.player_id)
- # wait for the player to stop
- await self.wait_for_state(player, PlaybackState.IDLE, 10, 0.4)
- # adjust volume if needed
- # in case of a (sync) group, we need to do this for all child players
- prev_volumes: dict[str, int] = {}
- async with TaskManager(self.mass) as tg:
- for volume_player_id in player.group_members or (player.player_id,):
- if not (volume_player := self.get(volume_player_id)):
- continue
- # catch any players that have a different source active
- if (
- volume_player.active_source
- not in (
- player.active_source,
- volume_player.player_id,
- None,
- )
- and volume_player.playback_state == PlaybackState.PLAYING
- ):
- self.logger.warning(
- "Detected announcement to playergroup %s while group member %s is playing "
- "other content, this may lead to unexpected behavior.",
- player.display_name,
- volume_player.display_name,
- )
- tg.create_task(self.cmd_stop(volume_player.player_id))
- if volume_player.volume_control == PLAYER_CONTROL_NONE:
- continue
- if (prev_volume := volume_player.volume_level) is None:
- continue
- announcement_volume = self.get_announcement_volume(volume_player_id, volume_level)
- if announcement_volume is None:
- continue
- temp_volume = announcement_volume or player.volume_level
- if temp_volume != prev_volume:
- prev_volumes[volume_player_id] = prev_volume
- self.logger.debug(
- "Announcement to player %s - setting temporary volume (%s)...",
- volume_player.display_name,
- announcement_volume,
- )
- tg.create_task(
- self._handle_cmd_volume_set(volume_player.player_id, announcement_volume)
- )
- # play the announcement
- self.logger.debug(
- "Announcement to player %s - playing the announcement on the player...",
- player.display_name,
- )
- await self.play_media(player_id=player.player_id, media=announcement)
- # wait for the player(s) to play
- await self.wait_for_state(player, PlaybackState.PLAYING, 10, minimal_time=0.1)
- # wait for the player to stop playing
- if not announcement.duration:
- if not announcement.custom_data:
- raise ValueError("Announcement missing duration and custom_data")
- media_info = await async_parse_tags(
- announcement.custom_data["announcement_url"], require_duration=True
- )
- announcement.duration = int(media_info.duration) if media_info.duration else None
-
- if announcement.duration is None:
- raise ValueError("Announcement duration could not be determined")
-
- await self.wait_for_state(
- player,
- PlaybackState.IDLE,
- timeout=announcement.duration + 10,
- minimal_time=float(announcement.duration) + 2,
- )
- self.logger.debug(
- "Announcement to player %s - restore previous state...", player.display_name
- )
- # restore volume
- async with TaskManager(self.mass) as tg:
- for volume_player_id, prev_volume in prev_volumes.items():
- tg.create_task(self._handle_cmd_volume_set(volume_player_id, prev_volume))
- await asyncio.sleep(0.2)
- # either power off the player or resume playing
- if not prev_power:
- if player.power_control != PLAYER_CONTROL_NONE:
- self.logger.debug(
- "Announcement to player %s - turning player off again...", player.display_name
- )
- await self._handle_cmd_power(player.player_id, False)
- # nothing to do anymore, player was not previously powered
- # and does not support power control
- return
- if prev_synced_to:
- self.logger.debug(
- "Announcement to player %s - syncing back to %s...",
- player.display_name,
- prev_synced_to,
- )
- await self.cmd_set_members(prev_synced_to, player_ids_to_add=[player.player_id])
- elif prev_group:
- if PlayerFeature.SET_MEMBERS in prev_group.supported_features:
- self.logger.debug(
- "Announcement to player %s - grouping back to group player %s...",
- player.display_name,
- prev_group.display_name,
- )
- await prev_group.set_members(player_ids_to_add=[player.player_id])
- elif prev_state == PlaybackState.PLAYING:
- # if the player is part of a group player that does not support set_members,
- # we need to restart the groupplayer
- self.logger.debug(
- "Announcement to player %s - restarting playback on group player %s...",
- player.display_name,
- prev_group.display_name,
- )
- await self.cmd_play(prev_group.player_id)
- elif prev_state == PlaybackState.PLAYING:
- # player was playing something before the announcement - try to resume that here
- await self._handle_cmd_resume(player.player_id, prev_source, prev_media)
-
- async def _poll_players(self) -> None:
- """Background task that polls players for updates."""
- while True:
- for player in list(self._players.values()):
- # if the player is playing, update elapsed time every tick
- # to ensure the queue has accurate details
- player_playing = player.playback_state == PlaybackState.PLAYING
- if player_playing:
- self.mass.loop.call_soon(
- self.mass.player_queues.on_player_update,
- player,
- {"corrected_elapsed_time": (None, player.corrected_elapsed_time)},
- )
- # Poll player;
- if not player.needs_poll:
- continue
- try:
- last_poll: float = player.extra_data[ATTR_LAST_POLL]
- except KeyError:
- last_poll = 0.0
- if (self.mass.loop.time() - last_poll) < player.poll_interval:
- continue
- player.extra_data[ATTR_LAST_POLL] = self.mass.loop.time()
- try:
- await player.poll()
- except Exception as err:
- self.logger.warning(
- "Error while requesting latest state from player %s: %s",
- player.display_name,
- str(err),
- exc_info=err if self.logger.isEnabledFor(10) else None,
- )
- # Yield to event loop to prevent blocking
- await asyncio.sleep(0)
- await asyncio.sleep(1)
-
- async def _handle_select_plugin_source(
- self, player: Player, plugin_prov: PluginProvider
- ) -> None:
- """Handle playback/select of given plugin source on player."""
- plugin_source = plugin_prov.get_source()
- if plugin_source.in_use_by and plugin_source.in_use_by != player.player_id:
- self.logger.debug(
- "Plugin source %s is already in use by player %s, stopping playback there first.",
- plugin_source.name,
- plugin_source.in_use_by,
- )
- with suppress(PlayerCommandFailed):
- await self.cmd_stop(plugin_source.in_use_by)
- stream_url = await self.mass.streams.get_plugin_source_url(plugin_source, player.player_id)
- plugin_source.in_use_by = player.player_id
- # Call on_select callback if available
- if plugin_source.on_select:
- await plugin_source.on_select()
- await self.play_media(
- player_id=player.player_id,
- media=PlayerMedia(
- uri=stream_url,
- media_type=MediaType.PLUGIN_SOURCE,
- title=plugin_source.name,
- custom_data={
- "provider": plugin_prov.instance_id,
- "source_id": plugin_source.id,
- "player_id": player.player_id,
- "audio_format": plugin_source.audio_format,
- },
- ),
- )
- # trigger player update to ensure the source is set
- self.trigger_player_update(player.player_id)
-
- def _handle_group_dsp_change(
- self, player: Player, prev_group_members: list[str], new_group_members: list[str]
- ) -> None:
- """Handle DSP reload when group membership changes."""
- prev_child_count = len(prev_group_members)
- new_child_count = len(new_group_members)
- is_player_group = player.type == PlayerType.GROUP
-
- # handle special case for PlayerGroups: since there are no leaders,
- # DSP still always work with a single player in the group.
- multi_device_dsp_threshold = 1 if is_player_group else 0
-
- prev_is_multiple_devices = prev_child_count > multi_device_dsp_threshold
- new_is_multiple_devices = new_child_count > multi_device_dsp_threshold
-
- if prev_is_multiple_devices == new_is_multiple_devices:
- return # no change in multi-device status
-
- supports_multi_device_dsp = PlayerFeature.MULTI_DEVICE_DSP in player.supported_features
-
- dsp_enabled: bool
- if player.type == PlayerType.GROUP:
- # Since player groups do not have leaders, we will use the only child
- # that was in the group before and after the change
- if prev_is_multiple_devices:
- if childs := new_group_members:
- # We shrank the group from multiple players to a single player
- # So the now only child will control the DSP
- dsp_enabled = self.mass.config.get_player_dsp_config(childs[0]).enabled
- else:
- dsp_enabled = False
- elif childs := prev_group_members:
- # We grew the group from a single player to multiple players,
- # let's see if the previous single player had DSP enabled
- dsp_enabled = self.mass.config.get_player_dsp_config(childs[0]).enabled
- else:
- dsp_enabled = False
- else:
- dsp_enabled = self.mass.config.get_player_dsp_config(player.player_id).enabled
-
- if dsp_enabled and not supports_multi_device_dsp:
- # We now know that the group configuration has changed so:
- # - multi-device DSP is not supported
- # - we switched from a group with multiple players to a single player
- # (or vice versa)
- # - the leader has DSP enabled
- self.mass.create_task(self.mass.players.on_player_dsp_change(player.player_id))
-
- # Private command handlers (no permission checks)
-
- async def _handle_cmd_resume(
- self, player_id: str, source: str | None = None, media: PlayerMedia | None = None
- ) -> None:
- """
- Handle resume playback command.
-
- Skips the permission checks (internal use only).
- """
- player = self._get_player_with_redirect(player_id)
- source = source or player.active_source
- media = media or player.current_media
- # power on the player if needed
- if not player.powered and player.power_control != PLAYER_CONTROL_NONE:
- await self._handle_cmd_power(player.player_id, True)
- # Redirect to queue controller if it is active
- if active_queue := self.mass.player_queues.get(source or player_id):
- await self.mass.player_queues.resume(active_queue.queue_id)
- return
- # try to handle command on player directly
- # TODO: check if player has an active source with native resume support
- active_source = next((x for x in player.source_list if x.id == source), None)
- if (
- player.playback_state in (PlaybackState.IDLE, PlaybackState.PAUSED)
- and active_source
- and active_source.can_play_pause
- ):
- # player has some other source active and native resume support
- await player.play()
- return
- if active_source and not active_source.passive:
- await self.select_source(player_id, active_source.id)
- return
- if media:
- # try to re-play the current media item
- await player.play_media(media)
- return
- # fallback: just send play command - which will fail if nothing can be played
- await player.play()
-
- async def _handle_cmd_power(self, player_id: str, powered: bool) -> None:
- """
- Handle player power on/off command.
-
- Skips the permission checks (internal use only).
- """
- player = self.get(player_id, True)
- assert player is not None # for type checking
- player_state = player.state
-
- if player_state.powered == powered:
- self.logger.debug(
- "Ignoring power %s command for player %s: already in state %s",
- "ON" if powered else "OFF",
- player_state.name,
- "ON" if player_state.powered else "OFF",
- )
- return # nothing to do
-
- # ungroup player at power off
- player_was_synced = player.synced_to is not None
- if player.type == PlayerType.PLAYER and not powered:
- # ungroup player if it is synced (or is a sync leader itself)
- # NOTE: ungroup will be ignored if the player is not grouped or synced
- await self.cmd_ungroup(player_id)
-
- # always stop player at power off
- if (
- not powered
- and not player_was_synced
- and player.playback_state in (PlaybackState.PLAYING, PlaybackState.PAUSED)
- ):
- await self.cmd_stop(player_id)
- # short sleep: allow the stop command to process and prevent race conditions
- await asyncio.sleep(0.2)
-
- # power off all synced childs when player is a sync leader
- elif not powered and player.type == PlayerType.PLAYER and player.group_members:
- async with TaskManager(self.mass) as tg:
- for member in self.iter_group_members(player, True):
- if member.power_control == PLAYER_CONTROL_NONE:
- continue
- # Use private method to skip permission check for child players
- tg.create_task(self._handle_cmd_power(member.player_id, False))
-
- # handle actual power command
- if player.power_control == PLAYER_CONTROL_NONE:
- raise UnsupportedFeaturedException(
- f"Player {player.display_name} does not support power control"
- )
- if player.power_control == PLAYER_CONTROL_NATIVE:
- # player supports power command natively: forward to player provider
- async with self._player_throttlers[player_id]:
- await player.power(powered)
- elif player.power_control == PLAYER_CONTROL_FAKE:
- # user wants to use fake power control - so we (optimistically) update the state
- # and store the state in the cache
- player.extra_data[ATTR_FAKE_POWER] = powered
- player.update_state() # trigger update of the player state
- await self.mass.cache.set(
- key=player_id,
- data=powered,
- provider=self.domain,
- category=CACHE_CATEGORY_PLAYER_POWER,
- )
- else:
- # handle external player control
- player_control = self._controls.get(player.power_control)
- control_name = player_control.name if player_control else player.power_control
- self.logger.debug("Redirecting power command to PlayerControl %s", control_name)
- if not player_control or not player_control.supports_power:
- raise UnsupportedFeaturedException(
- f"Player control {control_name} is not available"
- )
- if powered:
- assert player_control.power_on is not None # for type checking
- await player_control.power_on()
- else:
- assert player_control.power_off is not None # for type checking
- await player_control.power_off()
-
- # always trigger a state update to update the UI
- player.update_state()
-
- # handle 'auto play on power on' feature
- if (
- not player.active_group
- and powered
- and player.config.get_value(CONF_AUTO_PLAY)
- and player.active_source in (None, player_id)
- and not player.extra_data.get(ATTR_ANNOUNCEMENT_IN_PROGRESS)
- ):
- await self.mass.player_queues.resume(player_id)
-
- async def _handle_cmd_volume_set(self, player_id: str, volume_level: int) -> None:
- """
- Handle Player volume set command.
-
- Skips the permission checks (internal use only).
- """
- player = self.get(player_id, True)
- assert player is not None # for type checker
- if player.type == PlayerType.GROUP:
- # redirect to special group volume control
- await self.cmd_group_volume(player_id, volume_level)
- return
-
- if player.volume_control == PLAYER_CONTROL_NONE:
- raise UnsupportedFeaturedException(
- f"Player {player.display_name} does not support volume control"
- )
-
- # Check if player has mute lock (set when individually muted in a group)
- # If locked, don't auto-unmute when volume changes
- has_mute_lock = player.extra_data.get(ATTR_MUTE_LOCK, False)
- if (
- not has_mute_lock
- and player.mute_control not in (PLAYER_CONTROL_NONE, PLAYER_CONTROL_FAKE)
- and player.volume_muted
- ):
- # if player is muted and not locked, we unmute it first
- # skip this for fake mute since it uses volume to simulate mute
- self.logger.debug(
- "Unmuting player %s before setting volume",
- player.display_name,
- )
- await self.cmd_volume_mute(player_id, False)
-
- # Check if a plugin source is active with a volume callback
- if plugin_source := self._get_active_plugin_source(player):
- if plugin_source.on_volume:
- await plugin_source.on_volume(volume_level)
-
- if player.volume_control == PLAYER_CONTROL_NATIVE:
- # player supports volume command natively: forward to player
- async with self._player_throttlers[player_id]:
- await player.volume_set(volume_level)
- return
- if player.volume_control == PLAYER_CONTROL_FAKE:
- # user wants to use fake volume control - so we (optimistically) update the state
- # and store the state in the cache
- player.extra_data[ATTR_FAKE_VOLUME] = volume_level
- # trigger update
- player.update_state()
- return
- # else: handle external player control
- player_control = self._controls.get(player.volume_control)
- control_name = player_control.name if player_control else player.volume_control
- self.logger.debug("Redirecting volume command to PlayerControl %s", control_name)
- if not player_control or not player_control.supports_volume:
- raise UnsupportedFeaturedException(f"Player control {control_name} is not available")
- async with self._player_throttlers[player_id]:
- assert player_control.volume_set is not None
- await player_control.volume_set(volume_level)
-
- def __iter__(self) -> Iterator[Player]:
- """Iterate over all players."""
- return iter(self._players.values())
--- /dev/null
+"""
+Protocol Linking Mixin for the Player Controller.
+
+Handles all logic for linking protocol players (AirPlay, Chromecast, DLNA) to
+native players or wrapping them in Universal Players.
+
+This module provides the ProtocolLinkingMixin class which is inherited by
+PlayerController to add protocol linking capabilities.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import logging
+from typing import TYPE_CHECKING, cast
+
+from music_assistant_models.enums import (
+ IdentifierType,
+ PlaybackState,
+ PlayerFeature,
+ PlayerType,
+ ProviderType,
+)
+from music_assistant_models.errors import PlayerCommandFailed
+from music_assistant_models.player import OutputProtocol
+
+from music_assistant.constants import (
+ CONF_LINKED_PROTOCOL_PLAYER_IDS,
+ CONF_PLAYERS,
+ CONF_PREFERRED_OUTPUT_PROTOCOL,
+ CONF_PROTOCOL_PARENT_ID,
+ PROTOCOL_PRIORITY,
+ VERBOSE_LOG_LEVEL,
+)
+from music_assistant.helpers.util import is_locally_administered_mac, resolve_real_mac_address
+from music_assistant.models.player import Player
+from music_assistant.providers.universal_player import UniversalPlayer, UniversalPlayerProvider
+
+if TYPE_CHECKING:
+ from collections.abc import Coroutine
+ from typing import Any
+
+ from music_assistant import MusicAssistant
+
+
+class ProtocolLinkingMixin:
+ """
+ Mixin class providing protocol linking functionality for PlayerController.
+
+ Handles the complex logic of:
+ - Matching protocol players to native players via device identifiers
+ - Creating Universal Players for devices without native support
+ - Managing protocol links and their lifecycle
+ - Selecting the best output protocol for playback
+
+ This mixin expects to be mixed with a class that provides:
+ - mass: MusicAssistant instance
+ - _players: dict of registered players
+ - _pending_protocol_evaluations: dict of pending protocol evaluations
+ - logger: logging.Logger instance
+ - all(): method to get all players
+ - get(): method to get a player by ID
+ - unregister(): method to unregister a player
+ """
+
+ # Type hints for attributes provided by the class this mixin is used with
+ if TYPE_CHECKING:
+ mass: MusicAssistant
+ _players: dict[str, Player]
+ _pending_protocol_evaluations: dict[str, asyncio.TimerHandle]
+ logger: logging.Logger
+
+ def all_players( # noqa: D102
+ self,
+ return_unavailable: bool = True,
+ return_disabled: bool = False,
+ provider_filter: str | None = None,
+ return_protocol_players: bool = False,
+ ) -> list[Player]: ...
+
+ def get_player(self, player_id: str) -> Player | None: ... # noqa: D102
+
+ def unregister( # noqa: D102
+ self, player_id: str, permanent: bool = False
+ ) -> Coroutine[Any, Any, None]: ...
+
+ def _is_protocol_player(self, player: Player) -> bool:
+ """
+ Check if a player is a generic protocol player without native support.
+
+ Protocol players have PlayerType.PROTOCOL set by their provider, indicating
+ they are generic streaming endpoints (e.g., AirPlay receiver, Chromecast device)
+ without vendor-specific native support in Music Assistant.
+ """
+ return player.state.type == PlayerType.PROTOCOL
+
+ async def _enrich_player_identifiers(self, player: Player) -> None:
+ """
+ Enrich player identifiers with real MAC address if needed.
+
+ Some devices report different virtual/locally administered MAC addresses per protocol
+ (AirPlay, DLNA, Chromecast may all have different MACs for the same device).
+ This also applies to native players that may report virtual MACs.
+ This method tries to resolve the actual hardware MAC via ARP and adds it as an
+ additional identifier to enable proper matching between protocols and native players.
+ """
+ identifiers = player.device_info.identifiers
+ reported_mac = identifiers.get(IdentifierType.MAC_ADDRESS)
+ ip_address = identifiers.get(IdentifierType.IP_ADDRESS)
+
+ # Skip if no IP available (can't do ARP lookup)
+ if not ip_address:
+ return
+
+ # Skip if MAC already looks like a real one (not locally administered)
+ if reported_mac and not is_locally_administered_mac(reported_mac):
+ return
+
+ # Try to resolve real MAC via ARP
+ real_mac = await resolve_real_mac_address(reported_mac, ip_address)
+ if real_mac and real_mac.upper() != (reported_mac or "").upper():
+ # Replace the virtual MAC with the real MAC address
+ # (add_identifier will store multiple values if the implementation supports it)
+ player.device_info.add_identifier(IdentifierType.MAC_ADDRESS, real_mac)
+ self.logger.debug(
+ "Resolved real MAC for %s: %s -> %s",
+ player.state.name,
+ reported_mac,
+ real_mac,
+ )
+
+ def _evaluate_protocol_links(self, player: Player) -> None:
+ """
+ Evaluate and establish protocol links for a player.
+
+ Called when a player is registered to:
+ 1. If it's from a protocol provider - try to link to a native player.
+ 2. If it's a native player - try to link any existing protocol players.
+ """
+ if player.state.type == PlayerType.PROTOCOL:
+ # Protocol player: try to find a native parent
+ self._try_link_protocol_to_native(player)
+ else:
+ # Native player: try to find protocol players to link
+ self._try_link_protocols_to_native(player)
+
+ def _try_link_protocol_to_native(self, protocol_player: Player) -> None:
+ """Try to link a protocol player to a native player."""
+ protocol_domain = protocol_player.provider.domain
+
+ # Check for cached parent_id from previous session and restore link immediately
+ cached_parent_id = self._get_cached_protocol_parent_id(protocol_player.player_id)
+ if cached_parent_id:
+ protocol_player.set_protocol_parent_id(cached_parent_id)
+ if parent_player := self.get_player(cached_parent_id):
+ if not any(
+ link.output_protocol_id == protocol_player.player_id
+ for link in parent_player.linked_output_protocols
+ ):
+ self._add_protocol_link(parent_player, protocol_player, protocol_domain)
+ protocol_player.update_state()
+ parent_player.update_state()
+ return
+ # Parent not registered yet - skip evaluation (no universal player created)
+ return
+
+ # Look for a matching native player
+ # Protocol players should only link to:
+ # 1. True native players (Sonos, etc.)
+ # 2. Universal players
+ # NOT to other protocol players (they get merged via universal_player)
+ for native_player in self.all_players(return_protocol_players=False):
+ if native_player.player_id == protocol_player.player_id:
+ continue
+ # Skip all protocol players - they should be handled via universal_player
+ if native_player.state.type == PlayerType.PROTOCOL:
+ continue
+
+ # For universal players, check if this protocol player is in its stored list
+ if native_player.provider.domain == "universal_player":
+ if isinstance(native_player, UniversalPlayer):
+ if protocol_player.player_id in native_player._protocol_player_ids:
+ self._add_protocol_link(native_player, protocol_player, protocol_domain)
+ # Copy identifiers from protocol player to universal player
+ # This is important for restored universal players which start
+ # with empty identifiers
+ for conn_type, value in protocol_player.device_info.identifiers.items():
+ native_player.device_info.add_identifier(conn_type, value)
+ # Update model/manufacturer if universal player has generic values
+ self._update_universal_device_info(native_player, protocol_player)
+ # Update availability from protocol players
+ native_player.update_from_protocol_players()
+ # Persist updated data to config (async via task)
+ self._save_universal_player_data(native_player)
+ protocol_player.update_state()
+ native_player.update_state()
+ return
+ continue
+
+ # Check cached protocol IDs first for fast matching on restart
+ cached_ids = self._get_cached_protocol_ids(native_player.player_id)
+ if protocol_player.player_id in cached_ids:
+ self._add_protocol_link(native_player, protocol_player, protocol_domain)
+ protocol_player.update_state()
+ native_player.update_state()
+ return
+
+ # Fallback to identifier matching
+ if self._identifiers_match(native_player, protocol_player, protocol_domain):
+ self._add_protocol_link(native_player, protocol_player, protocol_domain)
+ protocol_player.update_state()
+ native_player.update_state()
+ return
+
+ # No native player found - schedule delayed evaluation to allow other protocols to register
+ if not protocol_player.protocol_parent_id:
+ self._schedule_protocol_evaluation(protocol_player)
+
+ def _schedule_protocol_evaluation(self, protocol_player: Player) -> None:
+ """
+ Schedule a delayed protocol evaluation.
+
+ Delays evaluation to allow other protocol players and native players to register.
+ Uses a longer delay (30s) if this protocol player was previously linked to a native
+ player that hasn't registered yet, giving native providers time to start up.
+ """
+ player_id = protocol_player.player_id
+
+ # Cancel any existing pending evaluation for this player
+ if player_id in self._pending_protocol_evaluations:
+ self._pending_protocol_evaluations[player_id].cancel()
+
+ # Check if this protocol player has a cached parent (was previously linked)
+ cached_parent_id = self._get_cached_protocol_parent_id(player_id)
+ if cached_parent_id and not self.get_player(cached_parent_id):
+ # Previously linked to a native player that hasn't registered yet
+ # Use longer delay to give native providers time to start up
+ delay = 30.0
+ self.logger.debug(
+ "Protocol player %s waiting for cached parent %s (30s delay)",
+ player_id,
+ cached_parent_id,
+ )
+ else:
+ # Standard delay for protocol player discovery
+ # Allows time for other protocols and native players to register
+ delay = 10.0
+
+ # Schedule evaluation after the delay
+ handle = self.mass.loop.call_later(
+ delay,
+ lambda: self.mass.create_task(self._delayed_protocol_evaluation(player_id)),
+ )
+ self._pending_protocol_evaluations[player_id] = handle
+
+ async def _delayed_protocol_evaluation(self, player_id: str) -> None:
+ """
+ Perform delayed protocol evaluation.
+
+ Called after a delay to allow all protocol players for a device to register.
+ Decides whether to create a universal player, join an existing one, or
+ promote a single protocol player directly.
+ """
+ self._pending_protocol_evaluations.pop(player_id, None)
+
+ protocol_player = self.get_player(player_id)
+ if not protocol_player or protocol_player.protocol_parent_id:
+ return
+
+ protocol_domain = protocol_player.provider.domain
+
+ # Check if there's an existing universal player we should join
+ if existing_universal := self._find_matching_universal_player(protocol_player):
+ await self._add_protocol_to_existing_universal(
+ existing_universal, protocol_player, protocol_domain
+ )
+ return
+
+ # Find all protocol players that match this device's identifiers
+ matching_protocols = self._find_matching_protocol_players(protocol_player)
+
+ # Create or update UniversalPlayer for all protocol players
+ await self._create_or_update_universal_player(matching_protocols)
+
+ def _find_matching_protocol_players(self, protocol_player: Player) -> list[Player]:
+ """
+ Find all protocol players that match the same device as the given player.
+
+ Searches through all registered protocol players to find ones that share
+ identifiers (MAC, IP, UUID) with the given player, indicating they represent
+ the same physical device.
+ """
+ matching = [protocol_player]
+
+ for other_player in self.all_players(return_protocol_players=True):
+ if other_player.player_id == protocol_player.player_id:
+ continue
+ if other_player.state.type != PlayerType.PROTOCOL:
+ continue
+ if other_player.protocol_parent_id:
+ continue
+ if self._identifiers_match(protocol_player, other_player):
+ matching.append(other_player)
+
+ return matching
+
+ def _find_matching_universal_player(self, protocol_player: Player) -> Player | None:
+ """Find an existing universal player that matches this protocol player."""
+ for player in self._players.values():
+ if player.provider.domain != "universal_player":
+ continue
+ if self._identifiers_match(protocol_player, player, ""):
+ return player
+ return None
+
+ async def _add_protocol_to_existing_universal(
+ self, universal_player: Player, protocol_player: Player, protocol_domain: str
+ ) -> None:
+ """Add a protocol player to an existing universal player."""
+ self._add_protocol_link(universal_player, protocol_player, protocol_domain)
+
+ if isinstance(universal_player, UniversalPlayer):
+ universal_player.add_protocol_player(protocol_player.player_id)
+ for conn_type, value in protocol_player.device_info.identifiers.items():
+ universal_player.device_info.add_identifier(conn_type, value)
+ # Update model/manufacturer if universal player has generic values
+ self._update_universal_device_info(universal_player, protocol_player)
+ # Update availability from protocol players
+ universal_player.update_from_protocol_players()
+
+ # Persist all player data (protocol IDs, identifiers, device info) to config
+ for provider in self.mass.get_providers(ProviderType.PLAYER):
+ if provider.domain == "universal_player":
+ await cast("UniversalPlayerProvider", provider)._save_player_data(
+ universal_player.player_id, universal_player
+ )
+ break
+
+ protocol_player.update_state()
+ universal_player.update_state()
+
+ def _update_universal_device_info(
+ self, universal_player: UniversalPlayer, protocol_player: Player
+ ) -> None:
+ """
+ Update universal player's device info from protocol player if needed.
+
+ When a universal player is restored from config, it has generic device info
+ (model="Universal Player", manufacturer="Music Assistant"). This method
+ updates those values from a protocol player that has real device info.
+ """
+ # Check if universal player has generic device info (from restore)
+ device_info = universal_player.device_info
+ protocol_info = protocol_player.device_info
+
+ # Update model if universal player has generic value
+ if device_info.model in (None, "Universal Player") and protocol_info.model:
+ device_info.model = protocol_info.model
+
+ # Update manufacturer if universal player has generic value
+ if device_info.manufacturer in (None, "Music Assistant") and protocol_info.manufacturer:
+ device_info.manufacturer = protocol_info.manufacturer
+
+ def _save_universal_player_data(self, universal_player: UniversalPlayer) -> None:
+ """
+ Save universal player data to config via background task.
+
+ This is a helper to persist player data from synchronous code.
+ """
+
+ async def _do_save() -> None:
+ for provider in self.mass.get_providers(ProviderType.PLAYER):
+ if provider.domain == "universal_player":
+ await cast("UniversalPlayerProvider", provider)._save_player_data(
+ universal_player.player_id, universal_player
+ )
+ break
+
+ self.mass.create_task(_do_save())
+
+ def _link_protocols_to_universal(
+ self, universal_player: Player, protocol_players: list[Player]
+ ) -> None:
+ """Link protocol players to a universal player, cleaning up existing links."""
+ for player in protocol_players:
+ # Clean up if linked to another player
+ if player.protocol_parent_id:
+ if parent := self.get_player(player.protocol_parent_id):
+ self._remove_protocol_link(parent, player.player_id)
+ player.set_protocol_parent_id(None)
+ # Link to universal player
+ self._add_protocol_link(universal_player, player, player.provider.domain)
+ player.update_state()
+
+ # Update availability from protocol players
+ if isinstance(universal_player, UniversalPlayer):
+ universal_player.update_from_protocol_players()
+
+ async def _create_or_update_universal_player(self, protocol_players: list[Player]) -> None:
+ """
+ Create or update a UniversalPlayer for a set of protocol players.
+
+ Delegates to the universal player provider which handles orchestration,
+ locking, and player creation. The controller then links the protocols
+ to the universal player.
+ """
+ # Get the universal_player provider
+ universal_provider: UniversalPlayerProvider | None = None
+ for provider in self.mass.get_providers(ProviderType.PLAYER):
+ if provider.domain == "universal_player":
+ universal_provider = cast("UniversalPlayerProvider", provider)
+ break
+
+ if not universal_provider:
+ return
+
+ # Delegate to provider - it handles locking, create/update decision, etc.
+ universal_player = await universal_provider.ensure_universal_player_for_protocols(
+ protocol_players
+ )
+
+ if not universal_player:
+ return
+
+ # Link the protocols to the universal player (controller manages cross-provider state)
+ self._link_protocols_to_universal(universal_player, protocol_players)
+ universal_player.update_state()
+
+ def _try_link_protocols_to_native(self, native_player: Player) -> None:
+ """Try to link protocol players to a native player."""
+ # First, check if there's a universal player for this device that should be replaced
+ self._check_replace_universal_player(native_player)
+
+ # Look for protocol players that should be linked
+ for protocol_player in self.all_players(return_protocol_players=True):
+ if protocol_player.state.type != PlayerType.PROTOCOL:
+ continue
+ if protocol_player.protocol_parent_id:
+ # Already linked to a parent (could be this native player after replacement)
+ continue
+
+ protocol_domain = protocol_player.provider.domain
+ if self._identifiers_match(native_player, protocol_player, protocol_domain):
+ self._add_protocol_link(native_player, protocol_player, protocol_domain)
+ protocol_player.update_state()
+ native_player.update_state()
+
+ # Proactively recover disabled/missing protocols from config
+ # This ensures disabled protocols show up in the UI so they can be re-enabled
+ self._recover_cached_protocol_links(native_player)
+
+ def _check_replace_universal_player(self, native_player: Player) -> None:
+ """Check if a universal player should be replaced by this native player."""
+ # Skip if native_player is itself a universal player (prevent self-replacement)
+ if native_player.provider.domain == "universal_player":
+ return
+
+ # Look for universal players that match this native player
+ for player in list(self._players.values()):
+ if player.provider.domain != "universal_player":
+ continue
+ if not self._identifiers_match(native_player, player, ""):
+ continue
+
+ # Transfer all protocol links from universal player to native player
+ for linked in list(player.linked_output_protocols):
+ if protocol_player := self.get_player(linked.output_protocol_id):
+ protocol_player.set_protocol_parent_id(None)
+ domain = linked.protocol_domain or protocol_player.provider.domain
+ self._add_protocol_link(native_player, protocol_player, domain)
+ protocol_player.update_state()
+
+ player.set_linked_output_protocols([])
+ native_player.update_state()
+
+ # Remove the now-obsolete universal player
+ self.mass.create_task(self.unregister(player.player_id, permanent=True))
+
+ def _add_protocol_link(
+ self, native_player: Player, protocol_player: Player, protocol_domain: str
+ ) -> None:
+ """Add a protocol link from native player to protocol player."""
+ # Remove any existing link for the same protocol domain
+ updated_protocols = [
+ link
+ for link in native_player.linked_output_protocols
+ if link.protocol_domain != protocol_domain
+ ]
+
+ # Get priority for this protocol
+ priority = PROTOCOL_PRIORITY.get(protocol_domain, 100)
+
+ # Add the new link
+ updated_protocols.append(
+ OutputProtocol(
+ output_protocol_id=protocol_player.player_id,
+ name=protocol_player.provider.name,
+ protocol_domain=protocol_domain,
+ priority=priority,
+ )
+ )
+ native_player.set_linked_output_protocols(updated_protocols)
+
+ # Set protocol player's parent
+ protocol_player.set_protocol_parent_id(native_player.player_id)
+
+ # Persist linked protocol IDs to config for fast restart
+ # (only for non-universal players, as universal players handle this themselves)
+ if native_player.provider.domain != "universal_player":
+ self._save_linked_protocol_ids(native_player)
+ # Also save the parent ID on the protocol player for reverse lookup on restart
+ self._save_protocol_parent_id(protocol_player.player_id, native_player.player_id)
+
+ def _remove_protocol_link(
+ self, native_player: Player, protocol_player_id: str, permanent: bool = False
+ ) -> None:
+ """
+ Remove a protocol link.
+
+ :param native_player: The parent player to remove the link from.
+ :param protocol_player_id: The protocol player ID to unlink.
+ :param permanent: If True, also removes the protocol ID from the cached list.
+ Use this when the protocol player config is being deleted. If False,
+ the protocol ID remains in the cache so it can be shown as disabled
+ and re-enabled later.
+ """
+ updated_protocols = [
+ link
+ for link in native_player.linked_output_protocols
+ if link.output_protocol_id != protocol_player_id
+ ]
+ native_player.set_linked_output_protocols(updated_protocols)
+
+ # Clear parent reference on protocol player if it still exists
+ if protocol_player := self.get_player(protocol_player_id):
+ if protocol_player.protocol_parent_id == native_player.player_id:
+ protocol_player.set_protocol_parent_id(None)
+
+ # Update persisted linked protocol IDs and clear cached parent
+ if native_player.provider.domain != "universal_player":
+ if permanent:
+ # Permanently remove from cache (player config is being deleted)
+ self._remove_protocol_id_from_cache(native_player.player_id, protocol_player_id)
+ # Note: we don't call _save_linked_protocol_ids here anymore for non-permanent
+ # removals because the merge approach will preserve the ID in the cache
+ self._clear_protocol_parent_id(protocol_player_id)
+
+ def _save_linked_protocol_ids(self, native_player: Player) -> None:
+ """
+ Save linked protocol IDs to config for persistence across restarts.
+
+ This method merges active protocol IDs with existing cached IDs to preserve
+ disabled protocol players in the cache. This allows disabled protocols to be
+ shown in the UI so they can be re-enabled.
+ """
+ conf_key = (
+ f"{CONF_PLAYERS}/{native_player.player_id}/values/{CONF_LINKED_PROTOCOL_PLAYER_IDS}"
+ )
+ # Get existing cached IDs to preserve disabled protocols
+ existing_ids: list[str] = self.mass.config.get(conf_key, [])
+ # Get currently active protocol IDs
+ active_ids = {link.output_protocol_id for link in native_player.linked_output_protocols}
+ # Merge: keep existing IDs and add any new active ones
+ merged_ids = list(existing_ids)
+ for protocol_id in active_ids:
+ if protocol_id not in merged_ids:
+ merged_ids.append(protocol_id)
+ self.mass.config.set(conf_key, merged_ids)
+
+ def _get_cached_protocol_ids(self, player_id: str) -> list[str]:
+ """Get cached linked protocol IDs from config."""
+ conf_key = f"{CONF_PLAYERS}/{player_id}/values/{CONF_LINKED_PROTOCOL_PLAYER_IDS}"
+ result = self.mass.config.get(conf_key, [])
+ return list(result) if result else []
+
+ def _remove_protocol_id_from_cache(
+ self, parent_player_id: str, protocol_player_id: str
+ ) -> None:
+ """
+ Permanently remove a protocol player ID from the cached linked protocol IDs.
+
+ Use this when a protocol player config is being deleted, not just disabled.
+ """
+ conf_key = f"{CONF_PLAYERS}/{parent_player_id}/values/{CONF_LINKED_PROTOCOL_PLAYER_IDS}"
+ cached_ids: list[str] = self.mass.config.get(conf_key, [])
+ if protocol_player_id in cached_ids:
+ cached_ids.remove(protocol_player_id)
+ self.mass.config.set(conf_key, cached_ids)
+
+ def _save_protocol_parent_id(self, protocol_player_id: str, parent_id: str) -> None:
+ """Save the parent ID for a protocol player for persistence across restarts."""
+ conf_key = f"{CONF_PLAYERS}/{protocol_player_id}/values/{CONF_PROTOCOL_PARENT_ID}"
+ self.mass.config.set(conf_key, parent_id)
+
+ def _get_cached_protocol_parent_id(self, protocol_player_id: str) -> str | None:
+ """Get cached parent ID for a protocol player from config."""
+ conf_key = f"{CONF_PLAYERS}/{protocol_player_id}/values/{CONF_PROTOCOL_PARENT_ID}"
+ result = self.mass.config.get(conf_key, None)
+ return str(result) if result else None
+
+ def _clear_protocol_parent_id(self, protocol_player_id: str) -> None:
+ """Clear the cached parent ID for a protocol player."""
+ conf_key = f"{CONF_PLAYERS}/{protocol_player_id}/values/{CONF_PROTOCOL_PARENT_ID}"
+ self.mass.config.set(conf_key, None)
+
+ def _recover_cached_protocol_links(self, native_player: Player) -> None:
+ """
+ Recover protocol links from config for disabled/missing protocols.
+
+ This ensures that disabled protocols show up in the output_protocols list
+ so they can be re-enabled by the user. It also handles the case where
+ protocol players haven't registered yet during startup.
+ """
+ # Get currently linked protocol IDs
+ linked_protocol_ids = {
+ link.output_protocol_id for link in native_player.linked_output_protocols
+ }
+
+ # Get cached protocol IDs from config (includes protocols that were explicitly linked)
+ cached_protocol_ids = self._get_cached_protocol_ids(native_player.player_id)
+
+ # Also check all protocol players that have protocol_parent_id pointing to this player
+ # (this handles disabled protocols that may not be in linked_protocol_player_ids)
+ all_player_configs = self.mass.config.get(CONF_PLAYERS, {})
+ for protocol_id, protocol_config in all_player_configs.items():
+ # Skip if not a protocol player
+ if protocol_config.get("player_type") != "protocol":
+ continue
+ # Check if this protocol has a parent_id pointing to this native player
+ protocol_values = protocol_config.get("values", {})
+ protocol_parent_id = protocol_values.get(CONF_PROTOCOL_PARENT_ID)
+ if protocol_parent_id == native_player.player_id:
+ if protocol_id not in cached_protocol_ids:
+ cached_protocol_ids.append(protocol_id)
+
+ if not cached_protocol_ids:
+ return
+
+ # Add OutputProtocol entries for any cached protocols that aren't currently linked
+ for protocol_id in cached_protocol_ids:
+ if protocol_id in linked_protocol_ids:
+ continue # Already linked
+
+ # Get protocol player config to determine the protocol domain and availability
+ protocol_config = self.mass.config.get(f"{CONF_PLAYERS}/{protocol_id}")
+ if not protocol_config:
+ continue
+
+ # Determine protocol domain from provider
+ protocol_provider = protocol_config.get("provider")
+ if not protocol_provider:
+ continue
+
+ # Get provider name for display
+ provider_name = "Protocol" # Default fallback
+ for provider in self.mass.get_providers(ProviderType.PLAYER):
+ if provider.domain == protocol_provider:
+ provider_name = provider.name
+ break
+
+ # Get priority for this protocol
+ priority = PROTOCOL_PRIORITY.get(protocol_provider, 100)
+
+ # Check if protocol player is available (registered)
+ protocol_player = self.get_player(protocol_id)
+ is_available = protocol_player is not None and protocol_player.available
+
+ # Add the OutputProtocol entry
+ native_player.linked_output_protocols.append(
+ OutputProtocol(
+ output_protocol_id=protocol_id,
+ name=provider_name,
+ protocol_domain=protocol_provider,
+ priority=priority,
+ is_native=False,
+ available=is_available,
+ )
+ )
+ self.logger.debug(
+ "Recovered cached protocol link %s -> %s (available: %s)",
+ native_player.player_id,
+ protocol_id,
+ is_available,
+ )
+
+ def _cleanup_protocol_links(self, player: Player) -> None:
+ """Clean up protocol links when a player is permanently removed."""
+ if player.state.type == PlayerType.PROTOCOL:
+ # Protocol player being removed: remove link from parent
+ if parent_id := player.protocol_parent_id:
+ if parent_player := self.get_player(parent_id):
+ # Use permanent=True to also remove from cached protocol IDs
+ self._remove_protocol_link(parent_player, player.player_id, permanent=True)
+ if (
+ parent_player.provider.domain == "universal_player"
+ and len(parent_player.linked_output_protocols) == 0
+ ):
+ # No protocols left - remove universal player
+ self.logger.info(
+ "Universal player %s has no protocols left, removing",
+ parent_id,
+ )
+ self.mass.create_task(
+ self.mass.players.unregister(parent_id, permanent=True)
+ )
+ else:
+ parent_player.update_state()
+ else:
+ # Native player being removed: schedule protocol evaluation for linked protocols
+ # so they can be assigned to a universal player
+ for linked in player.linked_output_protocols:
+ if protocol_player := self.get_player(linked.output_protocol_id):
+ protocol_player.set_protocol_parent_id(None)
+ protocol_player.update_state()
+ self.logger.debug(
+ "Native player %s removed - scheduling evaluation for %s",
+ player.player_id,
+ protocol_player.player_id,
+ )
+ self._schedule_protocol_evaluation(protocol_player)
+
+ def _identifiers_match(
+ self, player_a: Player, player_b: Player, protocol_domain: str = ""
+ ) -> bool:
+ """
+ Check if identifiers match between two players.
+
+ Matching is done by comparing connection identifiers (MAC, serial, UUID).
+ IP address is used as a fallback for protocol players only, because some
+ devices report different virtual MAC addresses per protocol (e.g., DLNA vs
+ AirPlay vs Chromecast may all have different MACs for the same device).
+ """
+ identifiers_a = player_a.device_info.identifiers
+ identifiers_b = player_b.device_info.identifiers
+
+ # Check identifiers in order of reliability
+ # MAC_ADDRESS > SERIAL_NUMBER > UUID
+ for conn_type in (
+ IdentifierType.MAC_ADDRESS,
+ IdentifierType.SERIAL_NUMBER,
+ IdentifierType.UUID,
+ ):
+ val_a = identifiers_a.get(conn_type)
+ val_b = identifiers_b.get(conn_type)
+
+ if not val_a or not val_b:
+ continue
+
+ # Normalize values for comparison
+ val_a_norm = val_a.lower().replace(":", "").replace("-", "")
+ val_b_norm = val_b.lower().replace(":", "").replace("-", "")
+
+ # Direct match
+ if val_a_norm == val_b_norm:
+ return True
+
+ # Special case: Sonos UUID matching with DLNA _MR suffix
+ # Sonos uses RINCON_xxx, DLNA uses RINCON_xxx_MR for Media Renderer
+ if conn_type == IdentifierType.UUID:
+ if val_b_norm.endswith("_mr") and val_b_norm[:-3] == val_a_norm:
+ return True
+ if val_a_norm.endswith("_mr") and val_a_norm[:-3] == val_b_norm:
+ return True
+
+ # Fallback: IP address matching for protocol players only
+ # Some devices report different virtual MAC addresses per protocol,
+ # but the IP address remains the same. Only use this for protocol-to-protocol
+ # or protocol-to-universal matching to avoid false positives.
+ if self._can_use_ip_matching(player_a, player_b):
+ ip_a = identifiers_a.get(IdentifierType.IP_ADDRESS)
+ ip_b = identifiers_b.get(IdentifierType.IP_ADDRESS)
+ if ip_a and ip_b and ip_a == ip_b:
+ return True
+
+ return False
+
+ def _can_use_ip_matching(self, player_a: Player, player_b: Player) -> bool:
+ """
+ Check if IP address matching can be used between two players.
+
+ IP matching is only allowed when at least one player is a protocol player
+ or universal player, to avoid false positives between unrelated devices.
+ """
+ # Check if at least one is a protocol player or universal player
+ a_is_protocol = (
+ player_a.type == PlayerType.PROTOCOL or player_a.provider.domain == "universal_player"
+ )
+ b_is_protocol = (
+ player_b.type == PlayerType.PROTOCOL or player_b.provider.domain == "universal_player"
+ )
+ return a_is_protocol or b_is_protocol
+
+ def _select_best_output_protocol(self, player: Player) -> tuple[Player, OutputProtocol | None]:
+ """
+ Select the best available output protocol for a player.
+
+ Selection priority:
+ 1. Output protocol that is currently grouped/synced with other players.
+ 2. User's preferred output protocol (from player settings).
+ 3. Native playback (if player supports PLAY_MEDIA).
+ 4. Best available protocol by priority.
+
+ Returns tuple of (target_player, output_protocol).
+ output_protocol is None when using native playback.
+ """
+ self.logger.log(
+ VERBOSE_LOG_LEVEL,
+ "Selecting output protocol for %s",
+ player.state.name,
+ )
+
+ # 1. Check if any output protocol is currently grouped
+ for linked in player.linked_output_protocols:
+ if protocol_player := self.get_player(linked.output_protocol_id):
+ if protocol_player.available and self._is_protocol_grouped(protocol_player):
+ self.logger.log(
+ VERBOSE_LOG_LEVEL,
+ "Selected protocol for %s: %s (grouped)",
+ player.state.name,
+ protocol_player.state.name,
+ )
+ return protocol_player, linked
+
+ # 2. Check for user's preferred output protocol
+ preferred = self.mass.config.get_raw_player_config_value(
+ player.player_id, CONF_PREFERRED_OUTPUT_PROTOCOL, "auto"
+ )
+ if preferred and preferred != "auto":
+ if preferred == "native":
+ if PlayerFeature.PLAY_MEDIA in player.supported_features:
+ self.logger.log(
+ VERBOSE_LOG_LEVEL,
+ "Selected protocol for %s: native (user preference)",
+ player.state.name,
+ )
+ return player, None
+ else:
+ for linked in player.linked_output_protocols:
+ if linked.output_protocol_id == preferred:
+ if protocol_player := self.get_player(linked.output_protocol_id):
+ if protocol_player.available:
+ self.logger.log(
+ VERBOSE_LOG_LEVEL,
+ "Selected protocol for %s: %s (user preference)",
+ player.state.name,
+ protocol_player.state.name,
+ )
+ return protocol_player, linked
+ break
+
+ # 3. Use native playback if available
+ if PlayerFeature.PLAY_MEDIA in player.supported_features:
+ self.logger.log(
+ VERBOSE_LOG_LEVEL, "Selected protocol for %s: native", player.state.name
+ )
+ return player, None
+
+ # 4. Fall back to best protocol by priority
+ for linked in sorted(player.linked_output_protocols, key=lambda x: x.priority):
+ if protocol_player := self.get_player(linked.output_protocol_id):
+ if protocol_player.available:
+ self.logger.log(
+ VERBOSE_LOG_LEVEL,
+ "Selected protocol for %s: %s (priority-based)",
+ player.state.name,
+ protocol_player.state.name,
+ )
+ return protocol_player, linked
+
+ raise PlayerCommandFailed(f"Player {player.state.name} has no available output protocols")
+
+ def _get_control_target(
+ self,
+ player: Player,
+ required_feature: PlayerFeature,
+ require_active: bool = False,
+ allow_native: bool = True,
+ ) -> Player | None:
+ """
+ Get the best player(protocol) to send control commands to.
+
+ Prefers the active output protocol, otherwise uses the first available
+ protocol player that supports the needed feature.
+ """
+ # If we have an active protocol, use that
+ if (
+ player.active_output_protocol
+ and player.active_output_protocol != "native"
+ and (protocol_player := self.mass.players.get_player(player.active_output_protocol))
+ and required_feature in protocol_player.supported_features
+ ):
+ return protocol_player
+
+ # if the player natively supports the required feature, use that
+ if allow_native and required_feature in player.supported_features:
+ return player
+
+ # If require_active is set, and no active protocol found, return None
+ if require_active:
+ return None
+
+ # Otherwise, use the first available linked protocol
+ for linked in player.linked_output_protocols:
+ if (
+ (protocol_player := self.mass.players.get_player(linked.output_protocol_id))
+ and protocol_player.available
+ and required_feature in protocol_player.supported_features
+ ):
+ return protocol_player
+
+ return None
+
+ def _is_protocol_grouped(self, protocol_player: Player) -> bool:
+ """
+ Check if a protocol player is currently grouped/synced with other players.
+
+ Used to prefer protocols that are actively participating in a group,
+ ensuring consistent playback across grouped players.
+ """
+ is_grouped = bool(
+ protocol_player.state.synced_to
+ or (
+ protocol_player.state.group_members and len(protocol_player.state.group_members) > 1
+ )
+ or protocol_player.state.active_group
+ )
+ if is_grouped:
+ self.logger.log(
+ VERBOSE_LOG_LEVEL,
+ "Protocol player %s is grouped",
+ protocol_player.state.name,
+ )
+ return is_grouped
+
+ def _translate_members_to_remove_for_protocols(
+ self,
+ parent_player: Player,
+ player_ids: list[str],
+ parent_protocol_player: Player | None,
+ parent_protocol_domain: str | None,
+ ) -> tuple[list[str], list[str]]:
+ """
+ Translate member IDs to remove into protocol and native lists.
+
+ :param parent_player: The parent player to remove members from.
+ :param player_ids: List of visible player IDs to remove.
+ :param parent_protocol_player: The parent's protocol player if available.
+ :param parent_protocol_domain: The parent's protocol domain if available.
+ """
+ self.logger.debug(
+ "Translating members to remove for %s: player_ids=%s, parent_protocol_domain=%s",
+ parent_player.state.name,
+ player_ids,
+ parent_protocol_domain,
+ )
+ protocol_members: list[str] = []
+ native_members: list[str] = []
+
+ for child_player_id in player_ids:
+ child_player = self.get_player(child_player_id)
+ if not child_player:
+ continue
+
+ # Check if this member is in the parent's group via protocol
+ if parent_protocol_domain and parent_protocol_player:
+ child_protocol = child_player.get_linked_protocol(parent_protocol_domain)
+ if (
+ child_protocol
+ and child_protocol.output_protocol_id in parent_protocol_player.group_members
+ ):
+ self.logger.debug(
+ "Translating removal: %s -> protocol %s",
+ child_player_id,
+ child_protocol.output_protocol_id,
+ )
+ protocol_members.append(child_protocol.output_protocol_id)
+ continue
+
+ native_members.append(child_player_id)
+
+ return protocol_members, native_members
+
+ def _filter_protocol_members(self, member_ids: list[str], protocol_player: Player) -> list[str]:
+ """Filter member IDs to only include protocol players from the same domain."""
+ return [
+ pid
+ for pid in member_ids
+ if (p := self.get_player(pid))
+ and p.type == PlayerType.PROTOCOL
+ and p.provider.domain == protocol_player.provider.domain
+ ]
+
+ def _filter_native_members(self, member_ids: list[str], parent_player: Player) -> list[str]:
+ """Filter member IDs to only include players compatible with the parent."""
+ return [
+ pid
+ for pid in member_ids
+ if (p := self.get_player(pid))
+ and (
+ p.provider.instance_id == parent_player.provider.instance_id
+ or pid in parent_player._attr_can_group_with
+ or p.provider.instance_id in parent_player._attr_can_group_with
+ )
+ ]
+
+ def _try_child_preferred_protocol(
+ self,
+ child_player: Player,
+ parent_player: Player,
+ ) -> tuple[str | None, str | None]:
+ """
+ Try to use child's preferred output protocol for grouping.
+
+ Returns tuple of (child_protocol_id, protocol_domain) or (None, None).
+ """
+ child_preferred = self.mass.config.get_raw_player_config_value(
+ child_player.player_id, CONF_PREFERRED_OUTPUT_PROTOCOL, "auto"
+ )
+ if not child_preferred or child_preferred in {"auto", "native"}:
+ return None, None
+
+ # Find child's preferred protocol in linked protocols
+ child_protocol = None
+ for linked in child_player.linked_output_protocols:
+ if linked.output_protocol_id == child_preferred:
+ child_protocol = linked
+ break
+
+ if not child_protocol or not child_protocol.available:
+ return None, None
+
+ # Check if parent supports this protocol
+ parent_protocol = parent_player.get_linked_protocol(child_protocol.protocol_domain)
+ if not parent_protocol or not parent_protocol.available:
+ return None, None
+
+ # Check if this protocol supports set_members
+ protocol_player = self.get_player(parent_protocol.output_protocol_id)
+ if (
+ not protocol_player
+ or PlayerFeature.SET_MEMBERS not in protocol_player.state.supported_features
+ ):
+ return None, None
+
+ return child_protocol.output_protocol_id, child_protocol.protocol_domain
+
+ def _can_use_native_grouping(
+ self,
+ child_player: Player,
+ parent_player: Player,
+ parent_supports_native: bool,
+ ) -> bool:
+ """Check if child can be grouped with parent using native grouping."""
+ if not parent_supports_native:
+ return False
+ return (
+ child_player.provider.instance_id == parent_player.provider.instance_id
+ or child_player.player_id in parent_player._attr_can_group_with
+ or child_player.provider.instance_id in parent_player._attr_can_group_with
+ )
+
+ def _try_find_common_protocol(
+ self, child_player: Player, parent_player: Player
+ ) -> tuple[OutputProtocol | None, OutputProtocol | None]:
+ """
+ Find common protocol that supports set_members.
+
+ Returns tuple of (parent_protocol, child_protocol) or (None, None).
+ """
+ for parent_output_protocol in parent_player.output_protocols:
+ if not parent_output_protocol.available:
+ continue
+ child_protocol = child_player.get_linked_protocol(
+ parent_output_protocol.protocol_domain
+ )
+ if not child_protocol or not child_protocol.available:
+ continue
+ protocol_player = self.get_player(parent_output_protocol.output_protocol_id)
+ if (
+ protocol_player
+ and PlayerFeature.SET_MEMBERS in protocol_player.state.supported_features
+ ):
+ return parent_output_protocol, child_protocol
+ return None, None
+
+ def _translate_members_for_protocols(
+ self,
+ parent_player: Player,
+ player_ids: list[str],
+ parent_protocol_player: Player | None,
+ parent_protocol_domain: str | None,
+ ) -> tuple[list[str], list[str], Player | None, str | None]:
+ """
+ Translate member IDs to protocol or native IDs.
+
+ Selection priority when grouping:
+ 1. Try child's preferred output protocol (from player settings)
+ 2. Try native grouping (if parent and child are compatible)
+ 3. Try parent's active output protocol (if any and child supports it)
+ 4. Search for common protocol that supports set_members
+ 5. Log warning if no option works
+
+ Returns tuple of (protocol_members, native_members, protocol_player, protocol_domain).
+ """
+ protocol_members: list[str] = []
+ native_members: list[str] = []
+ parent_supports_native_grouping = (
+ PlayerFeature.SET_MEMBERS in parent_player.supported_features
+ )
+
+ self.logger.log(
+ VERBOSE_LOG_LEVEL,
+ "Translating members for %s: parent_supports_native=%s, parent_protocol=%s (%s)",
+ parent_player.state.name,
+ parent_supports_native_grouping,
+ parent_protocol_player.state.name if parent_protocol_player else "none",
+ parent_protocol_domain or "none",
+ )
+
+ for child_player_id in player_ids:
+ child_player = self.get_player(child_player_id)
+ if not child_player:
+ continue
+
+ self.logger.log(
+ VERBOSE_LOG_LEVEL,
+ "Processing child %s (type=%s, protocols=%s)",
+ child_player.state.name,
+ child_player.state.type,
+ [p.protocol_domain for p in child_player.output_protocols],
+ )
+
+ # Priority 1: Try child's preferred output protocol
+ # (only if no active protocol or if it matches the active protocol)
+ child_protocol_id, protocol_domain = self._try_child_preferred_protocol(
+ child_player, parent_player
+ )
+ if (
+ child_protocol_id
+ and protocol_domain
+ and (not parent_protocol_domain or protocol_domain == parent_protocol_domain)
+ ):
+ if not parent_protocol_player or parent_protocol_domain != protocol_domain:
+ parent_protocol = parent_player.get_linked_protocol(protocol_domain)
+ if parent_protocol:
+ parent_protocol_player = self.get_player(parent_protocol.output_protocol_id)
+ parent_protocol_domain = protocol_domain
+ protocol_members.append(child_protocol_id)
+ self.logger.log(
+ VERBOSE_LOG_LEVEL,
+ "Using child's preferred protocol %s for %s",
+ protocol_domain,
+ child_player.state.name,
+ )
+ continue
+
+ # Priority 2: Try native grouping
+ if self._can_use_native_grouping(
+ child_player, parent_player, parent_supports_native_grouping
+ ):
+ native_members.append(child_player_id)
+ self.logger.log(
+ VERBOSE_LOG_LEVEL,
+ "Using native grouping for %s",
+ child_player.state.name,
+ )
+ continue
+
+ # Priority 3: Try parent's active output protocol (if it supports SET_MEMBERS)
+ if parent_protocol_domain and parent_protocol_player:
+ # Verify the active protocol supports SET_MEMBERS
+ if PlayerFeature.SET_MEMBERS in parent_protocol_player.state.supported_features:
+ child_protocol = child_player.get_linked_protocol(parent_protocol_domain)
+ if child_protocol and child_protocol.available:
+ protocol_members.append(child_protocol.output_protocol_id)
+ self.logger.log(
+ VERBOSE_LOG_LEVEL,
+ "Using parent's active protocol %s for %s",
+ parent_protocol_domain,
+ child_player.state.name,
+ )
+ continue
+ else:
+ self.logger.log(
+ VERBOSE_LOG_LEVEL,
+ "Parent's active protocol %s does not support SET_MEMBERS, "
+ "will search for alternative",
+ parent_protocol_domain,
+ )
+ # Clear the parent protocol so Priority 4 can select a new one
+ parent_protocol_player = None
+ parent_protocol_domain = None
+
+ # Priority 4: Search for common protocol that supports set_members
+ parent_protocol, child_protocol = self._try_find_common_protocol(
+ child_player, parent_player
+ )
+ if parent_protocol and child_protocol:
+ if (
+ not parent_protocol_player
+ or parent_protocol_domain != parent_protocol.protocol_domain
+ ):
+ parent_protocol_player = self.get_player(parent_protocol.output_protocol_id)
+ if parent_protocol_player:
+ parent_protocol_domain = parent_protocol_player.provider.domain
+ protocol_members.append(child_protocol.output_protocol_id)
+ self.logger.log(
+ VERBOSE_LOG_LEVEL,
+ "Selected common protocol %s for grouping %s with %s",
+ parent_protocol.protocol_domain,
+ child_player.state.name,
+ parent_player.state.name,
+ )
+ continue
+
+ # Priority 5: No option worked - log warning
+ self.logger.warning(
+ "Cannot group %s with %s: no compatible grouping method found "
+ "(tried: child preferred protocol, native grouping, "
+ "parent active protocol, common protocols)",
+ child_player.state.name,
+ parent_player.state.name,
+ )
+
+ return protocol_members, native_members, parent_protocol_player, parent_protocol_domain
+
+ async def _forward_protocol_set_members(
+ self,
+ parent_player: Player,
+ parent_protocol_player: Player,
+ protocol_members_to_add: list[str],
+ protocol_members_to_remove: list[str],
+ ) -> None:
+ """
+ Forward protocol members to protocol player's set_members and manage active output protocol.
+
+ :param parent_player: The parent player (native/universal).
+ :param parent_protocol_player: The protocol player to forward commands to.
+ :param protocol_members_to_add: Protocol player IDs to add.
+ :param protocol_members_to_remove: Protocol player IDs to remove.
+ """
+ filtered_protocol_add = self._filter_protocol_members(
+ protocol_members_to_add, parent_protocol_player
+ )
+ filtered_protocol_remove = self._filter_protocol_members(
+ protocol_members_to_remove, parent_protocol_player
+ )
+ self.logger.debug(
+ "Protocol grouping on %s: filtered_add=%s, filtered_remove=%s",
+ parent_protocol_player.state.name,
+ filtered_protocol_add,
+ filtered_protocol_remove,
+ )
+
+ if not filtered_protocol_add and not filtered_protocol_remove:
+ return
+
+ # Safety check: verify protocol player supports SET_MEMBERS
+ if PlayerFeature.SET_MEMBERS not in parent_protocol_player.state.supported_features:
+ self.logger.error(
+ "Protocol player %s does not support SET_MEMBERS, cannot perform grouping. "
+ "This should have been caught earlier in the flow.",
+ parent_protocol_player.state.name,
+ )
+ return
+
+ self.logger.debug(
+ "Calling set_members on protocol player %s with add=%s, remove=%s",
+ parent_protocol_player.state.name,
+ filtered_protocol_add,
+ filtered_protocol_remove,
+ )
+ await parent_protocol_player.set_members(
+ player_ids_to_add=filtered_protocol_add or None,
+ player_ids_to_remove=filtered_protocol_remove or None,
+ )
+
+ # If we added members via this protocol, set it as the active output protocol
+ # This ensures playback will be restarted on the correct protocol if needed
+ if (
+ filtered_protocol_add
+ and parent_player.active_output_protocol != parent_protocol_player.player_id
+ ):
+ self.logger.debug(
+ "Setting active output protocol to %s after grouping members",
+ parent_protocol_player.player_id,
+ )
+ parent_player.set_active_output_protocol(parent_protocol_player.player_id)
+ self.logger.debug(
+ "After set_members, protocol player %s state: group_members=%s, synced_to=%s",
+ parent_protocol_player.state.name,
+ parent_protocol_player.group_members,
+ parent_protocol_player.synced_to,
+ )
+
+ # Clear active protocol if all protocol members were removed
+ if (
+ filtered_protocol_remove
+ and not filtered_protocol_add
+ and parent_protocol_player.player_id == parent_player.active_output_protocol
+ ):
+ # Check group_members count to see if we should clear
+ members_count = len(parent_protocol_player.group_members)
+ self.logger.debug(
+ "Checking if should clear active protocol on %s: "
+ "protocol_members_count=%s, removing=%s",
+ parent_player.state.name,
+ members_count,
+ filtered_protocol_remove,
+ )
+ if members_count <= 1 and parent_player.state.playback_state == PlaybackState.IDLE:
+ parent_player.set_active_output_protocol(None)
+
+ # Clear active output protocol on removed child players
+ if filtered_protocol_remove:
+ for child_protocol_id in filtered_protocol_remove:
+ if child_protocol := self.get_player(child_protocol_id):
+ if child_protocol.protocol_parent_id:
+ if child_player := self.get_player(child_protocol.protocol_parent_id):
+ if child_player.active_output_protocol == child_protocol_id:
+ child_player.set_active_output_protocol(None)
+++ /dev/null
-"""
-Controller for (provider specific) SyncGroup players.
-
-A SyncGroup player is a virtual player that automatically groups multiple players
-together in a sync group, where one player is the sync leader
-and the other players are synced to that leader.
-"""
-
-from __future__ import annotations
-
-import asyncio
-from copy import deepcopy
-from typing import TYPE_CHECKING, cast
-
-import shortuuid
-from music_assistant_models.config_entries import ConfigEntry, ConfigValueOption, ConfigValueType
-from music_assistant_models.constants import PLAYER_CONTROL_NONE
-from music_assistant_models.enums import (
- ConfigEntryType,
- PlaybackState,
- PlayerFeature,
- PlayerType,
- ProviderFeature,
-)
-from music_assistant_models.errors import UnsupportedFeaturedException
-from music_assistant_models.player import DeviceInfo, PlayerMedia, PlayerSource
-from propcache import under_cached_property as cached_property
-
-from music_assistant.constants import (
- CONF_CROSSFADE_DURATION,
- CONF_DYNAMIC_GROUP_MEMBERS,
- CONF_ENABLE_ICY_METADATA,
- CONF_FLOW_MODE,
- CONF_GROUP_MEMBERS,
- CONF_HTTP_PROFILE,
- CONF_OUTPUT_CODEC,
- CONF_SAMPLE_RATES,
- CONF_SMART_FADES_MODE,
- SYNCGROUP_PREFIX,
-)
-from music_assistant.models.player import GroupPlayer, Player
-
-if TYPE_CHECKING:
- from music_assistant.models.player_provider import PlayerProvider
-
- from .player_controller import PlayerController
-
-
-SUPPORT_DYNAMIC_LEADER = {
- # providers that support dynamic leader selection in a syncgroup
- # meaning that if you would remove the current leader from the group,
- # the provider will automatically select a new leader from the remaining members
- # and the music keeps playing uninterrupted.
- "airplay",
- "squeezelite",
- "snapcast",
- # TODO: Get this working with Sonos as well (need to handle range requests)
-}
-
-OPTIONAL_FEATURES = {
- PlayerFeature.ENQUEUE,
- PlayerFeature.GAPLESS_PLAYBACK,
- PlayerFeature.NEXT_PREVIOUS,
- PlayerFeature.PAUSE,
- PlayerFeature.PLAY_ANNOUNCEMENT,
- PlayerFeature.SEEK,
- PlayerFeature.SELECT_SOURCE,
- PlayerFeature.VOLUME_MUTE,
- PlayerFeature.MULTI_DEVICE_DSP,
-}
-
-
-class SyncGroupPlayer(GroupPlayer):
- """Helper class for a (provider specific) SyncGroup player."""
-
- _attr_type: PlayerType = PlayerType.GROUP
- sync_leader: Player | None = None
- """The active sync leader player for this syncgroup."""
-
- @cached_property
- def is_dynamic(self) -> bool:
- """Return if the player is a dynamic group player."""
- return bool(self.config.get_value(CONF_DYNAMIC_GROUP_MEMBERS, False))
-
- def __init__(
- self,
- provider: PlayerProvider,
- player_id: str,
- ) -> None:
- """Initialize GroupPlayer instance."""
- super().__init__(provider, player_id)
- self._attr_name = self.config.name or self.config.default_name or f"SyncGroup {player_id}"
- self._attr_available = True
- self._attr_powered = False # group players are always powered off by default
- self._attr_device_info = DeviceInfo(model="Sync Group", manufacturer=provider.name)
- self._attr_supported_features = {
- PlayerFeature.POWER,
- PlayerFeature.VOLUME_SET,
- }
-
- async def on_config_updated(self) -> None:
- """Handle logic when the player is loaded or updated."""
- # Config is only available after the player was registered
- self._cache.clear() # clear to prevent loading old is_dynamic
- static_members = cast("list[str]", self.config.get_value(CONF_GROUP_MEMBERS, []))
- if self.is_dynamic:
- self._attr_static_group_members = []
- self._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
- else:
- self._attr_static_group_members = static_members.copy()
- self._attr_supported_features.discard(PlayerFeature.SET_MEMBERS)
- if not self.powered:
- self._attr_group_members = static_members.copy()
-
- @property
- def supported_features(self) -> set[PlayerFeature]:
- """Return the supported features of the player."""
- members = self.group_members
- reference_player: Player | None = self.sync_leader or (
- self.mass.players.get(members[0]) if members else None
- )
- if reference_player:
- base_features = self._attr_supported_features.copy()
- # add features supported by the sync leader
- for feature in OPTIONAL_FEATURES:
- if feature in reference_player.supported_features:
- base_features.add(feature)
- return base_features
- return self._attr_supported_features
-
- @property
- def playback_state(self) -> PlaybackState:
- """Return the current playback state of the player."""
- if self.powered:
- return self.sync_leader.playback_state if self.sync_leader else PlaybackState.IDLE
- return PlaybackState.IDLE
-
- @property
- def requires_flow_mode(self) -> bool:
- """Return if the player needs flow mode."""
- if leader := self.sync_leader:
- return leader.requires_flow_mode
- return False
-
- @property
- def elapsed_time(self) -> float | None:
- """Return the elapsed time in (fractional) seconds of the current track (if any)."""
- return self.sync_leader.elapsed_time if self.sync_leader else None
-
- @property
- def elapsed_time_last_updated(self) -> float | None:
- """Return when the elapsed time was last updated."""
- return self.sync_leader.elapsed_time_last_updated if self.sync_leader else None
-
- @property
- def _current_media(self) -> PlayerMedia | None:
- """Return the current media item (if any) loaded in the player."""
- return self.sync_leader._current_media if self.sync_leader else self._attr_current_media
-
- @property
- def _active_source(self) -> str | None:
- """Return the active source id (if any) of the player."""
- return self.sync_leader._active_source if self.sync_leader else self._attr_active_source
-
- @property
- def _source_list(self) -> list[PlayerSource]:
- """Return list of available (native) sources for this player."""
- if self.sync_leader:
- return self.sync_leader._source_list
- return []
-
- @property
- def can_group_with(self) -> set[str]:
- """
- Return the id's of players this player can group with.
-
- This should return set of player_id's this player can group/sync with
- or just the provider's instance_id if all players can group with each other.
- """
- if self.is_dynamic and (leader := self.sync_leader):
- return leader.can_group_with
- if self.is_dynamic:
- return {self.provider.instance_id}
- return set()
-
- async def get_config_entries(
- self,
- action: str | None = None,
- values: dict[str, ConfigValueType] | None = None,
- ) -> list[ConfigEntry]:
- """Return all (provider/player specific) Config Entries for the given player (if any)."""
- entries: list[ConfigEntry] = [
- # syncgroup specific entries
- ConfigEntry(
- key=CONF_GROUP_MEMBERS,
- type=ConfigEntryType.STRING,
- multi_value=True,
- label="Group members",
- default_value=[],
- description="Select all players you want to be part of this group",
- required=False, # needed for dynamic members (which allows empty members list)
- options=[
- ConfigValueOption(x.display_name, x.player_id)
- for x in self.provider.players
- if x.type != PlayerType.GROUP
- ],
- ),
- ConfigEntry(
- key="dynamic_members",
- type=ConfigEntryType.BOOLEAN,
- label="Enable dynamic members",
- description="Allow (un)joining members dynamically, so the group more or less "
- "behaves the same like manually syncing players together, "
- "with the main difference being that the group player will hold the queue.",
- default_value=False,
- required=False,
- ),
- ]
- # combine base group entries with (base) player entries for this player type
- child_player = next((x for x in self.provider.players if x.type == PlayerType.PLAYER), None)
- if child_player:
- allowed_conf_entries = (
- CONF_HTTP_PROFILE,
- CONF_ENABLE_ICY_METADATA,
- CONF_CROSSFADE_DURATION,
- CONF_OUTPUT_CODEC,
- CONF_FLOW_MODE,
- CONF_SAMPLE_RATES,
- CONF_SMART_FADES_MODE,
- )
- child_config_entries = await child_player.get_config_entries()
- entries.extend(
- [entry for entry in child_config_entries if entry.key in allowed_conf_entries]
- )
- return entries
-
- async def stop(self) -> None:
- """Send STOP command to given player."""
- if sync_leader := self.sync_leader:
- await sync_leader.stop()
-
- async def play(self) -> None:
- """Send PLAY command to given player."""
- if sync_leader := self.sync_leader:
- await sync_leader.play()
-
- async def pause(self) -> None:
- """Send PAUSE command to given player."""
- if sync_leader := self.sync_leader:
- await sync_leader.pause()
-
- async def power(self, powered: bool) -> None:
- """Handle POWER command to group player."""
- prev_power = self._attr_powered
-
- # always stop at power off
- if not powered and self.playback_state in (PlaybackState.PLAYING, PlaybackState.PAUSED):
- await self.stop()
- self._attr_current_media = None
-
- # optimistically set the group state
- self._attr_powered = powered
- if prev_power != powered:
- self.update_state()
-
- if powered:
- # ensure static members are present when powering on
- for static_group_member in self._attr_static_group_members:
- member_player = self.mass.players.get(static_group_member)
- if not member_player or not member_player.available or not member_player.enabled:
- if static_group_member in self._attr_group_members:
- self._attr_group_members.remove(static_group_member)
- continue
- if static_group_member not in self._attr_group_members:
- # Always add static members when power(true) is called,
- # this will ensure that static members that just became available are added
- self._attr_group_members.append(static_group_member)
- # Select sync leader and handle turn on
- new_leader = self._select_sync_leader()
- # handle TURN_ON of the group player by turning on all members
- for member in self.mass.players.iter_group_members(
- self, only_powered=False, active_only=False
- ):
- await self._handle_member_collisions(member)
- if not member.powered and member.power_control != PLAYER_CONTROL_NONE:
- await self.mass.players._handle_cmd_power(member.player_id, True)
- # Set up the sync group with the new leader
- if prev_power and new_leader == self.sync_leader:
- # Already powered on with same leader, just re-sync members without full transition
- await self._form_syncgroup()
- else:
- await self._handle_leader_transition(new_leader)
- elif prev_power and not powered:
- # handle TURN_OFF of the group player by dissolving group and turning off all members
- await self._dissolve_syncgroup()
- # turn off all group members
- for member in self.mass.players.iter_group_members(
- self, only_powered=True, active_only=True
- ):
- if member.powered and member.power_control != PLAYER_CONTROL_NONE:
- await self.mass.players._handle_cmd_power(member.player_id, False)
-
- if not powered:
- configured_members = cast("list[str]", self.config.get_value(CONF_GROUP_MEMBERS, []))
- self._attr_group_members = configured_members.copy()
- self.sync_leader = None
- self.update_state()
-
- async def volume_set(self, volume_level: int) -> None:
- """Send VOLUME_SET command to given player."""
- # group volume is already handled in the player manager
-
- async def play_media(self, media: PlayerMedia) -> None:
- """Handle PLAY MEDIA on given player."""
- # power on (which will also resync and add static members if needed)
- await self.power(True)
- # simply forward the command to the sync leader
- if sync_leader := self.sync_leader:
- await sync_leader.play_media(media)
- self._attr_current_media = deepcopy(media)
- self.update_state()
- else:
- raise RuntimeError("an empty group cannot play media, consider adding members first")
-
- async def enqueue_next_media(self, media: PlayerMedia) -> None:
- """Handle enqueuing of a next media item on the player."""
- if sync_leader := self.sync_leader:
- await sync_leader.enqueue_next_media(media)
-
- async def select_source(self, source: str) -> None:
- """
- Handle SELECT SOURCE command on the player.
-
- Will only be called if the PlayerFeature.SELECT_SOURCE is supported.
-
- :param source: The source(id) to select, as defined in the source_list.
- """
- if sync_leader := self.sync_leader:
- await sync_leader.select_source(source)
- self.update_state()
-
- async def set_members(
- self,
- player_ids_to_add: list[str] | None = None,
- player_ids_to_remove: list[str] | None = None,
- ) -> None:
- """Handle SET_MEMBERS command on the player."""
- if not self.is_dynamic:
- raise UnsupportedFeaturedException(
- f"Group {self.display_name} does not allow dynamically adding/removing members!"
- )
- # handle additions
- final_players_to_add: list[str] = []
- for player_id in player_ids_to_add or []:
- if player_id in self._attr_group_members:
- continue
- if player_id == self.player_id:
- raise UnsupportedFeaturedException(
- f"Cannot add {self.display_name} to itself as a member!"
- )
- self._attr_group_members.append(player_id)
- final_players_to_add.append(player_id)
- # handle removals
- final_players_to_remove: list[str] = []
- for player_id in player_ids_to_remove or []:
- if player_id not in self._attr_group_members:
- continue
- if player_id == self.player_id:
- raise UnsupportedFeaturedException(
- f"Cannot remove {self.display_name} from itself as a member!"
- )
- self._attr_group_members.remove(player_id)
- final_players_to_remove.append(player_id)
- self.update_state()
- if not self.powered:
- # Don't need to do anything else if the group is powered off
- # The syncing will be done once powered on
- return
- next_leader = self._select_sync_leader()
- prev_leader = self.sync_leader
-
- if prev_leader and next_leader is None:
- # Edge case: we no longer have any members in the group (and thus no leader)
- await self._handle_leader_transition(None)
- elif prev_leader != next_leader:
- # Edge case: we had changed the leader (or just got one)
- await self._handle_leader_transition(next_leader)
- elif self.sync_leader and (player_ids_to_add or player_ids_to_remove):
- # if the group still has the same leader, we need to (re)sync the members
- # Handle collisions for newly added players
- for player_id in final_players_to_add:
- if player := self.mass.players.get(player_id):
- await self._handle_member_collisions(player)
-
- await self.sync_leader.set_members(
- player_ids_to_add=final_players_to_add,
- player_ids_to_remove=final_players_to_remove,
- )
-
- async def _form_syncgroup(self) -> None:
- """Form syncgroup by syncing all (possible) members."""
- if self.sync_leader is None:
- # This is an empty group, leader will be selected once a member is added
- self._attr_group_members = []
- self.update_state()
- return
- # ensure the sync leader is first in the list
- self._attr_group_members = [
- self.sync_leader.player_id,
- *[x for x in self._attr_group_members if x != self.sync_leader.player_id],
- ]
- self.update_state()
- members_to_sync: list[str] = []
- members_to_remove: list[str] = []
- for member in self.mass.players.iter_group_members(self, active_only=False):
- # Handle collisions before attempting to sync
- await self._handle_member_collisions(member)
-
- if member.synced_to and member.synced_to != self.sync_leader.player_id:
- # ungroup first
- await member.ungroup()
- if member.player_id == self.sync_leader.player_id:
- # skip sync leader
- continue
- # Always add to members_to_sync to prevent them from being removed below
- members_to_sync.append(member.player_id)
- for former_members in self.sync_leader.group_members:
- if (
- former_members not in members_to_sync
- ) and former_members != self.sync_leader.player_id:
- members_to_remove.append(former_members)
- if members_to_sync or members_to_remove:
- await self.sync_leader.set_members(members_to_sync, members_to_remove)
-
- async def _dissolve_syncgroup(self) -> None:
- """Dissolve the current syncgroup by ungrouping all members and restoring leader queue."""
- if sync_leader := self.sync_leader:
- # dissolve the temporary syncgroup from the sync leader
- sync_children = [x for x in sync_leader.group_members if x != sync_leader.player_id]
- if sync_children:
- await sync_leader.set_members(player_ids_to_remove=sync_children)
- # Reset the leaders queue since it is no longer part of this group
- sync_leader.update_state()
-
- async def _handle_leader_transition(self, new_leader: Player | None) -> None:
- """Handle transition from current leader to new leader."""
- prev_leader = self.sync_leader
- was_playing = False
-
- if (
- prev_leader
- and new_leader
- and prev_leader != new_leader
- and self.provider.domain in SUPPORT_DYNAMIC_LEADER
- ):
- # provider supports dynamic leader selection, so just remove/add members
- await prev_leader.ungroup()
- self.sync_leader = new_leader
- # allow some time to propagate the changes before resyncing
- await asyncio.sleep(2)
- await self._form_syncgroup()
- return
-
- if prev_leader:
- # Save current media and playback state for potential restart
- was_playing = self.playback_state == PlaybackState.PLAYING
- # Stop current playback and dissolve existing group
- await self.stop()
- await self._dissolve_syncgroup()
- # allow some time to propagate the changes before resyncing
- await asyncio.sleep(2)
-
- # Set new leader
- self.sync_leader = new_leader
-
- if new_leader:
- # form a syncgroup with the new leader
- await self._form_syncgroup()
-
- # Restart playback if requested and we have media to play
- if was_playing:
- await self.mass.players._handle_cmd_resume(self.player_id)
- else:
- # We have no leader anymore, send update since we stopped playback
- self.update_state()
-
- def _select_sync_leader(self) -> Player | None:
- """Select the active sync leader player for a syncgroup."""
- if self.sync_leader and self.sync_leader.player_id in self.group_members:
- # Don't change the sync leader if we already have one
- return self.sync_leader
- for prefer_sync_leader in (True, False):
- for child_player in self.mass.players.iter_group_members(self):
- if prefer_sync_leader and child_player.synced_to:
- # prefer the first player that already has sync children
- continue
- if child_player.active_group not in (
- None,
- self.player_id,
- child_player.player_id,
- ):
- # this should not happen (because its already handled in the power on logic),
- # but guard it just in case bad things happen
- continue
- return child_player
- return None
-
- async def _handle_member_collisions(self, member: Player) -> None:
- """Handle collisions when adding a member to the sync group."""
- active_groups = member.active_groups
- for group in active_groups:
- if group == self.player_id:
- continue
- # collision: child player is part another group that is already active !
- # solve this by trying to leave the group first
- if other_group := self.mass.players.get(group):
- if (
- other_group.supports_feature(PlayerFeature.SET_MEMBERS)
- and member.player_id not in other_group.static_group_members
- ):
- await other_group.set_members(player_ids_to_remove=[member.player_id])
- else:
- # if the other group does not support SET_MEMBERS or it is a static
- # member, we need to power it off to leave the group
- await other_group.power(False)
- if (
- member.synced_to is not None
- and self.sync_leader
- and member.synced_to != self.sync_leader.player_id
- and (synced_to_player := self.mass.players.get(member.synced_to))
- and member.player_id in synced_to_player.group_members
- ):
- # collision: child player is synced to another player and still in that group
- # ungroup it first
- await synced_to_player.set_members(player_ids_to_remove=[member.player_id])
-
-
-class SyncGroupController:
- """Controller managing SyncGroup players."""
-
- def __init__(self, player_controller: PlayerController) -> None:
- """Initialize SyncGroupController."""
- self.player_controller = player_controller
- self.mass = player_controller.mass
-
- async def create_group_player(
- self, provider: PlayerProvider, name: str, members: list[str], dynamic: bool = True
- ) -> Player:
- """
- Create new SyncGroup Player.
-
- :param provider: The provider to create the group player for
- :param name: Name of the group player
- :param members: List of player ids to add to the group
- :param dynamic: Whether the group is dynamic (members can change)
- """
- # default implementation for providers that support syncing players
- if ProviderFeature.SYNC_PLAYERS not in provider.supported_features:
- # the frontend should already prevent this, but just in case
- raise UnsupportedFeaturedException(
- f"Provider {provider.name} does not support player syncing!"
- )
- # Create a new syncgroup player with the given members
- members = [x for x in members if x in [y.player_id for y in provider.players]]
- player_id = f"{SYNCGROUP_PREFIX}{shortuuid.random(8).lower()}"
- self.mass.config.create_default_player_config(
- player_id=player_id,
- provider=provider.instance_id,
- player_type=PlayerType.GROUP,
- name=name,
- enabled=True,
- values={
- CONF_GROUP_MEMBERS: members,
- CONF_DYNAMIC_GROUP_MEMBERS: dynamic,
- },
- )
- return await self._register_syncgroup_player(player_id, provider)
-
- async def remove_group_player(self, player_id: str) -> None:
- """
- Remove a group player.
-
- :param player_id: ID of the group player to remove.
- """
- # we simply permanently unregister the syncgroup player and wipe its config
- await self.mass.players.unregister(player_id, True)
-
- async def _register_syncgroup_player(self, player_id: str, provider: PlayerProvider) -> Player:
- """Register a syncgroup player."""
- syncgroup = SyncGroupPlayer(provider, player_id)
- await self.mass.players.register_or_update(syncgroup)
- return syncgroup
-
- async def on_provider_loaded(self, provider: PlayerProvider) -> None:
- """Handle logic when a provider is loaded."""
- # register existing syncgroup players for this provider
- for player_conf in await self.mass.config.get_player_configs(provider.instance_id):
- if player_conf.player_id.startswith(SYNCGROUP_PREFIX):
- await self._register_syncgroup_player(player_conf.player_id, provider)
-
- async def on_provider_unload(self, provider: PlayerProvider) -> None:
- """Handle logic when a provider is (about to get) unloaded."""
- # unregister existing syncgroup players for this provider
- for player in self.mass.players.all(
- provider_filter=provider.instance_id, return_sync_groups=True
- ):
- if player.player_id.startswith(SYNCGROUP_PREFIX):
- await self.mass.players.unregister(player.player_id, False)
SILENCE_FILE,
VERBOSE_LOG_LEVEL,
)
-from music_assistant.controllers.players.player_controller import AnnounceData
+from music_assistant.controllers.players.helpers import AnnounceData
from music_assistant.controllers.streams.smart_fades import SmartFadesMixer
from music_assistant.controllers.streams.smart_fades.analyzer import SmartFadesAnalyzer
from music_assistant.controllers.streams.smart_fades.fades import SMART_CROSSFADE_DURATION
static_routes=[
(
"*",
- "/flow/{session_id}/{queue_id}/{queue_item_id}.{fmt}",
+ "/flow/{session_id}/{queue_id}/{queue_item_id}/{player_id}.{fmt}",
self.serve_queue_flow_stream,
),
(
"*",
- "/single/{session_id}/{queue_id}/{queue_item_id}.{fmt}",
+ "/single/{session_id}/{queue_id}/{queue_item_id}/{player_id}.{fmt}",
self.serve_queue_item_stream,
),
(
async def resolve_stream_url(
self,
- session_id: str,
- queue_item: QueueItem,
- flow_mode: bool = False,
- player_id: str | None = None,
+ player_id: str,
+ media: PlayerMedia,
) -> str:
- """Resolve the stream URL for the given QueueItem."""
- if not player_id:
- player_id = queue_item.queue_id
+ """Resolve the stream URL for the given PlayerMedia."""
conf_output_codec = await self.mass.config.get_player_config_value(
player_id, CONF_OUTPUT_CODEC, default="flac", return_type=str
)
# handle raw pcm without exact format specifiers
if output_codec.is_pcm() and ";" not in fmt:
fmt += f";codec=pcm;rate={44100};bitrate={16};channels={2}"
+ extra_data = media.custom_data or {}
+ flow_mode = extra_data.get("flow_mode", False)
+ session_id = extra_data.get("session_id")
+ queue_item_id = media.queue_item_id
+ if not session_id or not queue_item_id:
+ raise InvalidDataError("Can not resolve stream URL: Invalid PlayerMedia data")
+ queue_id = media.source_id
base_path = "flow" if flow_mode else "single"
- return f"{self._server.base_url}/{base_path}/{session_id}/{queue_item.queue_id}/{queue_item.queue_item_id}.{fmt}" # noqa: E501
+ return f"{self._server.base_url}/{base_path}/{session_id}/{queue_id}/{queue_item_id}/{player_id}.{fmt}" # noqa: E501
async def get_plugin_source_url(
self,
"""Stream single queueitem audio to a player."""
self._log_request(request)
queue_id = request.match_info["queue_id"]
- queue = self.mass.player_queues.get(queue_id)
- if not queue:
+ player_id = request.match_info["player_id"]
+ if not (queue := self.mass.player_queues.get(queue_id)):
raise web.HTTPNotFound(reason=f"Unknown Queue: {queue_id}")
session_id = request.match_info["session_id"]
if queue.session_id and session_id != queue.session_id:
raise web.HTTPNotFound(reason=f"Unknown (or invalid) session: {session_id}")
- queue_player = self.mass.players.get(queue_id)
+ if not (player := self.mass.players.get_player(player_id)):
+ raise web.HTTPNotFound(reason=f"Unknown Player: {player_id}")
queue_item_id = request.match_info["queue_item_id"]
queue_item = self.mass.player_queues.get_item(queue_id, queue_item_id)
if not queue_item:
raise web.HTTPNotFound(reason=f"No streamdetails for Queue item: {queue_item_id}")
# pick output format based on the streamdetails and player capabilities
- if not queue_player:
- raise web.HTTPNotFound(reason=f"Unknown Player: {queue_id}")
-
- # work out pcm format based on streamdetails
pcm_format = await self._select_pcm_format(
- player=queue_player,
+ player=player,
streamdetails=queue_item.streamdetails,
smartfades_enabled=True,
)
output_format = await self.get_output_format(
output_format_str=request.match_info["fmt"],
- player=queue_player,
+ player=player,
content_sample_rate=pcm_format.sample_rate,
content_bit_depth=pcm_format.bit_depth,
)
)
if (
smart_fades_mode != SmartFadesMode.DISABLED
- and PlayerFeature.GAPLESS_PLAYBACK not in queue_player.supported_features
+ and PlayerFeature.GAPLESS_PLAYBACK not in player.state.supported_features
):
# crossfade is not supported on this player due to missing gapless playback
self.logger.warning(
"Crossfade disabled: Player %s does not support gapless playback, "
"consider enabling flow mode to enable crossfade on this player.",
- queue_player.display_name if queue_player else "Unknown Player",
+ player.state.name if player else "Unknown Player",
)
smart_fades_mode = SmartFadesMode.DISABLED
output_format=output_format,
filter_params=get_player_filter_params(
self.mass,
- player_id=queue_player.player_id,
+ player_id=player.player_id,
input_format=pcm_format,
output_format=output_format,
),
queue_item.queue_id, queue_item.queue_item_id
)
except (BrokenPipeError, ConnectionResetError, ConnectionError) as err:
- if first_chunk_received and not queue_player.stop_called:
+ if first_chunk_received and not player.stop_called:
# Player disconnected (unexpected) after receiving at least some data
# This could indicate buffering issues, network problems,
# or player-specific issues
"""Stream Queue Flow audio to player."""
self._log_request(request)
queue_id = request.match_info["queue_id"]
- queue = self.mass.player_queues.get(queue_id)
- if not queue:
+ player_id = request.match_info["player_id"]
+ if not (queue := self.mass.player_queues.get(queue_id)):
raise web.HTTPNotFound(reason=f"Unknown Queue: {queue_id}")
- if not (queue_player := self.mass.players.get(queue_id)):
- raise web.HTTPNotFound(reason=f"Unknown Player: {queue_id}")
+ if not (player := self.mass.players.get_player(player_id)):
+ raise web.HTTPNotFound(reason=f"Unknown Player: {player_id}")
start_queue_item_id = request.match_info["queue_item_id"]
start_queue_item = self.mass.player_queues.get_item(queue_id, start_queue_item_id)
if not start_queue_item:
queue.flow_mode_stream_log = []
# select the highest possible PCM settings for this player
- flow_pcm_format = await self._select_flow_format(queue_player)
+ flow_pcm_format = await self._select_flow_format(player)
# work out output format/details
output_format = await self.get_output_format(
output_format_str=request.match_info["fmt"],
- player=queue_player,
+ player=player,
content_sample_rate=flow_pcm_format.sample_rate,
content_bit_depth=flow_pcm_format.bit_depth,
)
input_format=flow_pcm_format,
output_format=output_format,
filter_params=get_player_filter_params(
- self.mass, queue_player.player_id, flow_pcm_format, output_format
+ self.mass, player.player_id, flow_pcm_format, output_format
),
# we need to slowly feed the music to avoid the player stopping and later
# restarting (or completely failing) the audio stream by keeping the buffer short.
self.logger.debug(
"Start serving audio stream for Announcement %s to %s",
announce_data["announcement_url"],
- player.display_name,
+ player.state.name,
)
async for chunk in self.get_announcement_stream(
announcement_url=announce_data["announcement_url"],
self.logger.debug(
"Finished serving audio stream for Announcement %s to %s",
announce_data["announcement_url"],
- player.display_name,
+ player.state.name,
)
return resp
raise ProviderUnavailableError(f"Unknown PluginSource: {plugin_source_id}")
# work out output format/details
player_id = request.match_info["player_id"]
- player = self.mass.players.get(player_id)
+ player = self.mass.players.get_player(player_id)
if not player:
raise web.HTTPNotFound(reason=f"Unknown Player: {player_id}")
plugin_source = provider.get_source()
):
# special case: member player accessing UGP stream
# Check URI to distinguish from the UGP accessing its own stream
- ugp_player = cast("UniversalGroupPlayer", self.mass.players.get(media.source_id))
+ ugp_player = cast("UniversalGroupPlayer", self.mass.players.get_player(media.source_id))
ugp_stream = ugp_player.stream
assert ugp_stream is not None # for type checker
if ugp_stream.base_pcm_format == pcm_format:
queue.index_in_buffer = self.mass.player_queues.index_by_id(
queue.queue_id, next_queue_item.queue_item_id
)
- queue_player = self.mass.players.get(queue.queue_id)
+ queue_player = self.mass.players.get_player(queue.queue_id)
assert queue_player is not None
next_queue_item_pcm_format = await self._select_pcm_format(
player=queue_player,
"""Get the crossfade config for a queue item."""
if smart_fades_mode == SmartFadesMode.DISABLED:
return False
- if not (self.mass.players.get(queue_item.queue_id)):
+ if not (self.mass.players.get_player(queue_item.queue_id)):
return False # just a guard
if queue_item.media_type != MediaType.TRACK:
self.logger.debug("Skipping crossfade: current item is not a track")
3. Update this README if adding significant new features
4. Test authentication flows thoroughly
5. Consider security implications of all changes
-6. Update API documentation if adding new commands
+6. The API documentation will be auto updated if adding new commands (based on docstrings and type hints)
+
+---
+
+*This architecture document is maintained alongside the code and should be updated when significant changes are made to the provider's design or functionality.*
MASS_LOGGER_NAME,
VERBOSE_LOG_LEVEL,
)
-from music_assistant.controllers.players.sync_groups import SyncGroupPlayer
from music_assistant.helpers.json import JSON_DECODE_EXCEPTIONS, json_loads
from music_assistant.helpers.throttle_retry import BYPASS_THROTTLER
from music_assistant.helpers.util import clean_stream_title, remove_file
+from music_assistant.providers.sync_group.constants import SGP_PREFIX
from .audio_buffer import AudioBuffer
from .dsp import filter_to_ffmpeg_params
from music_assistant.mass import MusicAssistant
from music_assistant.models.music_provider import MusicProvider
from music_assistant.models.player import Player
+ from music_assistant.providers.sync_group import SyncGroupPlayer
LOGGER = logging.getLogger(f"{MASS_LOGGER_NAME}.audio")
queue_id: str,
) -> dict[str, DSPDetails]:
"""Return DSP details of all players playing this queue, keyed by player_id."""
- player = mass.players.get(queue_id)
+ player = mass.players.get_player(queue_id)
dsp: dict[str, DSPDetails] = {}
assert player is not None # for type checking
group_preventing_dsp = is_grouping_preventing_dsp(player)
output_format = None
is_external_group = False
- if player.type == PlayerType.GROUP and isinstance(player, SyncGroupPlayer):
+ if player.player_id.startswith(SGP_PREFIX):
if group_preventing_dsp:
- if sync_leader := player.sync_leader:
+ sgp_player = cast("SyncGroupPlayer", player)
+ if sync_leader := sgp_player.sync_leader:
output_format = sync_leader.extra_data.get("output_format", None)
else:
# We only add real players (so skip the PlayerGroups as they only sync containing players)
# The leader is responsible for sending the (combined) audio stream, so get
# the output format from the leader.
output_format = player.extra_data.get("output_format", None)
- is_external_group = player.type in (PlayerType.GROUP, PlayerType.STEREO_PAIR)
+ is_external_group = player.state.type in (PlayerType.GROUP, PlayerType.STEREO_PAIR)
# We don't enumerate all group members in case this group is externally created
# (e.g. a Chromecast group from the Google Home app)
- if player and player.group_members and not is_external_group:
+ if player and player.state.group_members and not is_external_group:
# grouped playback, get DSP details for each player in the group
- for child_id in player.group_members:
+ for child_id in player.state.group_members:
# skip if we already have the details (so if it's the group leader)
if child_id in dsp:
continue
- if child_player := mass.players.get(child_id):
+ if child_player := mass.players.get_player(child_id):
dsp[child_id] = get_player_dsp_details(
mass, child_player, group_preventing_dsp=group_preventing_dsp
)
If this returns True, no DSP should be applied to the player.
This function will not check if the Player is in a group, the caller should do that first.
"""
- # We require the caller to handle non-leader cases themselves since player.synced_to
+ # We require the caller to handle non-leader cases themselves since player.state.synced_to
# can be unreliable in some edge cases
- multi_device_dsp_supported = PlayerFeature.MULTI_DEVICE_DSP in player.supported_features
- child_count = len(player.group_members) if player.group_members else 0
+ multi_device_dsp_supported = PlayerFeature.MULTI_DEVICE_DSP in player.state.supported_features
+ child_count = len(player.state.group_members) if player.state.group_members else 0
is_multiple_devices: bool
if player.provider.domain == "player_group":
# PlayerGroups have no leader, so having a child count of 1 means
# the group actually contains only a single player.
is_multiple_devices = child_count > 1
- elif player.type == PlayerType.GROUP:
+ elif player.state.type == PlayerType.GROUP:
# This is an group player external to Music Assistant.
is_multiple_devices = True
else:
decides if the limiter should be turned on or not.
"""
deciding_player_id = player.player_id
- if player.active_group:
+ if player.state.active_group:
# Syncgroup, get from the group player
- deciding_player_id = player.active_group
- elif player.synced_to:
+ deciding_player_id = player.state.active_group
+ elif player.state.synced_to:
# Not in sync group, but synced, get from the leader
- deciding_player_id = player.synced_to
+ deciding_player_id = player.state.synced_to
output_limiter_enabled = mass.config.get_raw_player_config_value(
deciding_player_id,
CONF_ENTRY_OUTPUT_LIMITER.key,
dsp = mass.config.get_player_dsp_config(player_id)
limiter_enabled = True
- if player := mass.players.get(player_id):
+ if player := mass.players.get_player(player_id):
if is_grouping_preventing_dsp(player):
# We can not correctly apply DSP to a grouped player without multi-device DSP support,
# so we disable it.
dsp.enabled = False
elif player.provider.domain == "player_group" and (
- PlayerFeature.MULTI_DEVICE_DSP not in player.supported_features
+ PlayerFeature.MULTI_DEVICE_DSP not in player.state.supported_features
):
# This is a special case! We have a player group where:
# - The group leader does not support MULTI_DEVICE_DSP
# - But only contains a single player (since nothing is preventing DSP)
# We can still apply the DSP of that single player.
- if player.group_members:
- child_player = mass.players.get(player.group_members[0])
+ if player.state.group_members:
+ child_player = mass.players.get_player(player.state.group_members[0])
assert child_player is not None # for type checking
dsp = mass.config.get_player_dsp_config(child_player.player_id)
else:
async def get_mac_address(ip_address: str) -> str | None:
- """Get MAC address for given IP address."""
- from getmac import get_mac_address # noqa: PLC0415
+ """Get MAC address for given IP address via ARP lookup."""
+ try:
+ from getmac import get_mac_address as getmac_lookup # noqa: PLC0415
+
+ return await asyncio.to_thread(getmac_lookup, ip=ip_address)
+ except ImportError:
+ LOGGER.debug("getmac module not available, cannot resolve MAC from IP")
+ return None
+ except Exception as err:
+ LOGGER.debug("Failed to resolve MAC address for %s: %s", ip_address, err)
+ return None
+
+
+def is_locally_administered_mac(mac_address: str) -> bool:
+ """
+ Check if a MAC address is locally administered (virtual/randomized).
+
+ Locally administered addresses have bit 1 of the first octet set to 1.
+ These are often used by devices for virtual interfaces or protocol-specific
+ addresses (e.g., AirPlay, DLNA may use different virtual MACs than the real hardware MAC).
+
+ :param mac_address: MAC address in any common format (with :, -, or no separator).
+ :return: True if locally administered, False if globally unique (real hardware MAC).
+ """
+ # Normalize MAC address
+ mac_clean = mac_address.upper().replace(":", "").replace("-", "")
+ if len(mac_clean) < 2:
+ return False
- return await asyncio.to_thread(get_mac_address, ip=ip_address)
+ # Get first octet and check bit 1 (second bit from right)
+ try:
+ first_octet = int(mac_clean[:2], 16)
+ return bool(first_octet & 0x02)
+ except ValueError:
+ return False
+
+
+async def resolve_real_mac_address(reported_mac: str | None, ip_address: str | None) -> str | None:
+ """
+ Resolve the real MAC address for a device.
+
+ Some devices report different virtual MAC addresses per protocol (AirPlay, DLNA,
+ Chromecast). This function tries to resolve the actual hardware MAC via ARP
+ when the reported MAC appears to be locally administered (virtual).
+
+ :param reported_mac: The MAC address reported by the protocol.
+ :param ip_address: The IP address of the device (for ARP lookup).
+ :return: The real MAC address if found, or None if it couldn't be resolved.
+ """
+ if not ip_address:
+ return None
+
+ # If no MAC reported or it's a locally administered one, try ARP lookup
+ if not reported_mac or is_locally_administered_mac(reported_mac):
+ real_mac = await get_mac_address(ip_address)
+ if real_mac and real_mac.lower() not in ("00:00:00:00:00:00", "ff:ff:ff:ff:ff:ff"):
+ return real_mac.upper()
+
+ return None
class TaskManager:
from music_assistant.constants import (
API_SCHEMA_VERSION,
+ CONF_DEFAULT_PROVIDERS_SETUP,
CONF_PROVIDERS,
CONF_SERVER_ID,
CONF_ZEROCONF_INTERFACES,
CONFIGURABLE_CORE_CONTROLLERS,
+ DEFAULT_PROVIDERS,
MASS_LOGGER_NAME,
MIN_SCHEMA_VERSION,
VERBOSE_LOG_LEVEL,
from music_assistant.controllers.metadata import MetaDataController
from music_assistant.controllers.music import MusicController
from music_assistant.controllers.player_queues import PlayerQueuesController
-from music_assistant.controllers.players.player_controller import PlayerController
+from music_assistant.controllers.players import PlayerController
from music_assistant.controllers.streams import StreamsController
from music_assistant.controllers.webserver import WebserverController
from music_assistant.controllers.webserver.helpers.auth_middleware import get_current_user
self._tracked_timers.pop(task_id)
self.create_task(_target, *args, task_id=task_id, abort_existing=True, **kwargs)
+ def _call_sync(_target: Callable[..., _R]) -> None:
+ self._tracked_timers.pop(task_id)
+ _target(*args, **kwargs)
+
if inspect.iscoroutinefunction(target) or inspect.iscoroutine(target):
# coroutine function
if TYPE_CHECKING:
target = cast("Coroutine[Any, Any, _R]", target)
handle = self.loop.call_later(delay, _create_task, target)
else:
- # regular callable
+ # regular sync callable
if TYPE_CHECKING:
target = cast("Callable[..., _R]", target)
- handle = self.loop.call_later(delay, target, *args)
+ handle = self.loop.call_later(delay, _call_sync, target)
self._tracked_timers[task_id] = handle
return handle
if not prov_manifest.builtin:
continue
await self.config.create_builtin_provider_config(prov_manifest.domain)
+ # handle default providers setup
+ self.config.set_default(CONF_DEFAULT_PROVIDERS_SETUP, set())
+ default_providers_setup = cast("set[str]", self.config.get(CONF_DEFAULT_PROVIDERS_SETUP))
+ changes_made = False
+ for default_provider, require_mdns in DEFAULT_PROVIDERS:
+ if default_provider in default_providers_setup:
+ # already processed/setup before, skip
+ continue
+ if not (manifest := self._provider_manifests.get(default_provider)):
+ continue
+ if require_mdns:
+ # if mdns discovery is required, check if we have seen any mdns entries
+ # for this provider before setting it up
+ for mdns_name in set(self.aiozc.zeroconf.cache.cache):
+ if manifest.mdns_discovery and any(
+ mdns_type in mdns_name for mdns_type in manifest.mdns_discovery
+ ):
+ break
+ else:
+ continue
+ await self.config.create_builtin_provider_config(manifest.domain)
+ changes_made = True
+ # TEMP: migration - to be removed after 2.8 release
+ # enable all existing players of the default providers if they are not already enabled
+ # due to the linked protocol feature we introduced
+ for player_config in await self.config.get_player_configs(
+ provider=default_provider, include_disabled=True
+ ):
+ if player_config.enabled:
+ continue
+ await self.config.save_player_config(player_config.player_id, {"enabled": True})
+ default_providers_setup.add(default_provider)
+ if changes_made:
+ self.config.set(CONF_DEFAULT_PROVIDERS_SETUP, default_providers_setup)
+ self.config.save(True)
# load all configured (and enabled) providers
+ # builtin providers are loaded first (and awaited) before loading the rest
prov_configs = await self.config.get_provider_configs(include_values=True)
+ builtin_configs: list[ProviderConfig] = []
+ other_configs: list[ProviderConfig] = []
for prov_conf in prov_configs:
if not prov_conf.enabled:
continue
+ manifest = self._provider_manifests.get(prov_conf.domain)
+ if manifest and manifest.builtin:
+ builtin_configs.append(prov_conf)
+ else:
+ other_configs.append(prov_conf)
+
+ # load builtin providers first and wait for them to complete
+ await asyncio.gather(
+ *[self.load_provider(conf.instance_id, allow_retry=True) for conf in builtin_configs]
+ )
+
+ # load remaining providers concurrently via tasks
+ for prov_conf in other_configs:
# Use a task so we can load multiple providers at once.
# If a provider fails, that will not block the loading of other providers.
self.create_task(self.load_provider(prov_conf.instance_id, allow_retry=True))
All providerspecific players should inherit from this class and implement the required methods.
-Note that the serverside Player object is not the same as the clientside Player object,
-which is a dataclass in the models package containing the player state.
+Note that this is NOT the final state of the player,
+as it may be overridden by (sync)group memberships, configuration options, or other factors.
+This final state will be calculated and snapshotted in the PlayerState dataclass,
+which is what is also what is sent over the API.
+The final active source can be retrieved by using the 'state' property.
"""
from __future__ import annotations
import time
-from abc import ABC, abstractmethod
+from abc import ABC
from collections.abc import Callable
from copy import deepcopy
from typing import TYPE_CHECKING, Any, cast, final
PLAYER_CONTROL_NATIVE,
PLAYER_CONTROL_NONE,
)
-from music_assistant_models.enums import (
- MediaType,
- PlaybackState,
- PlayerFeature,
- PlayerType,
-)
+from music_assistant_models.enums import MediaType, PlaybackState, PlayerFeature, PlayerType
from music_assistant_models.errors import UnsupportedFeaturedException
from music_assistant_models.player import (
DeviceInfo,
+ OutputProtocol,
PlayerMedia,
PlayerOption,
PlayerOptionValueType,
from propcache import under_cached_property as cached_property
from music_assistant.constants import (
+ ACTIVE_PROTOCOL_FEATURES,
ATTR_ANNOUNCEMENT_IN_PROGRESS,
ATTR_FAKE_MUTE,
ATTR_FAKE_POWER,
CONF_EXPOSE_PLAYER_TO_HA,
CONF_FLOW_MODE,
CONF_HIDE_IN_UI,
+ CONF_LINKED_PROTOCOL_PLAYER_IDS,
CONF_MUTE_CONTROL,
+ CONF_PLAYERS,
CONF_POWER_CONTROL,
CONF_SMART_FADES_MODE,
CONF_VOLUME_CONTROL,
+ PROTOCOL_FEATURES,
+ PROTOCOL_PRIORITY,
)
from music_assistant.helpers.util import get_changed_dataclass_values
self._attr_options = []
# do not override/overwrite these private attributes below!
self._cache: dict[str, Any] = {} # storage dict for cached properties
+ self.__attr_linked_protocols: list[OutputProtocol] = []
+ self.__attr_protocol_parent_id: str | None = None
+ self.__attr_active_output_protocol: str | None = None
self._player_id = player_id
self._provider = provider
self.mass.config.create_default_player_config(
playback_state=self.playback_state,
)
- @property
- def type(self) -> PlayerType:
- """Return the type of the player."""
- return self._attr_type
-
@property
def available(self) -> bool:
"""Return if the player is available."""
return self._attr_available
+ @property
+ def type(self) -> PlayerType:
+ """Return the type of the player."""
+ return self._attr_type
+
@property
def name(self) -> str | None:
"""Return the name of the player."""
"""
Return if the player needs flow mode.
- Will by default be set to True if the player does not support PlayerFeature.ENQUEUE
- or has crossfade enabled without gapless support.
+ Default implementation: True if the player does not support PlayerFeature.ENQUEUE
+ or has crossfade enabled without gapless support. Can be overridden by providers if needed.
"""
if PlayerFeature.ENQUEUE not in self.supported_features:
# without enqueue support, flow mode is required
"""
return self._attr_elapsed_time_last_updated
- @property
- def group_members(self) -> list[str]:
- """
- Return the group members of the player.
-
- If there are other players synced/grouped with this player,
- this should return the id's of players synced to this player,
- and this should include the player's own id (as first item in the list).
-
- If there are currently no group members, this should return an empty list.
- """
- if self.type == PlayerType.PLAYER and (
- len(self._attr_group_members) >= 1 and self.player_id not in self._attr_group_members
- ):
- # always ensure the player_id is in the group_members list for players
- return [self.player_id, *self._attr_group_members]
- if self._attr_group_members == [self.player_id]:
- return []
- return self._attr_group_members
-
- @property
- def static_group_members(self) -> list[str]:
- """
- Return the static group members for a player group.
-
- For PlayerType.GROUP return the player_ids of members that must not be removed by
- the user.
- For all other player types return an empty list.
- """
- return self._attr_static_group_members
-
- @property
- def can_group_with(self) -> set[str]:
- """
- Return the id's of players this player can group with.
-
- This should return set of player_id's this player can group/sync with
- or just the provider's instance_id if all players can group with each other.
- """
- return self._attr_can_group_with
-
@property
def needs_poll(self) -> bool:
"""Return if the player needs to be polled for state updates."""
return self._attr_enabled_by_default
@property
- def _powered(self) -> bool | None:
+ def static_group_members(self) -> list[str]:
+ """
+ Return the static group members for a player group.
+
+ For PlayerType.GROUP return the player_ids of members that must/can not be removed by
+ the user. For all other player types return an empty list.
+ """
+ return self._attr_static_group_members
+
+ @property
+ def powered(self) -> bool | None:
"""
Return if the player is powered on.
If the player does not support PlayerFeature.POWER,
or the state is (currently) unknown, this property may return None.
-
- Note that this is NOT the final power state of the player,
- as it may be overridden by a playercontrol.
- Hence it's marked as a private property.
- The final power state can be retrieved by using the 'powered' property.
"""
return self._attr_powered
@property
- def _volume_level(self) -> int | None:
+ def volume_level(self) -> int | None:
"""
Return the current volume level (0..100) of the player.
If the player does not support PlayerFeature.VOLUME_SET,
or the state is (currently) unknown, this property may return None.
-
- Note that this is NOT the final volume level state of the player,
- as it may be overridden by a playercontrol.
- Hence it's marked as a private property.
- The final volume level state can be retrieved by using the 'volume_level' property.
"""
return self._attr_volume_level
@property
- def _volume_muted(self) -> bool | None:
+ def volume_muted(self) -> bool | None:
"""
Return the current mute state of the player.
If the player does not support PlayerFeature.VOLUME_MUTE,
or the state is (currently) unknown, this property may return None.
-
- Note that this is NOT the final muted state of the player,
- as it may be overridden by a playercontrol.
- Hence it's marked as a private property.
- The final muted state can be retrieved by using the 'volume_muted' property.
"""
return self._attr_volume_muted
@property
- def _active_source(self) -> str | None:
+ def active_source(self) -> str | None:
"""
Return the (id of) the active source of the player.
Set to None if the player is not currently playing a source or
the player_id if the player is currently playing a MA queue.
-
- Note that this is NOT the final active source of the player,
- as it may be overridden by a active group/sync membership.
- Hence it's marked as a private property.
- The final active source can be retrieved by using the 'active_source' property.
"""
return self._attr_active_source
@property
- def _current_media(self) -> PlayerMedia | None:
+ def group_members(self) -> list[str]:
"""
- Return the current media being played by the player.
+ Return the group members of the player.
+
+ If there are other players synced/grouped with this player,
+ this should return the id's of players synced to this player,
+ and this should include the player's own id (as first item in the list).
- Note that this is NOT the final current media of the player,
- as it may be overridden by a active group/sync membership.
- Hence it's marked as a private property.
- The final current media can be retrieved by using the 'current_media' property.
+ If there are currently no group members, this should return an empty list.
"""
- return self._attr_current_media
+ return self._attr_group_members
@property
- def _source_list(self) -> list[PlayerSource]:
+ def can_group_with(self) -> set[str]:
"""
- Return list of available (native) sources for this player.
+ Return the id's of players this player can group with.
- Note that this is NOT the final source list of the player,
- as we inject the MA queue source if the player is currently playing a MA queue.
- Hence it's marked as a private property.
- The final source list can be retrieved by using the 'source_list' property.
+ This should return set of player_id's this player can group/sync with
+ or just the provider's instance_id if all players can group with each other.
"""
+ return self._attr_can_group_with
+
+ @cached_property
+ def synced_to(self) -> str | None:
+ """Return the id of the player this player is synced to (sync leader)."""
+ # default implementation, feel free to override if your
+ # provider has a more efficient way to determine this
+ if self.group_members and self.group_members[0] != self.player_id:
+ return self.group_members[0]
+ for player in self.mass.players.all_players(
+ return_unavailable=False, return_protocol_players=True
+ ):
+ if player.type == PlayerType.GROUP:
+ continue
+ if self.player_id in player.group_members and player.player_id != self.player_id:
+ return player.player_id
+ return None
+
+ @property
+ def current_media(self) -> PlayerMedia | None:
+ """Return the current media being played by the player."""
+ return self._attr_current_media
+
+ @property
+ def source_list(self) -> list[PlayerSource]:
+ """Return list of available (native) sources for this player."""
return self._attr_source_list
+ @property
+ def active_sound_mode(self) -> str | None:
+ """Return active sound mode of this player."""
+ return self._attr_active_sound_mode
+
+ @cached_property
+ def sound_mode_list(self) -> UniqueList[PlayerSoundMode]:
+ """Return available PlayerSoundModes for Player."""
+ return UniqueList(self._attr_sound_mode_list)
+
+ @cached_property
+ def options(self) -> UniqueList[PlayerOption]:
+ """Return all PlayerOptions for Player."""
+ return UniqueList(self._attr_options)
+
async def power(self, powered: bool) -> None:
"""
Handle POWER command on the player.
"""Handle PLAY command on the player."""
raise NotImplementedError("play needs to be implemented")
- @abstractmethod
async def stop(self) -> None:
"""
Handle STOP command on the player.
- Will only be called if the player reports PlayerFeature.PAUSE is supported or
- player supports resuming of stopped playback.
+ Will be called to stop the stream/playback if the player has play_media support.
"""
- raise NotImplementedError("stop needs to be implemented")
+ raise NotImplementedError(
+ "stop needs to be implemented when PlayerFeature.PLAY_MEDIA is set"
+ )
async def pause(self) -> None:
"""
Handle NEXT_TRACK command on the player.
Will only be called if the player reports PlayerFeature.NEXT_PREVIOUS
- is supported and the player is not currently playing a MA queue.
+ is supported and the player's currently selected source supports it.
"""
raise NotImplementedError(
"next_track needs to be implemented when PlayerFeature.NEXT_PREVIOUS is set"
Handle PREVIOUS_TRACK command on the player.
Will only be called if the player reports PlayerFeature.NEXT_PREVIOUS
- is supported and the player is not currently playing a MA queue.
+ is supported and the player's currently selected source supports it.
"""
raise NotImplementedError(
"previous_track needs to be implemented when PlayerFeature.NEXT_PREVIOUS is set"
"""
raise NotImplementedError("seek needs to be implemented when PlayerFeature.SEEK is set")
- @abstractmethod
async def play_media(
self,
media: PlayerMedia,
:param media: Details of the item that needs to be played on the player.
"""
- raise NotImplementedError("play_media needs to be implemented")
+ raise NotImplementedError(
+ "play_media needs to be implemented when PlayerFeature.PLAY_MEDIA is set"
+ )
+
+ async def on_protocol_playback(
+ self,
+ output_protocol: OutputProtocol,
+ ) -> None:
+ """
+ Handle callback when playback starts on a protocol output.
+
+ Called by the Player Controller after play_media is executed on a protocol player.
+ Allows the native player implementation to perform special logic when protocol
+ playback starts.
+
+ Optional - providers can override to implement protocol-specific logic.
+
+ :param output_protocol: The OutputProtocol object containing protocol details.
+ """
+ return # Optional callback - no-op by default
async def enqueue_next_media(self, media: PlayerMedia) -> None:
"""
# no need to implement unless your player/provider has an optimized way to execute this
# default implementation will simply call set_members
# to add the target player to the group.
- target_player = self.mass.players.get(target_player_id, raise_unavailable=True)
+ target_player = self.mass.players.get_player(target_player_id, raise_unavailable=True)
assert target_player # for type checking
await target_player.set_members(player_ids_to_add=[self.player_id])
# no need to implement unless your player/provider has an optimized way to execute this
# default implementation will simply call set_members
if self.synced_to:
- if parent_player := self.mass.players.get(self.synced_to):
+ if parent_player := self.mass.players.get_player(self.synced_to):
# if this player is synced to another player, remove self from that group
await parent_player.set_members(player_ids_to_remove=[self.player_id])
elif self.group_members:
await self.set_members(player_ids_to_remove=self.group_members)
- @property
- def synced_to(self) -> str | None:
- """
- Return the id of the player this player is synced to (sync leader).
-
- If this player is not synced to another player (or is the sync leader itself),
- this should return None.
- If it is part of a (permanent) group, this should also return None.
- """
- # default implementation: feel free to override
- for player in self.mass.players.all():
- if player.player_id == self.player_id:
- # skip self
- continue
- if player.type == PlayerType.PLAYER and self.player_id in player.group_members:
- # this player is synced to another player, but not part of a (permanent) group
- return player.player_id
- return None
-
- @property
- def active_sound_mode(self) -> str | None:
- """Return active sound mode of this player."""
- return self._attr_active_sound_mode
-
- @cached_property
- def sound_mode_list(self) -> UniqueList[PlayerSoundMode]:
- """Return available PlayerSoundModes for Player."""
- return UniqueList(self._attr_sound_mode_list)
-
- @cached_property
- def options(self) -> UniqueList[PlayerOption]:
- """Return all PlayerOptions for Player."""
- return UniqueList(self._attr_options)
-
def _on_player_media_updated(self) -> None: # noqa: B027
"""Handle callback when the current media of the player is updated."""
# optional callback for players that want to be informed when the final
@cached_property
@final
def display_name(self) -> str:
- """Return the display name of the player."""
+ """Return the (FINAL) display name of the player."""
if custom_name := self._config.name:
# always prefer the custom name over the default name
return custom_name
return self.name or self._config.default_name or self.player_id
- @property
- @final
- def powered(self) -> bool | None:
- """
- Return the FINAL power state of the player.
-
- This is a convenience property which calculates the final power state
- based on the playercontrol which may have been set-up.
- """
- power_control = self.power_control
- if power_control == PLAYER_CONTROL_FAKE:
- return bool(self.extra_data.get(ATTR_FAKE_POWER, False))
- if power_control == PLAYER_CONTROL_NATIVE:
- return self._powered
- if power_control == PLAYER_CONTROL_NONE:
- return None
- if control := self.mass.players.get_player_control(power_control):
- return control.power_state
- return None
-
- @property
- @final
- def volume_level(self) -> int | None:
- """
- Return the FINAL volume level of the player.
-
- This is a convenience property which calculates the final volume level
- based on the playercontrol which may have been set-up.
- """
- volume_control = self.volume_control
- if volume_control == PLAYER_CONTROL_FAKE:
- return int(self.extra_data.get(ATTR_FAKE_VOLUME, 0))
- if volume_control == PLAYER_CONTROL_NATIVE:
- return self._volume_level
- if volume_control == PLAYER_CONTROL_NONE:
- return None
- if control := self.mass.players.get_player_control(volume_control):
- return control.volume_level
- return None
-
- @property
- @final
- def volume_muted(self) -> bool | None:
- """
- Return the FINAL mute state of the player.
-
- This is a convenience property which calculates the final mute state
- based on the playercontrol which may have been set-up.
- """
- mute_control = self.mute_control
- if mute_control == PLAYER_CONTROL_FAKE:
- return bool(self.extra_data.get(ATTR_FAKE_MUTE, False))
- if mute_control == PLAYER_CONTROL_NATIVE:
- return self._volume_muted
- if mute_control == PLAYER_CONTROL_NONE:
- return None
- if control := self.mass.players.get_player_control(mute_control):
- return control.volume_muted
- return None
-
- @property
- @final
- def active_source(self) -> str | None:
- """
- Return the FINAL active source of the player.
-
- This is a convenience property which calculates the final active source
- based on any group memberships or source plugins that can be active.
- """
- # if the player is grouped/synced, use the active source of the group/parent player
- if parent_player_id := (self.active_group or self.synced_to):
- if parent_player_id != self.player_id and (
- parent_player := self.mass.players.get(parent_player_id)
- ):
- return parent_player.active_source
- for plugin_source in self.mass.players.get_plugin_sources():
- if plugin_source.in_use_by == self.player_id:
- return plugin_source.id
- if (
- self.playback_state in (PlaybackState.PLAYING, PlaybackState.PAUSED)
- and self._active_source
- ):
- # active source as reported by the player itself
- # but only if playing/paused, otherwise we always prefer the MA source
- return self._active_source
- # return the (last) known MA source
- return self.__active_mass_source
-
- @cached_property
- @final
- def source_list(self) -> UniqueList[PlayerSource]:
- """
- Return the FINAL source list of the player.
-
- This is a convenience property with the calculated final source list
- based on any group memberships or source plugins that can be active.
- """
- return self.__attr_source_list or UniqueList()
-
@cached_property
@final
def enabled(self) -> bool:
return self.elapsed_time + (time.time() - self.elapsed_time_last_updated)
return self.elapsed_time
- @property
- @final
- def active_groups(self) -> list[str]:
- """
- Return the player ids of all playergroups that are currently active for this player.
-
- This will return the ids of the groupplayers if any groups are active.
- If no groups are currently active, this will return an empty list.
- """
- return self.__attr_active_groups or []
-
- @property
- @final
- def active_group(self) -> str | None:
- """
- Return the player id of the (first) playergroup that is currently active for this player.
-
- This will return the id of the groupplayer if a group is active.
- If no group is currently active, this will return None.
- """
- active_groups = self.active_groups
- return active_groups[0] if active_groups else None
-
- @property
- @final
- def current_media(self) -> PlayerMedia | None:
- """
- Return the current media being played by the player.
-
- This is a convenience property with the calculates current media
- based on any group memberships or source plugins that can be active.
- """
- return self.__attr_current_media
-
@cached_property
@final
def icon(self) -> str:
@final
def power_control(self) -> str:
"""Return the power control type."""
- if conf := self._config.get_value(CONF_POWER_CONTROL):
+ if conf := self.mass.config.get_raw_player_config_value(self.player_id, CONF_POWER_CONTROL):
return str(conf)
+ # not explicitly set, use native if supported
+ if PlayerFeature.POWER in self.supported_features:
+ return PLAYER_CONTROL_NATIVE
+ # note that we do not try to use protocol players for power control,
+ # as this is very unlikely to be provided by a generic protocol and if it does,
+ # it will be handled automatically on stream start/stop.
return PLAYER_CONTROL_NONE
@cached_property
@final
def volume_control(self) -> str:
"""Return the volume control type."""
- if conf := self._config.get_value(CONF_VOLUME_CONTROL):
+ if conf := self.mass.config.get_raw_player_config_value(
+ self.player_id, CONF_VOLUME_CONTROL
+ ):
return str(conf)
+ # not explicitly set, use native if supported
+ if PlayerFeature.VOLUME_SET in self.supported_features:
+ return PLAYER_CONTROL_NATIVE
+ # check for protocol player with volume support, and use that if found
+ if protocol_player := self._get_protocol_player_for_feature(PlayerFeature.VOLUME_SET):
+ return protocol_player.player_id
return PLAYER_CONTROL_NONE
@cached_property
@final
def mute_control(self) -> str:
"""Return the mute control type."""
- if conf := self._config.get_value(CONF_MUTE_CONTROL):
+ if conf := self.mass.config.get_raw_player_config_value(self.player_id, CONF_MUTE_CONTROL):
return str(conf)
+ # not explicitly set, use native if supported
+ if PlayerFeature.VOLUME_MUTE in self.supported_features:
+ return PLAYER_CONTROL_NATIVE
+ # check for protocol player with volume mute support, and use that if found
+ if protocol_player := self._get_protocol_player_for_feature(PlayerFeature.VOLUME_MUTE):
+ return protocol_player.player_id
return PLAYER_CONTROL_NONE
- @property
+ @cached_property
@final
def group_volume(self) -> int:
"""
If the player is not a group player or syncgroup, this will return the volume level
of the player itself (if set), or 0 if not set.
"""
- if len(self.group_members) == 0:
+ if len(self.state.group_members) == 0:
# player is not a group or syncgroup
- return self.volume_level or 0
+ return self.state.volume_level or 0
# calculate group volume from all (turned on) players
group_volume = 0
active_players = 0
for child_player in self.mass.players.iter_group_members(
self, only_powered=True, exclude_self=self.type != PlayerType.PLAYER
):
- if (child_volume := child_player.volume_level) is None:
+ if (child_volume := child_player.state.volume_level) is None:
continue
group_volume += child_volume
active_players += 1
"""
return bool(self.mass.players.get_active_queue(self))
- @property
+ @cached_property
@final
def flow_mode(self) -> bool:
"""
Return if the player needs flow mode.
Will use 'requires_flow_mode' unless overridden by flow_mode config.
+ Considers the active output protocol's flow_mode if a protocol is active.
"""
+ # If an output protocol is active (and not native), use the protocol player's flow_mode
+ # The protocol player will handle its own config check
+ if (
+ self.__attr_active_output_protocol
+ and self.__attr_active_output_protocol != "native"
+ and (
+ protocol_player := self.mass.players.get_player(self.__attr_active_output_protocol)
+ )
+ ):
+ return protocol_player.flow_mode
+ # Check native player's config override
if bool(self._config.get_value(CONF_FLOW_MODE)) is True:
# flow mode explicitly enabled in config
return True
return self.requires_flow_mode
+ @property
+ @final
+ def supports_enqueue(self) -> bool:
+ """
+ Return if the player supports enqueueing tracks.
+
+ This considers the active output protocol's capabilities if one is active.
+ If a protocol player is active, checks that protocol's ENQUEUE feature.
+ Otherwise checks the native player's ENQUEUE feature.
+ """
+ return self._check_feature_with_active_protocol(PlayerFeature.ENQUEUE)
+
@property
@final
def state(self) -> PlayerState:
- """Return the current PlayerState of the player."""
+ """Return the current (and FINAL) PlayerState of the player."""
return self._state
+ # Protocol-related properties and helpers
+
+ @cached_property
+ @final
+ def is_native_player(self) -> bool:
+ """Return True if this player is a native player."""
+ is_universal_player = self.provider.domain == "universal_player"
+ has_play_media = PlayerFeature.PLAY_MEDIA in self.supported_features
+ return self.type != PlayerType.PROTOCOL and not is_universal_player and has_play_media
+
+ @cached_property
+ @final
+ def output_protocols(self) -> list[OutputProtocol]:
+ """
+ Return all output options for this player.
+
+ Includes:
+ - Native playback (if player supports PLAY_MEDIA and is not a protocol/universal player)
+ - Active protocol players from linked_output_protocols
+ - Disabled protocols from cached linked_protocol_player_ids in config
+
+ Each entry has an available flag indicating current availability.
+ """
+ result: list[OutputProtocol] = []
+
+ # Add native playback option if applicable
+ if self.is_native_player:
+ result.append(
+ OutputProtocol(
+ output_protocol_id="native",
+ name=self.provider.name,
+ protocol_domain=self.provider.domain,
+ priority=0, # Native is always highest priority
+ available=self.available,
+ is_native=True,
+ )
+ )
+
+ # Add active protocol players
+ active_ids: set[str] = set()
+ for linked in self.__attr_linked_protocols:
+ active_ids.add(linked.output_protocol_id)
+ # Check if the protocol player is actually available
+ protocol_player = self.mass.players.get_player(linked.output_protocol_id)
+ is_available = protocol_player.available if protocol_player else False
+ if protocol_player and not is_available:
+ self.logger.debug(
+ "Protocol player %s (%s) is unavailable for %s",
+ linked.output_protocol_id,
+ linked.protocol_domain,
+ self.display_name,
+ )
+ # Use provider name if available, else domain title
+ if protocol_player:
+ name = protocol_player.provider.name
+ else:
+ name = linked.protocol_domain.title() if linked.protocol_domain else "Unknown"
+ result.append(
+ OutputProtocol(
+ output_protocol_id=linked.output_protocol_id,
+ name=name,
+ protocol_domain=linked.protocol_domain,
+ priority=linked.priority,
+ available=is_available,
+ )
+ )
+
+ # Add disabled protocols from cache
+ cached_protocol_ids: list[str] = self.mass.config.get(
+ f"{CONF_PLAYERS}/{self.player_id}/values/{CONF_LINKED_PROTOCOL_PLAYER_IDS}",
+ [],
+ )
+ for protocol_id in cached_protocol_ids:
+ if protocol_id in active_ids:
+ continue # Already included above
+ # Get stored config to determine protocol domain
+ if raw_conf := self.mass.config.get(f"{CONF_PLAYERS}/{protocol_id}"):
+ provider_id = raw_conf.get("provider", "")
+ protocol_domain = provider_id.split("--")[0] if provider_id else "unknown"
+ priority = PROTOCOL_PRIORITY.get(protocol_domain, 100)
+ result.append(
+ OutputProtocol(
+ output_protocol_id=protocol_id,
+ name=protocol_domain.title(),
+ protocol_domain=protocol_domain,
+ priority=priority,
+ available=False, # Disabled protocols are not available
+ )
+ )
+
+ # Sort by priority (lower = more preferred)
+ result.sort(key=lambda o: o.priority)
+ return result
+
+ @property
+ @final
+ def linked_output_protocols(self) -> list[OutputProtocol]:
+ """Return the list of actively linked output protocol players."""
+ return self.__attr_linked_protocols
+
+ @property
+ @final
+ def protocol_parent_id(self) -> str | None:
+ """Return the parent player_id if this is a protocol player linked to a native player."""
+ return self.__attr_protocol_parent_id
+
+ @property
+ @final
+ def active_output_protocol(self) -> str | None:
+ """Return the currently active output protocol ID."""
+ return self.__attr_active_output_protocol
+
+ @final
+ def set_active_output_protocol(self, protocol_id: str | None) -> None:
+ """
+ Set the currently active output protocol ID.
+
+ :param protocol_id: The protocol player_id to set as active, "native" for native playback,
+ or None to clear the active protocol.
+ """
+ if self.__attr_active_output_protocol == protocol_id:
+ return # No change
+ if protocol_id == self.player_id:
+ protocol_id = "native" # Normalize to "native" for native player
+ if protocol_id:
+ protocol_name = protocol_id
+ if protocol_id == "native":
+ protocol_name = "Native"
+ elif protocol_player := self.mass.players.get_player(protocol_id):
+ protocol_name = protocol_player.provider.name
+ self.logger.info(
+ "Setting active output protocol on %s to %s",
+ self.display_name,
+ protocol_name,
+ )
+ else:
+ self.logger.info(
+ "Clearing active output protocol on %s",
+ self.display_name,
+ )
+ self.__attr_active_output_protocol = protocol_id
+ self.update_state()
+
+ @final
+ def set_linked_output_protocols(self, protocols: list[OutputProtocol]) -> None:
+ """
+ Set the actively linked output protocol players.
+
+ :param protocols: List of OutputProtocol objects representing active protocol players.
+ """
+ self.__attr_linked_protocols = protocols
+ self.mass.players.trigger_player_update(self.player_id)
+
+ @final
+ def set_protocol_parent_id(self, parent_id: str | None) -> None:
+ """
+ Set the parent player_id for protocol players.
+
+ :param parent_id: The player_id of the parent player, or None to clear.
+ """
+ self.__attr_protocol_parent_id = parent_id
+ self.mass.players.trigger_player_update(self.player_id)
+
+ @final
+ def get_linked_protocol(self, protocol_domain: str) -> OutputProtocol | None:
+ """Get a linked protocol by domain with current availability."""
+ for linked in self.__attr_linked_protocols:
+ if linked.protocol_domain == protocol_domain:
+ protocol_player = self.mass.players.get_player(linked.output_protocol_id)
+ current_available = protocol_player.available if protocol_player else False
+ return OutputProtocol(
+ output_protocol_id=linked.output_protocol_id,
+ name=protocol_player.provider.name
+ if protocol_player
+ else linked.protocol_domain.title(),
+ protocol_domain=linked.protocol_domain,
+ priority=linked.priority,
+ available=current_available,
+ is_native=False,
+ )
+ return None
+
+ @final
+ def get_protocol_player(self, player_id: str) -> Player | None:
+ """Get the protocol Player for a given player_id."""
+ if player_id == "native":
+ return self if PlayerFeature.PLAY_MEDIA in self.supported_features else None
+ return self.mass.players.get_player(player_id)
+
+ @final
+ def get_preferred_protocol_player(self) -> Player | None:
+ """Get the best available protocol player by priority."""
+ for linked in sorted(self.__attr_linked_protocols, key=lambda x: x.priority):
+ if protocol_player := self.mass.players.get_player(linked.output_protocol_id):
+ if protocol_player.available:
+ return protocol_player
+ return None
+
@final
def update_state(self, force_update: bool = False, signal_event: bool = True) -> None:
"""
- Update the PlayerState with the current state of the player.
+ Update the PlayerState from the current state of the player.
This method should be called to update the player's state
and signal any changes to the PlayerController.
self._cache.clear()
# calculate the new state
prev_media_checksum = self._get_player_media_checksum()
- changed_values = self.__calculate_state()
+ changed_values = self.__calculate_player_state()
if prev_media_checksum != self._get_player_media_checksum():
# current media changed, call the media updated callback
self._on_player_media_updated()
# return early if nothing changed (unless force_update is True)
if len(changed_values) == 0 and not force_update:
return
+
# signal the state update to the PlayerController
if signal_event:
self.mass.players.signal_player_state_update(self, changed_values)
"""
# TODO: validate that caller is the PlayerController ?
self._config = config
+ self.mass.players.trigger_player_update(self.player_id)
@final
def to_dict(self) -> dict[str, Any]:
f"Player {self.display_name} does not support feature {feature.name}"
)
+ @final
def _get_player_media_checksum(self) -> str:
"""Return a checksum for the current media."""
- if not (media := self.current_media):
+ if not (media := self.state.current_media):
return ""
return (
f"{media.uri}|{media.title}|{media.source_id}|{media.queue_item_id}|"
f"{media.image_url}|{media.duration}|{media.elapsed_time}"
)
- def __calculate_state(
+ @final
+ def _check_feature_with_active_protocol(
+ self, feature: PlayerFeature, active_only: bool = False
+ ) -> bool:
+ """
+ Check if a feature is supported considering the active output protocol.
+
+ If an active output protocol is set (and not native), checks that protocol
+ player's features. Otherwise checks the native player's features.
+
+ :param feature: The PlayerFeature to check.
+ :return: True if the feature is supported by the active protocol or native player.
+ """
+ # If active output protocol is set and not native, check protocol player's features
+ if (
+ self.__attr_active_output_protocol
+ and self.__attr_active_output_protocol != "native"
+ and (
+ protocol_player := self.mass.players.get_player(self.__attr_active_output_protocol)
+ )
+ ):
+ return feature in protocol_player.supported_features
+ # Otherwise check native player's features
+ return feature in self.supported_features
+
+ @final
+ def _get_protocol_player_for_feature(
+ self,
+ feature: PlayerFeature,
+ ) -> Player | None:
+ """Get player(protocol) which has the given PlayerFeature."""
+ # prefer native player
+ if feature in self.supported_features:
+ return self
+ # Otherwise, use the first available linked protocol
+ for linked in self.linked_output_protocols:
+ if (
+ (protocol_player := self.mass.players.get_player(linked.output_protocol_id))
+ and protocol_player.available
+ and feature in protocol_player.supported_features
+ ):
+ return protocol_player
+
+ return None
+
+ @final
+ def __calculate_player_state(
self,
) -> dict[str, tuple[Any, Any]]:
"""
- Calculate the (current) PlayerState.
+ Calculate the (current) and FINAL PlayerState.
This method is called when we're updating the player,
and we compare the current state with the previous state to determine
Returns a dict with the state attributes that have changed.
"""
- self.__attr_active_groups = self.__calculate_active_groups()
- self.__attr_current_media = self.__calculate_current_media()
- self.__attr_source_list = self.__calculate_source_list()
+ playback_state, elapsed_time, elapsed_time_last_updated = self.__final_playback_state
prev_state = deepcopy(self._state)
self._state = PlayerState(
player_id=self.player_id,
type=self.type,
available=self.enabled and self.available,
device_info=self.device_info,
- supported_features=self.supported_features,
- playback_state=self.playback_state,
- elapsed_time=self.elapsed_time,
- elapsed_time_last_updated=self.elapsed_time_last_updated,
- powered=self.powered,
- volume_level=self.volume_level,
- volume_muted=self.volume_muted,
- group_members=UniqueList(self.group_members),
+ supported_features=self.__final_supported_features,
+ playback_state=playback_state,
+ elapsed_time=elapsed_time,
+ elapsed_time_last_updated=elapsed_time_last_updated,
+ powered=self.__final_power_state,
+ volume_level=self.__final_volume_level,
+ volume_muted=self.__final_volume_muted_state,
+ group_members=UniqueList(self.__final_group_members),
static_group_members=UniqueList(self.static_group_members),
- can_group_with=self.can_group_with,
- synced_to=self.synced_to,
- active_source=self.active_source,
- source_list=self.source_list,
+ can_group_with=self.__final_can_group_with,
+ synced_to=self.__final_synced_to,
+ active_source=self.__final_active_source,
+ source_list=self.__final_source_list,
+ active_group=self.__final_active_group,
+ current_media=self.__final_current_media,
active_sound_mode=self.active_sound_mode,
sound_mode_list=self.sound_mode_list,
options=self.options,
- active_group=self.active_group,
- current_media=self.current_media,
name=self.display_name,
enabled=self.enabled,
hide_in_ui=self.hide_in_ui,
power_control=self.power_control,
volume_control=self.volume_control,
mute_control=self.mute_control,
+ output_protocols=self.output_protocols,
+ active_output_protocol=self.__attr_active_output_protocol,
)
- # correct group_members if needed
- if self._state.group_members == [self.player_id]:
- self._state.group_members.clear()
- elif (
- self._state.group_members
- and self.player_id not in self._state.group_members
- and self.type == PlayerType.PLAYER
- ):
- self._state.group_members.set([self.player_id, *self._state.group_members])
-
# track stop called state
if (
prev_state.playback_state == PlaybackState.IDLE
and self._state.playback_state == PlaybackState.IDLE
):
self.__stop_called = True
-
- # Auto correct player state if player is synced (or group child)
- # This is because some players/providers do not accurately update this info
- # for the sync child's.
- if self._state.synced_to and (sync_leader := self.mass.players.get(self._state.synced_to)):
- self._state.playback_state = sync_leader.playback_state
- self._state.elapsed_time = sync_leader.elapsed_time
- self._state.elapsed_time_last_updated = sync_leader.elapsed_time_last_updated
+ self.__active_mass_source = None
return get_changed_dataclass_values(
prev_state,
recursive=True,
)
- __attr_active_groups: list[str] | None = None
+ @cached_property
+ @final
+ def __final_playback_state(self) -> tuple[PlaybackState, float | None, float | None]:
+ """
+ Return the FINAL playback state based on the playercontrol which may have been set-up.
+
+ Returns a tuple of (playback_state, elapsed_time, elapsed_time_last_updated).
+ """
+ # If an output protocol is active (and not native), use the protocol player's state
+ if (
+ self.__attr_active_output_protocol
+ and self.__attr_active_output_protocol != "native"
+ and (
+ protocol_player := self.mass.players.get_player(self.__attr_active_output_protocol)
+ )
+ ):
+ return (
+ protocol_player.state.playback_state,
+ protocol_player.state.elapsed_time,
+ protocol_player.state.elapsed_time_last_updated,
+ )
+ # if we're synced/grouped, use the parent player's state
+ parent_id = self.__final_synced_to or self.__final_active_group
+ if parent_id and (parent_player := self.mass.players.get_player(parent_id)):
+ return (
+ parent_player.state.playback_state,
+ parent_player.state.elapsed_time,
+ parent_player.state.elapsed_time_last_updated,
+ )
+ return (self.playback_state, self.elapsed_time, self.elapsed_time_last_updated)
+
+ @cached_property
+ @final
+ def __final_power_state(self) -> bool | None:
+ """Return the FINAL power state based on the playercontrol which may have been set-up."""
+ power_control = self.power_control
+ if power_control == PLAYER_CONTROL_FAKE:
+ return bool(self.extra_data.get(ATTR_FAKE_POWER, False))
+ if power_control == PLAYER_CONTROL_NATIVE:
+ return self.powered
+ if power_control == PLAYER_CONTROL_NONE:
+ return None
+ # handle player control for power if set
+ if control := self.mass.players.get_player_control(power_control):
+ return control.power_state
+ return None
+
+ @cached_property
+ @final
+ def __final_volume_level(self) -> int | None:
+ """Return the FINAL volume level based on the playercontrol which may have been set-up."""
+ volume_control = self.volume_control
+ if volume_control == PLAYER_CONTROL_FAKE:
+ return int(self.extra_data.get(ATTR_FAKE_VOLUME, 0))
+ if volume_control == PLAYER_CONTROL_NATIVE:
+ return self.volume_level
+ if volume_control == PLAYER_CONTROL_NONE:
+ return None
+ # handle protocol player as volume control
+ if control := self.mass.players.get_player(volume_control):
+ return control.volume_level
+ # handle player control for volume if set
+ if player_control := self.mass.players.get_player_control(volume_control):
+ return player_control.volume_level
+ return None
+
+ @cached_property
+ @final
+ def __final_volume_muted_state(self) -> bool | None:
+ """Return the FINAL mute state based on any playercontrol which may have been set-up."""
+ mute_control = self.mute_control
+ if mute_control == PLAYER_CONTROL_FAKE:
+ return bool(self.extra_data.get(ATTR_FAKE_MUTE, False))
+ if mute_control == PLAYER_CONTROL_NATIVE:
+ return self.volume_muted
+ if mute_control == PLAYER_CONTROL_NONE:
+ return None
+ # handle protocol player as mute control
+ if control := self.mass.players.get_player(mute_control):
+ return control.volume_muted
+ # handle player control for mute if set
+ if player_control := self.mass.players.get_player_control(mute_control):
+ return player_control.volume_muted
+ return None
+
+ @cached_property
+ @final
+ def __final_active_group(self) -> str | None:
+ """
+ Return the player id of any playergroup that is currently active for this player.
- def __calculate_active_groups(self) -> list[str]:
- """Calculate the active groups for the player."""
- active_groups = []
- for player in self.mass.players.all(return_unavailable=False, return_disabled=False):
- if player.type != PlayerType.GROUP:
+ This will return the id of the groupplayer if any groups are active.
+ If no groups are currently active, this will return None.
+ """
+ if self.type == PlayerType.PROTOCOL:
+ # protocol players should not have an active group,
+ # they follow the group state of their parent player
+ return None
+ for group_player in self.mass.players.all_players(
+ return_unavailable=False, return_disabled=False
+ ):
+ if group_player.type != PlayerType.GROUP:
continue
- if player.player_id == self.player_id:
+ if group_player.player_id == self.player_id:
continue
- if not (player.powered or player.playback_state == PlaybackState.PLAYING):
+ if group_player.playback_state not in (PlaybackState.PLAYING, PlaybackState.PAUSED):
continue
- if self.player_id in player.group_members:
- active_groups.append(player.player_id)
- return active_groups
-
- __attr_current_media: PlayerMedia | None = None
+ if self.player_id in group_player.group_members:
+ return group_player.player_id
+ return None
- def __calculate_current_media(self) -> PlayerMedia | None:
- """Calculate the current media for the player."""
+ @cached_property
+ @final
+ def __final_current_media(self) -> PlayerMedia | None:
+ """Return the FINAL current media for the player."""
if self.extra_data.get(ATTR_ANNOUNCEMENT_IN_PROGRESS):
# if an announcement is in progress, return announcement details
return PlayerMedia(
media_type=MediaType.ANNOUNCEMENT,
title="ANNOUNCEMENT",
)
+
# if the player is grouped/synced, use the current_media of the group/parent player
- if parent_player_id := (self.active_group or self.synced_to):
+ if parent_player_id := (self.__final_active_group or self.__final_synced_to):
if parent_player_id != self.player_id and (
- parent_player := self.mass.players.get(parent_player_id)
+ parent_player := self.mass.players.get_player(parent_player_id)
):
- return parent_player.current_media
+ return parent_player.state.current_media
+ # if this is a protocol player, use the current_media of the parent player
+ if self.type == PlayerType.PROTOCOL and self.__attr_protocol_parent_id:
+ if parent_player := self.mass.players.get_player(self.__attr_protocol_parent_id):
+ return parent_player.state.current_media
# if a pluginsource is currently active, return those details
+ active_source = self.__final_active_source
if (
- self.active_source
- and (source := self.mass.players.get_plugin_source(self.active_source))
+ active_source
+ and (source := self.mass.players.get_plugin_source(active_source))
and source.metadata
):
return PlayerMedia(
)
# if MA queue is active, return those details
active_queue = None
- if self._current_media and self._current_media.source_id:
- active_queue = self.mass.player_queues.get(self._current_media.source_id)
- if not active_queue and self.active_source:
- active_queue = self.mass.player_queues.get(self.active_source)
- if not active_queue and self._active_source is None:
+ if self.current_media and self.current_media.source_id:
+ active_queue = self.mass.player_queues.get(self.current_media.source_id)
+ if not active_queue and active_source:
+ active_queue = self.mass.player_queues.get(active_source)
+ if not active_queue and self.active_source is None:
active_queue = self.mass.player_queues.get(self.player_id)
if active_queue and (current_item := active_queue.current_item):
# queue is active but no current item
return None
# return native current media if no group/queue is active
- if self._current_media:
+ if self.current_media:
return PlayerMedia(
- uri=self._current_media.uri,
- media_type=self._current_media.media_type,
- title=self._current_media.title,
- artist=self._current_media.artist,
- album=self._current_media.album,
- image_url=self._current_media.image_url,
- duration=self._current_media.duration,
- source_id=self._current_media.source_id or self._active_source,
- queue_item_id=self._current_media.queue_item_id,
- elapsed_time=self._current_media.elapsed_time or int(self.elapsed_time)
+ uri=self.current_media.uri,
+ media_type=self.current_media.media_type,
+ title=self.current_media.title,
+ artist=self.current_media.artist,
+ album=self.current_media.album,
+ image_url=self.current_media.image_url,
+ duration=self.current_media.duration,
+ source_id=self.current_media.source_id or active_source,
+ queue_item_id=self.current_media.queue_item_id,
+ elapsed_time=self.current_media.elapsed_time or int(self.elapsed_time)
if self.elapsed_time
else None,
- elapsed_time_last_updated=self._current_media.elapsed_time_last_updated
+ elapsed_time_last_updated=self.current_media.elapsed_time_last_updated
or self.elapsed_time_last_updated,
)
return None
- __attr_source_list: UniqueList[PlayerSource] | None = None
-
- def __calculate_source_list(self) -> UniqueList[PlayerSource]:
- """Calculate the source list for the player."""
- sources = UniqueList(self._source_list)
+ @cached_property
+ @final
+ def __final_source_list(self) -> UniqueList[PlayerSource]:
+ """Return the FINAL source list for the player."""
+ sources = UniqueList(self.source_list)
+ if self.type == PlayerType.PROTOCOL:
+ return sources
# always ensure the Music Assistant Queue is in the source list
mass_source = next((x for x in sources if x.id == self.player_id), None)
if mass_source is None:
sources.append(plugin_source)
return sources
+ @cached_property
+ @final
+ def __final_group_members(self) -> list[str]:
+ """Return the FINAL group members of this player."""
+ if self.__final_synced_to:
+ # If player is synced to another player, it has no group members itself
+ return []
+
+ members = self.group_members.copy()
+ # If there's an active linked protocol, include its group members (translated)
+ if self.__attr_active_output_protocol and self.__attr_active_output_protocol != "native":
+ if protocol_player := self.mass.players.get_player(self.__attr_active_output_protocol):
+ # Translate protocol player IDs to visible player IDs
+ protocol_members = self._translate_protocol_ids_to_visible(
+ set(protocol_player.group_members)
+ )
+ for member in protocol_members:
+ if member.player_id not in members:
+ members.append(member.player_id)
+
+ if self.type != PlayerType.GROUP:
+ # Ensure the player_id is first in the group_members list
+ if len(members) > 0 and members[0] != self.player_id:
+ members = [self.player_id, *[m for m in members if m != self.player_id]]
+ # If the only member is self, return empty list
+ if members == [self.player_id]:
+ return []
+ return members
+
+ @cached_property
+ @final
+ def __final_synced_to(self) -> str | None:
+ """
+ Return the FINAL synced_to state.
+
+ This checks both native sync state and protocol player sync state,
+ translating protocol player IDs to visible player IDs.
+ """
+ # First check the native synced_to from the property
+ if native_synced_to := self.synced_to:
+ return native_synced_to
+
+ for linked in self.__attr_linked_protocols:
+ if not (protocol_player := self.mass.players.get_player(linked.output_protocol_id)):
+ continue
+ if protocol_player.synced_to:
+ # Protocol player is synced, translate to visible player
+ if proto_sync_parent := self.mass.players.get_player(protocol_player.synced_to):
+ if proto_sync_parent.protocol_parent_id and (
+ parent := self.mass.players.get_player(proto_sync_parent.protocol_parent_id)
+ ):
+ return parent.player_id
+
+ return None
+
+ @cached_property
+ @final
+ def __final_supported_features(self) -> set[PlayerFeature]:
+ """Return the FINAL supported features based supported output protocol(s)."""
+ base_features = self.supported_features.copy()
+ if self.__attr_active_output_protocol and self.__attr_active_output_protocol != "native":
+ # Active linked protocol: add from that specific protocol
+ if protocol_player := self.mass.players.get_player(self.__attr_active_output_protocol):
+ for feature in protocol_player.supported_features:
+ if feature in ACTIVE_PROTOCOL_FEATURES:
+ base_features.add(feature)
+ # Append (allowed features) from all linked protocols
+ for linked in self.__attr_linked_protocols:
+ if protocol_player := self.mass.players.get_player(linked.output_protocol_id):
+ for feature in protocol_player.supported_features:
+ if feature in PROTOCOL_FEATURES:
+ base_features.add(feature)
+ return base_features
+
+ @cached_property
+ @final
+ def __final_can_group_with(self) -> set[str]:
+ """
+ Return the FINAL set of player id's this player can group with.
+
+ This is a convenience property which calculates the final can_group_with set
+ based on any linked protocol players and current player/grouped state.
+
+ If player is synced to a native parent: return empty set (already grouped).
+ If player is synced to a protocol: can still group with other players.
+ If no active linked protocol: return can_group_with from all active output protocols.
+ If active linked protocol: return native can_group_with + active protocol's.
+
+ All protocol player IDs are translated to their visible parent player IDs.
+ """
+ result: set[str] = set()
+
+ def _should_include_player(player: Player) -> bool:
+ """Check if a player should be included in the can-group-with set."""
+ if not player.available:
+ return False
+ if player.player_id == self.player_id:
+ return False # Don't include self
+ # Don't include (playing) players that have group members (they are group leaders)
+ if (
+ player.state.playback_state in (PlaybackState.PLAYING, PlaybackState.PAUSED)
+ and player.group_members
+ and player.type != PlayerType.PROTOCOL
+ ):
+ return False # Regular native group leader - exclude
+ # Don't include players that are currently grouped/synced to OTHER players
+ # But DO include players grouped to THIS player (so they can be ungrouped)
+ grouped_to = player.state.synced_to or player.state.active_group
+ return grouped_to is None or grouped_to == self.player_id
+
+ if self.__final_synced_to:
+ # player is already synced/grouped, cannot group with others
+ return result
+
+ # always start with the native can_group_with options (expanded for provider instance IDs)
+ for player in self._expand_can_group_with():
+ if not _should_include_player(player):
+ continue
+ result.add(player.player_id)
+
+ # Scenario 1: Player is a protocol player - just return the (expanded) result
+ if self.type == PlayerType.PROTOCOL:
+ return result
+
+ # Translate can_group_with from active linked protocol(s) and add to result
+ for linked in self.__attr_linked_protocols:
+ if protocol_player := self.mass.players.get_player(linked.output_protocol_id):
+ for player in self._translate_protocol_ids_to_visible(
+ protocol_player.state.can_group_with
+ ):
+ if not _should_include_player(player):
+ continue
+ result.add(player.player_id)
+ return result
+
+ @cached_property
+ @final
+ def __final_active_source(self) -> str | None:
+ """
+ Calculate the final active source based on any group memberships, source plugins etc.
+
+ Note: When an output protocol is active, the source remains the parent player's
+ source since protocol players don't have their own queue/source - they only
+ handle the actual streaming/playback.
+ """
+ # if the player is grouped/synced, use the active source of the group/parent player
+ if parent_player_id := (self.__final_synced_to or self.__final_active_group):
+ if parent_player := self.mass.players.get_player(parent_player_id):
+ return parent_player.state.active_source
+ # always prioritize active MA source
+ # (it is set on playback start and cleared on stop)
+ if self.__active_mass_source:
+ return self.__active_mass_source
+ # if a plugin source is active that belongs to this player, return that
+ for plugin_source in self.mass.players.get_plugin_sources():
+ if plugin_source.in_use_by == self.player_id:
+ return plugin_source.id
+ # active source as reported by the player itself, but only if playing/paused
+ if self.playback_state != PlaybackState.IDLE and self.active_source:
+ return self.active_source
+ # return the (last) known MA source
+ return self.__last_active_mass_source
+
+ @final
+ def _translate_protocol_ids_to_visible(self, player_ids: set[str]) -> set[Player]:
+ """
+ Translate protocol player IDs to their visible parent players.
+
+ Protocol players are hidden and users interact with visible players
+ (native or universal). This method translates protocol player IDs
+ back to the visible (parent) players.
+
+ :param player_ids: Set of player IDs (protocol player IDs).
+ :return: Set of visible players.
+ """
+ result: set[Player] = set()
+ if not player_ids:
+ return result
+ for player_id in player_ids:
+ target_player = self.mass.players.get_player(player_id)
+ if not target_player or target_player.type != PlayerType.PROTOCOL:
+ continue
+ # This is a protocol player - find its visible parent
+ if not target_player.protocol_parent_id:
+ continue
+ parent_player = self.mass.players.get_player(target_player.protocol_parent_id)
+ if not parent_player:
+ continue
+ result.add(parent_player)
+ return result
+
+ @final
+ def _expand_can_group_with(self) -> set[Player]:
+ """
+ Expand the 'can-group-with' to include all players from provider instance IDs.
+
+ This method expands any provider instance IDs (e.g., "airplay", "chromecast")
+ in the group members to all (available) players of that provider
+
+ :return: Set of available players in the can-group-with.
+ """
+ result = set()
+
+ for member_id in self.can_group_with:
+ if player := self.mass.players.get_player(member_id):
+ result.add(player)
+ continue # already a player ID
+ # Check if member_id is a provider instance ID
+ if provider := self.mass.get_provider(member_id):
+ for player in self.mass.players.all_players(
+ return_unavailable=False, # Only include available players
+ provider_filter=provider.instance_id,
+ return_protocol_players=True,
+ ):
+ result.add(player)
+ return result
+
# The id of the (last) active mass source.
# This is to keep track of the last active MA source for the player,
# so we can restore it when needed (e.g. after switching to a plugin source).
- __active_mass_source: str = ""
+ __active_mass_source: str | None = None
+ __last_active_mass_source: str | None = None
+ @final
def set_active_mass_source(self, value: str) -> None:
"""
- Set the id of the (last) active mass source.
+ Set the id of the active mass source.
This is to keep track of the last active MA source for the player,
so we can restore it when needed (e.g. after switching to a plugin source).
"""
self.__active_mass_source = value
+ self.__last_active_mass_source = value
self.update_state()
__stop_called: bool = False
+ @final
def mark_stop_called(self) -> None:
"""Mark that the STOP command was called on the player."""
self.__stop_called = True
+ self.__active_mass_source = None
@property
+ @final
def stop_called(self) -> bool:
"""
Return True if the STOP command was called on the player.
@property
def players(self) -> list[Player]:
"""Return all players belonging to this provider."""
- return self.mass.players.all(provider_filter=self.instance_id, return_sync_groups=False)
+ return self.mass.players.all_players(
+ provider_filter=self.instance_id, return_protocol_players=True
+ )
class Provider:
"""Base representation of a Provider implementation within Music Assistant."""
+ mass: MusicAssistant
+ manifest: ProviderManifest
+ config: ProviderConfig
+
def __init__(
self,
mass: MusicAssistant,
from typing import TYPE_CHECKING
from music_assistant_models.config_entries import ConfigEntry, ConfigValueType
-from music_assistant_models.enums import ConfigEntryType, PlaybackState, PlayerFeature, PlayerType
+from music_assistant_models.enums import ConfigEntryType, PlaybackState, PlayerFeature
from music_assistant_models.player import PlayerSource
from music_assistant.models.player import Player, PlayerMedia
super().__init__(provider, player_id)
# init some static variables
self._attr_name = f"Demo Player {player_id}"
- self._attr_type = PlayerType.PLAYER
self._attr_supported_features = {
+ PlayerFeature.PLAY_MEDIA,
PlayerFeature.POWER,
PlayerFeature.VOLUME_SET,
PlayerFeature.VOLUME_MUTE,
# OPTIONAL
# used in conjunction with the needs_poll property.
# this should return the interval in seconds to poll the player for state updates.
- return 5 if self.playback_state == PlaybackState.PLAYING else 30
+ return 5 if self._attr_playback_state == PlaybackState.PLAYING else 30
@property
- def _source_list(self) -> list[PlayerSource]:
+ def source_list(self) -> list[PlayerSource]:
"""Return list of available (native) sources for this player."""
# OPTIONAL - required only if you specified PlayerFeature.SELECT_SOURCE
# this is an optional property that you can implement if your
# In this demo implementation we just optimistically set the state.
# In a real implementation you actually send a command to the player
# wait for the player to report a new state before updating the playback state.
+ url = await self.provider.mass.streams.resolve_stream_url(self.player_id, media)
logger = self.provider.logger.getChild(self.player_id)
- logger.info(
- "Received PLAY_MEDIA command on player %s with uri %s", self.display_name, media.uri
- )
+ logger.info("Received PLAY_MEDIA command on player %s with url %s", self.display_name, url)
self._attr_current_media = media
self._attr_playback_state = PlaybackState.PLAYING
self.update_state()
# handle removed player
if state_change == ServiceStateChange.Removed:
# check if the player manager has an existing entry for this player
- if mass_player := self.mass.players.get(player_id):
+ if mass_player := self.mass.players.get_player(player_id):
# the player has become unavailable
self.logger.debug("Player offline: %s", mass_player.display_name)
await self.mass.players.unregister(player_id)
# check if we have an existing player in the player manager
# note that you can use this point to update the player connection info
# if that changed (e.g. ip address)
- if mass_player := self.mass.players.get(player_id):
+ if mass_player := self.mass.players.get_player(player_id):
# existing player found in the player manager,
# this is an existing player that has been updated/reconnected
# or simply a re-announcement on mdns.
return False
+def is_apple_device(manufacturer: str) -> bool:
+ """Check if a device is an Apple device with native AirPlay support.
+
+ Apple devices (HomePod, Apple TV, Mac, etc.) have native AirPlay support
+ and should be exposed as PlayerType.PLAYER. Non-Apple devices with AirPlay
+ support should be exposed as PlayerType.PROTOCOL.
+ """
+ return manufacturer.lower() == "apple"
+
+
async def get_cli_binary(protocol: StreamingProtocol) -> str:
"""Find the correct raop/airplay binary belonging to the platform.
from typing import TYPE_CHECKING, cast
from music_assistant_models.config_entries import ConfigEntry, ConfigValueOption, ConfigValueType
-from music_assistant_models.enums import ConfigEntryType, PlaybackState, PlayerFeature, PlayerType
+from music_assistant_models.enums import (
+ ConfigEntryType,
+ IdentifierType,
+ PlaybackState,
+ PlayerFeature,
+ PlayerType,
+)
from music_assistant.constants import CONF_ENTRY_SYNC_ADJUST, create_sample_rates_config_entry
from music_assistant.models.player import DeviceInfo, Player, PlayerMedia
from .helpers import (
get_primary_ip_address_from_zeroconf,
is_airplay2_preferred_model,
+ is_apple_device,
is_broken_airplay_model,
player_id_to_mac_address,
)
self._active_pairing: AirPlayPairing | None = None
self._transitioning = False # Set during stream replacement to ignore stale DACP messages
# Set (static) player attributes
- self._attr_type = PlayerType.PLAYER
self._attr_name = display_name
self._attr_available = True
+ mac_address = player_id_to_mac_address(player_id)
self._attr_device_info = DeviceInfo(
model=model,
manufacturer=manufacturer,
)
- self._attr_device_info.ip_address = address
- self._attr_device_info.mac_address = player_id_to_mac_address(player_id)
+ self._attr_device_info.add_identifier(IdentifierType.MAC_ADDRESS, mac_address)
+ self._attr_device_info.add_identifier(IdentifierType.IP_ADDRESS, address)
self._attr_supported_features = {
+ PlayerFeature.PLAY_MEDIA,
PlayerFeature.PAUSE,
PlayerFeature.SET_MEMBERS,
PlayerFeature.MULTI_DEVICE_DSP,
self._attr_can_group_with = {provider.instance_id}
self._attr_enabled_by_default = not is_broken_airplay_model(manufacturer, model)
+ # Set player type based on manufacturer:
+ # - Apple devices (HomePod, Apple TV, Mac) have native AirPlay support -> PLAYER
+ # - Non-Apple devices are generic AirPlay receivers -> PROTOCOL (wrapped in UniversalPlayer)
+ if is_apple_device(manufacturer):
+ self._attr_type = PlayerType.PLAYER
+ else:
+ self._attr_type = PlayerType.PROTOCOL
+
@property
def protocol(self) -> StreamingProtocol:
"""Get the streaming protocol to use/prefer for this player."""
"""Return if the player requires flow mode."""
return True
- @property
- def corrected_elapsed_time(self) -> float:
- """Return the corrected elapsed time accounting for stream session restarts."""
- if not self.stream or not self.stream.session:
- return super().corrected_elapsed_time or 0.0
- session = self.stream.session
- elapsed = time.time() - session.start_time - session.total_pause_time
- if session.last_paused is not None:
- current_pause = time.time() - session.last_paused
- elapsed -= current_pause
- return max(0.0, elapsed)
-
async def get_config_entries(
self,
action: str | None = None,
provider = cast("AirPlayProvider", self.provider)
stream_session = AirPlayStreamSession(provider, sync_clients, AIRPLAY_FLOW_PCM_FORMAT)
await stream_session.start(audio_source)
+ self._attr_elapsed_time = time.time() - stream_session.start_time
+ self._attr_elapsed_time_last_updated = time.time()
self._transitioning = False
async def volume_set(self, volume_level: int) -> None:
if child_player.player_id in self._attr_group_members:
self._attr_group_members.remove(child_player.player_id)
+ # If group leader is left alone after removals, clear the group_members list
+ if (
+ self._attr_group_members
+ and len(self._attr_group_members) == 1
+ and self.player_id in self._attr_group_members
+ ):
+ self._attr_group_members = []
+
# handle additions
for player_id in player_ids_to_add or []:
if player_id == self.player_id or player_id in self.group_members:
# nothing to do: player is already part of the group
continue
child_player_to_add: AirPlayPlayer | None = cast(
- "AirPlayPlayer | None", self.mass.players.get(player_id)
+ "AirPlayPlayer | None", self.mass.players.get_player(player_id)
)
if not child_player_to_add:
# should not happen, but guard against it
# ensure the child does not have an existing stream session active
if child_player_to_add := cast(
- "AirPlayPlayer | None", self.mass.players.get(player_id)
+ "AirPlayPlayer | None", self.mass.players.get_player(player_id)
):
if (
child_player_to_add.playback_state == PlaybackState.PAUSED
if stream_session:
await stream_session.add_client(child_player_to_add)
+ # Ensure group leader includes itself in group_members when it has members
+ # This is required for the synced_to property to work correctly
+ if self._attr_group_members and self.player_id not in self._attr_group_members:
+ self._attr_group_members.insert(0, self.player_id)
+
# always update the state after modifying group members
self.update_state()
"""Handle callback when the current media of the player is updated."""
if not self.stream or not self.stream.running or not self.stream.session:
return
- metadata = self.current_media
+ metadata = self.state.current_media
if not metadata:
return
progress = int(metadata.corrected_elapsed_time or 0)
return
if cur_address != new_address:
self.logger.debug("Address updated from %s to %s", cur_address, new_address)
+ self._attr_device_info.add_identifier(IdentifierType.IP_ADDRESS, new_address)
self.address = new_address
- self._attr_device_info.ip_address = new_address
self.update_state()
def set_state_from_stream(
# Ignore state updates from old/stale streams
if stream is not None and stream != self.stream:
return
-
if state is not None:
- prev_state = self._attr_playback_state
self._attr_playback_state = state
- if self.stream and self.stream.session:
- if prev_state == PlaybackState.PLAYING and state != PlaybackState.PLAYING:
- self.stream.session.last_paused = time.time()
- elif prev_state != PlaybackState.PLAYING and state == PlaybackState.PLAYING:
- if self.stream.session.last_paused is not None:
- pause_duration = time.time() - self.stream.session.last_paused
- self.stream.session.total_pause_time += pause_duration
- self.stream.session.last_paused = None
if elapsed_time is not None:
self._attr_elapsed_time = elapsed_time
self._attr_elapsed_time_last_updated = time.time()
group_child_ids = {self.player_id}
group_child_ids.update(self.group_members)
for child_id in group_child_ids:
- if client := cast("AirPlayPlayer | None", self.mass.players.get(child_id)):
+ if client := cast("AirPlayPlayer | None", self.mass.players.get_player(child_id)):
sync_clients.append(client)
return sync_clients
player.set_state_from_stream(
state=PlaybackState.PLAYING, elapsed_time=0, stream=self
)
+ elif "elapsed milliseconds:" in line:
+ # this is received more or less every second while playing
+ millis = int(line.split("elapsed milliseconds: ")[1])
+ # note that this represents the total elapsed time of the streaming session
+ elapsed_time = millis / 1000
+ player.set_state_from_stream(elapsed_time=elapsed_time)
if "lost packet out of backlog" in line:
lost_packets += 1
if lost_packets == 100:
player_id = f"ap{raw_id.lower()}"
# handle removed player
if state_change == ServiceStateChange.Removed:
- if _player := self.mass.players.get(player_id):
+ if _player := self.mass.players.get_player(player_id):
# the player has become unavailable
self.logger.debug("Player offline: %s", _player.display_name)
await self.mass.players.unregister(player_id)
# handle update for existing device
assert info is not None # type guard
player: AirPlayPlayer | None
- if player := cast("AirPlayPlayer | None", self.mass.players.get(player_id)):
+ if player := cast("AirPlayPlayer | None", self.mass.players.get_player(player_id)):
# update the latest discovery info for existing player
player.set_discovery_info(info, display_name)
return
):
volume = FALLBACK_VOLUME
- # Append airplay to the default name for non-apple devices
- # to make it easier for users to distinguish
- is_apple = manufacturer.lower() == "apple"
- if not is_apple and "airplay" not in display_name.lower():
- display_name += " (AirPlay)"
-
# Final check before registration to handle race conditions
# (multiple MDNS events processed in parallel for same device)
- if self.mass.players.get(player_id):
+ if self.mass.players.get_player(player_id):
self.logger.debug(
"Player %s already registered during setup, skipping registration", player_id
)
self.mass.config.get_raw_player_config_value(player_id, CONF_IGNORE_VOLUME, False)
or player.device_info.manufacturer.lower() == "apple"
)
- active_queue = self.mass.player_queues.get_active_queue(player_id)
+ active_queue = self.mass.players.get_active_queue(player)
if not active_queue:
self.logger.warning(
"DACP request for %s (%s) but no active queue found, ignoring request",
def get_player(self, player_id: str) -> AirPlayPlayer | None:
"""Return AirplayPlayer by id."""
- return cast("AirPlayPlayer | None", self.mass.players.get(player_id))
+ return cast("AirPlayPlayer | None", self.mass.players.get_player(player_id))
self.start_time: float = 0.0
self.wait_start: float = 0.0
self.seconds_streamed: float = 0
- self.total_pause_time: float = 0.0
- self.last_paused: float | None = None
self._first_chunk_received = asyncio.Event()
async def start(self, audio_source: AsyncGenerator[bytes, None]) -> None:
)
if not allow_late_join:
await self.stop()
- if sync_leader.current_media:
+ if sync_leader.state.current_media:
self.mass.call_later(
0.5,
self.mass.players.cmd_resume(sync_leader.player_id),
*(
ConfigValueOption(x.display_name, x.player_id)
for x in sorted(
- mass.players.all(False, False), key=lambda p: p.display_name.lower()
+ mass.players.all_players(False, False), key=lambda p: p.display_name.lower()
)
),
],
# If there's an active player (source was selected on a player), use it
if self._active_player_id:
# Validate that the active player still exists
- if self.mass.players.get(self._active_player_id):
+ if self.mass.players.get_player(self._active_player_id):
return self._active_player_id
# Active player no longer exists, clear it
self._active_player_id = None
# Handle auto selection
if self._default_player_id == PLAYER_ID_AUTO:
- all_players = list(self.mass.players.all(False, False))
+ all_players = list(self.mass.players.all_players(False, False))
# First, try to find a playing player
for player in all_players:
if player.state.playback_state == PlaybackState.PLAYING:
return None
# Use the specific default player if configured and it still exists
- if self.mass.players.get(self._default_player_id):
+ if self.mass.players.get_player(self._default_player_id):
return self._default_player_id
self.logger.warning(
"Configured default player '%s' no longer exists", self._default_player_id
# Convert player volume (0-100) to AirPlay volume (-30.0 to 0.0 dB)
player_volume = 100 # Default to 100%
if self._default_player_id and self._default_player_id != PLAYER_ID_AUTO:
- if _player := self.mass.players.get(self._default_player_id):
+ if _player := self.mass.players.get_player(self._default_player_id):
if _player.volume_level is not None:
player_volume = _player.volume_level
# Map 0-100 to -30.0...0.0
super().__init__(provider, player_id)
self.device = device
self._attr_supported_features = {
+ PlayerFeature.PLAY_MEDIA,
PlayerFeature.VOLUME_SET,
PlayerFeature.PAUSE,
}
if username is not None and password is not None:
auth = BasicAuth(str(username), str(password))
+ stream_url = await self.provider.mass.streams.resolve_stream_url(self.player_id, media)
+
async with aiohttp.ClientSession() as session:
try:
async with session.post(
f"{self.provider.config.get_value(CONF_API_URL)}/ma/push-url",
json={
- "streamUrl": media.uri,
+ "streamUrl": stream_url,
"title": media.title,
"artist": media.artist,
"album": media.album,
PLAYBACK_POLL_INTERVAL = 10
PLAYER_FEATURES_BASE = {
+ PlayerFeature.PLAY_MEDIA,
PlayerFeature.SET_MEMBERS,
PlayerFeature.VOLUME_MUTE,
PlayerFeature.PAUSE,
import time
from typing import TYPE_CHECKING
-from music_assistant_models.enums import PlaybackState, PlayerFeature, PlayerType
+from music_assistant_models.config_entries import ConfigEntry, ConfigValueType
+from music_assistant_models.enums import IdentifierType, PlaybackState, PlayerFeature
from music_assistant_models.errors import PlayerCommandFailed
from pyblu import Player as BluosPlayer
from pyblu import Status, SyncStatus
self.dynamic_poll_count: int = 0
self._listen_task: asyncio.Task | None = None
# Set base player attributes
- self._attr_type = PlayerType.PLAYER
self._attr_supported_features = PLAYER_FEATURES_BASE.copy()
self._attr_name = name
self._attr_device_info = DeviceInfo(
model=discovery_info.get("model", "BluOS Device"),
manufacturer="BluOS",
)
- self._attr_device_info.ip_address = ip_address
+ self._attr_device_info.add_identifier(IdentifierType.IP_ADDRESS, ip_address)
+ if mac_address := discovery_info.get("mac"):
+ self._attr_device_info.add_identifier(IdentifierType.MAC_ADDRESS, mac_address)
self._attr_available = True
self._attr_source_list = []
self._attr_needs_poll = True
"""Handle PLAY MEDIA for BluOS player using the provided URL."""
self.logger.debug("Play_media called")
self.logger.debug(media)
- play_state = await self.client.play_url(media.uri, timeout=1)
+ url = await self.provider.mass.streams.resolve_stream_url(self.player_id, media)
+ play_state = await self.client.play_url(url, timeout=1)
# Enable dynamic polling
if play_state == "stream":
return
def player_id_to_paired_player(player_id: str) -> PairedPlayer:
- client = self.mass.players.get(player_id, raise_unavailable=True)
+ client = self.mass.players.get_player(player_id, raise_unavailable=True)
return PairedPlayer(client.ip_address, client.port)
if player_ids_to_remove:
except (PlayerUnexpectedResponseError, PlayerUnreachableError) as err:
self.logger.debug(f"Could not remove players: {err!s}")
continue
- removed_player = self.mass.players.get(player_id)
+ removed_player = self.mass.players.get_player(player_id)
if removed_player:
removed_player._set_polling_dynamic()
removed_player._attr_current_media = None
self.logger.debug(f"Could not add player {paired_player}: {err!s}")
continue
self._attr_group_members.append(player_id)
- added_player = self.mass.players.get(player_id)
+ added_player = self.mass.players.get_player(player_id)
if added_player:
added_player._set_polling_dynamic()
added_player.update_state()
"""Handle UNGROUP command for BluOS player."""
leader = self.client.leader
leader_player_id = self.client.provider.player_map((leader.ip, leader.port))
- await self.mass.player.get(leader_player_id).set_members(None, [self.player_id])
+ await self.mass.players.get_player(leader_player_id).set_members(None, [self.player_id])
async def poll(self) -> None:
"""Poll player for state updates."""
# Handle update of existing player
assert player_id is not None # for type checker
- if bluos_player := self.mass.players.get(player_id):
+ if bluos_player := self.mass.players.get_player(player_id):
bluos_player = cast("BluesoundPlayer", bluos_player)
# Check if the IP address has changed
if ip_address and ip_address != bluos_player.ip_address:
is_dynamic_group: bool | None = None
is_multichannel_group: bool = False # group created for e.g. stereo pair
is_multichannel_child: bool = False # speaker that is part of multichannel setup
+ mac_address: str | None = None # MAC address from eureka_info API
@property
def is_audio_group(self) -> bool:
):
self.is_multichannel_child = True
+ # Get MAC address for device matching (not available for groups)
+ if self.mac_address is None and self.cast_type != "group":
+ self.mac_address = get_mac_address(self.services, zconf)
+
def get_multizone_info(services: list[ServiceInfo], zconf: Zeroconf, timeout=30):
"""Get multizone info from eureka endpoint."""
return (dynamic_groups, multichannel_groups)
+def get_mac_address(services: list[ServiceInfo], zconf: Zeroconf, timeout: int = 10) -> str | None:
+ """Get MAC address from Chromecast eureka_info API.
+
+ :param services: List of zeroconf service info.
+ :param zconf: Zeroconf instance.
+ :param timeout: Request timeout in seconds.
+ :return: MAC address string or None if not available.
+ """
+ try:
+ _, status = dial._get_status(
+ services,
+ zconf,
+ "/setup/eureka_info?options=detail",
+ True,
+ timeout,
+ None,
+ )
+ if mac_address := status.get("mac_address"):
+ # Normalize to uppercase with colons
+ mac = mac_address.upper().replace("-", ":")
+ # Ensure proper format
+ if ":" not in mac and len(mac) == 12:
+ mac = ":".join(mac[i : i + 2] for i in range(0, 12, 2))
+ return mac
+ except (urllib.error.HTTPError, urllib.error.URLError, OSError, KeyError, ValueError):
+ pass
+ return None
+
+
class CastStatusListener:
"""
Helper class to handle pychromecast status callbacks.
def multizone_new_cast_status(self, group_uuid, cast_status) -> None:
"""Handle reception of a new CastStatus for a group."""
mass = self.castplayer.mass
- if group_player := mass.players.get(group_uuid):
+ if group_player := mass.players.get_player(group_uuid):
if TYPE_CHECKING:
assert isinstance(group_player, ChromecastPlayer)
if group_player.cc.media_controller.is_active:
from music_assistant_models.enums import (
ConfigEntryType,
EventType,
+ IdentifierType,
MediaType,
PlaybackState,
PlayerFeature,
player_type = PlayerType.STEREO_PAIR
elif cast_info.is_audio_group:
player_type = PlayerType.GROUP
- else:
+ elif self._is_google_device(cast_info):
+ # Google devices (Chromecast, Nest, Google Home) have native Cast support
player_type = PlayerType.PLAYER
+ else:
+ # Non-Google devices are generic Chromecast receivers
+ # Will be wrapped in a UniversalPlayer
+ player_type = PlayerType.PROTOCOL
self.cc = chromecast
self.status_listener: CastStatusListener | None
self.cast_info = cast_info
self.flow_meta_checksum: str | None = None
# set static variables
self._attr_supported_features = {
+ PlayerFeature.PLAY_MEDIA,
PlayerFeature.POWER,
PlayerFeature.VOLUME_SET,
PlayerFeature.PAUSE,
model=self.cast_info.model_name,
manufacturer=self.cast_info.manufacturer or "",
)
- self._attr_device_info.ip_address = self.cast_info.host
+ self._attr_device_info.add_identifier(IdentifierType.IP_ADDRESS, self.cast_info.host)
+ self._attr_device_info.add_identifier(
+ IdentifierType.MAC_ADDRESS, self.cast_info.mac_address
+ )
+ self._attr_device_info.add_identifier(IdentifierType.UUID, str(self.cast_info.uuid))
assert provider.mz_mgr is not None # for type checking
status_listener = CastStatusListener(self, provider.mz_mgr)
self.status_listener = status_listener
)
)
+ @staticmethod
+ def _is_google_device(cast_info: ChromecastInfo) -> bool:
+ """Check if a device is a Google device with native Cast support.
+
+ Google devices (Chromecast, Nest, Google Home) have native Cast support
+ and should be exposed as PlayerType.PLAYER. Non-Google devices with Cast
+ support should be exposed as PlayerType.PROTOCOL.
+ """
+ if not cast_info.manufacturer:
+ # If no manufacturer, check model name for Google devices
+ model = cast_info.model_name.lower() if cast_info.model_name else ""
+ return any(google in model for google in ("chromecast", "google", "nest", "home"))
+ return cast_info.manufacturer.lower() in ("google", "google inc.")
+
@property
def sendspin_mode_enabled(self) -> bool:
"""Return if sendspin mode is enabled for the player."""
"""Return the linked sendspin player if available/enabled."""
if enabled_only and not self.sendspin_mode_enabled:
return None
- if not (sendspin_player := self.mass.players.get(self.sendspin_player_id)):
+ if not (sendspin_player := self.mass.players.get_player(self.sendspin_player_id)):
return None
if not sendspin_player.available:
return None
return sendspin_player
@property
- def supported_features(self) -> set[PlayerFeature]:
+ def _supported_features(self) -> set[PlayerFeature]:
"""Return the supported features for this player."""
try:
if self.sendspin_mode_enabled:
if not sendspin_player_id.startswith("cast-"):
return None
# Search for a Chromecast player with matching sendspin_player_id
- for player in self.mass.players.all():
+ for player in self.mass.players.all_players():
if hasattr(player, "sendspin_player_id"):
if player.sendspin_player_id == sendspin_player_id:
return player.player_id
await self._play_media_sendspin(media)
return
+ media.uri = await self.provider.mass.streams.resolve_stream_url(self.player_id, media)
queuedata = {
"type": "LOAD",
"media": self._create_cc_media_item(media),
return
if self.active_cast_group:
return
- if self.playback_state != PlaybackState.PLAYING:
+ if self._attr_playback_state != PlaybackState.PLAYING:
return
if not (current_media := self.current_media):
return
# handle stereo pairs
if self.cast_info.is_multichannel_group:
self._attr_type = PlayerType.STEREO_PAIR
- self.group_members.clear()
+ self._attr_group_members.clear()
# handle cast groups
if self.cast_info.is_audio_group and not self.cast_info.is_multichannel_group:
assert self.mz_controller is not None # for type checking
self._attr_type = PlayerType.GROUP
self._attr_group_members = [str(UUID(x)) for x in self.mz_controller.members]
self._attr_supported_features = {
+ PlayerFeature.PLAY_MEDIA,
PlayerFeature.POWER,
PlayerFeature.VOLUME_SET,
PlayerFeature.PAUSE,
self._attr_volume_muted = status.volume_muted
new_powered = self.cc.app_id is not None and self.cc.app_id != IDLE_APP_ID
self._attr_powered = new_powered
- if self._attr_powered and not new_powered and self._attr_type == PlayerType.GROUP:
+ if self._attr_powered and not new_powered and self.type == PlayerType.GROUP:
# group is being powered off, update group childs
for child_id in self.group_members:
- if child := self.mass.players.get(child_id):
+ if child := self.mass.players.get_player(child_id):
self.mass.loop.call_soon_threadsafe(child.update_state)
self.mass.loop.call_soon_threadsafe(self.update_state)
# handle player playing from a group
group_player: ChromecastPlayer | None = None
if self.active_cast_group is not None:
- if not (group_player := self.mass.players.get(self.active_cast_group)):
+ if not (group_player := self.mass.players.get_player(self.active_cast_group)):
return
if not isinstance(group_player, ChromecastPlayer):
return
# so we need to update the group child(s) manually
if self.type == PlayerType.GROUP and self.powered:
for child_id in self.group_members:
- if child := self.mass.players.get(child_id):
+ if child := self.mass.players.get_player(child_id):
assert isinstance(child, ChromecastPlayer) # for type checking
if not child.cast_info.is_multichannel_group:
continue
- child._attr_playback_state = self.playback_state
- child._attr_current_media = self.current_media
- child._attr_elapsed_time = self.elapsed_time
- child._attr_elapsed_time_last_updated = self.elapsed_time_last_updated
- child._attr_active_source = self.active_source
+ child._attr_playback_state = self._attr_playback_state
+ child._attr_current_media = self._attr_current_media
+ child._attr_elapsed_time = self._attr_elapsed_time
+ child._attr_elapsed_time_last_updated = self._attr_elapsed_time_last_updated
+ child._attr_active_source = self._active_source
self.mass.loop.call_soon_threadsafe(child.update_state)
self.mass.loop.call_soon_threadsafe(self.update_state)
model=self.cast_info.model_name,
manufacturer=self.cast_info.manufacturer or "",
)
- self._attr_device_info.ip_address = self.cast_info.host
+ self._attr_device_info.add_identifier(IdentifierType.IP_ADDRESS, self.cast_info.host)
+ self._attr_device_info.add_identifier(IdentifierType.UUID, str(self.cast_info.uuid))
+ if self.cast_info.mac_address:
+ self._attr_device_info.add_identifier(
+ IdentifierType.MAC_ADDRESS, self.cast_info.mac_address
+ )
self.mass.loop.call_soon_threadsafe(self.update_state)
if new_available and self.type == PlayerType.PLAYER:
"""Wait for the Sendspin player to connect and become available."""
start_time = time.time()
while (time.time() - start_time) < timeout:
- if sendspin_player := self.mass.players.get(self.sendspin_player_id):
+ if sendspin_player := self.mass.players.get_player(self.sendspin_player_id):
if sendspin_player.available:
self.logger.debug(
"Sendspin player %s is now available", self.sendspin_player_id
self.logger.debug("Discovered new or updated chromecast %s", disc_info)
- castplayer = self.mass.players.get(player_id)
+ castplayer = self.mass.players.get_player(player_id)
if castplayer:
assert isinstance(castplayer, ChromecastPlayer) # for type checking
# if player was already added, the player will take care of reconnects itself.
"credits": [
"[Asyncio UPnP Client library by Steven Looman](https://github.com/StevenLooman/async_upnp_client)"
],
- "requirements": ["async-upnp-client==0.46.2"],
+ "requirements": ["async-upnp-client==0.46.2", "defusedxml==0.7.1"],
"documentation": "https://music-assistant.io/player-support/dlna/",
"multi_instance": false,
"builtin": false,
from collections.abc import Awaitable, Callable, Coroutine, Sequence
from contextlib import suppress
from typing import TYPE_CHECKING, Any, Concatenate
+from urllib.parse import urlparse
-from async_upnp_client.client import UpnpService, UpnpStateVariable
+import defusedxml.ElementTree as DefusedET
+from async_upnp_client.client import UpnpDevice, UpnpService, UpnpStateVariable
from async_upnp_client.exceptions import UpnpError, UpnpResponseError
from async_upnp_client.profiles.dlna import DmrDevice, TransportState
from music_assistant_models.config_entries import ConfigEntry, ConfigValueType
-from music_assistant_models.enums import PlaybackState, PlayerFeature
+from music_assistant_models.enums import IdentifierType, PlaybackState, PlayerFeature, PlayerType
from music_assistant_models.errors import PlayerUnavailableError
-from music_assistant_models.player import DeviceInfo, PlayerMedia
+from music_assistant_models.player import PlayerMedia
from music_assistant.constants import VERBOSE_LOG_LEVEL
from music_assistant.helpers.upnp import create_didl_metadata
-from music_assistant.models.player import Player
+from music_assistant.models.player import DeviceInfo, Player
from .constants import PLAYER_CONFIG_ENTRIES
class DLNAPlayer(Player):
- """DLNA Player."""
+ """DLNA Player.
+
+ All DLNA players are considered generic protocol endpoints (PlayerType.PROTOCOL)
+ and will be wrapped in a UniversalPlayer. Devices with native provider support
+ (e.g., Sonos) are handled by their respective providers and will link to
+ the DLNA player as a protocol output.
+ """
+
+ # All DLNA devices are generic protocol endpoints - no vendor has native DLNA support in MA
+ _attr_type = PlayerType.PROTOCOL
def __init__(
self,
model=self.device.model_name,
manufacturer=self.device.manufacturer,
)
+ # Add UDN (player_id) as UUID identifier for matching with other protocols
+ # Strip the "uuid:" prefix if present for proper matching
+ uuid_value = self.player_id
+ if uuid_value.lower().startswith("uuid:"):
+ uuid_value = uuid_value[5:]
+ self._attr_device_info.add_identifier(IdentifierType.UUID, uuid_value)
+ # Try to extract MAC address from UUID
+ # Many UPnP devices embed MAC in the last 12 chars of UUID
+ # e.g., uuid:4d691234-444c-164e-1234-001f33eaacf1 -> 00:1f:33:ea:ac:f1
+ mac_address = self._extract_mac_from_uuid(uuid_value)
+ if mac_address:
+ self._attr_device_info.add_identifier(IdentifierType.MAC_ADDRESS, mac_address)
+ # Try to extract just the IP from the URL for matching
+ ip_address = self.device.device.presentation_url or self.description_url
+ with suppress(ValueError):
+ parsed = urlparse(ip_address)
+ if parsed.hostname:
+ self._attr_device_info.add_identifier(
+ IdentifierType.IP_ADDRESS, parsed.hostname
+ )
def _handle_event(
self,
):
self.force_poll = True
self.mass.create_task(self.poll())
- self.logger.debug(
+ self.logger.log(
+ VERBOSE_LOG_LEVEL,
"Received new state from event for Player %s: %s",
self.display_name,
state_variable.value,
def _set_player_features(self) -> None:
"""Set Player Features based on config values and capabilities."""
assert self.device is not None # for type checking
- supported_features: set[PlayerFeature] = {
+ supported_features: set[PlayerFeature] = set()
+
+ # Only add PLAY_MEDIA if the device actually supports playback
+ # Passive speakers (like stereo pair satellites) don't have play capability
+ if self.device.has_play_media:
+ supported_features.add(PlayerFeature.PLAY_MEDIA)
# there is no way to check if a dlna player support enqueuing
# so we simply assume it does and if it doesn't
# you'll find out at playback time and we log a warning
- PlayerFeature.ENQUEUE,
- PlayerFeature.GAPLESS_PLAYBACK,
- }
+ supported_features.add(PlayerFeature.ENQUEUE)
+ supported_features.add(PlayerFeature.GAPLESS_PLAYBACK)
+
if self.device.has_volume_level:
supported_features.add(PlayerFeature.VOLUME_SET)
if self.device.has_volume_mute:
supported_features.add(PlayerFeature.PAUSE)
self._attr_supported_features = supported_features
- async def setup(self) -> None:
- """Set up player in MA."""
+ async def setup(self) -> bool:
+ """Set up player in MA.
+
+ :return: True if setup was successful, False if device should be ignored.
+ """
await self._device_connect()
+
+ if self.device and not self.device.has_play_media:
+ self.logger.debug("Ignoring %s - no play capability", self.device.name)
+ return False
+
+ if self.device and await self._is_sonos_passive_speaker():
+ self.logger.debug("Ignoring %s - passive stereo pair speaker", self.device.name)
+ return False
+
self.set_static_attributes()
await self.mass.players.register_or_update(self)
+ return True
+
+ async def _is_sonos_passive_speaker(self) -> bool:
+ """Check if this is a Sonos passive stereo pair speaker.
+
+ Queries the device's own topology. If that returns 403, the device is
+ considered passive (passive satellites and speakers with UPnP disabled
+ block topology queries). If successful, checks for Invisible="1" attribute.
+ """
+ if not self.device:
+ return False
+
+ manufacturer = (self.device.manufacturer or "").lower()
+ if "sonos" not in manufacturer:
+ return False
+
+ # Extract base UUID (strip "uuid:" prefix and "_MR" suffix)
+ our_uuid = self.player_id.removeprefix("uuid:").removesuffix("_MR")
+
+ # Query this device's topology
+ upnp_device = self.device.profile_device.root_device
+ result = await self._check_invisible_in_topology(upnp_device, our_uuid)
+
+ # Return the result: True if passive/403, False if active or check failed
+ return result if result is not None else False
+
+ async def _check_invisible_in_topology(
+ self, upnp_device: UpnpDevice, our_uuid: str
+ ) -> bool | None:
+ """Check if our UUID is marked as Invisible in the topology.
+
+ :param upnp_device: UPnP device to query
+ :param our_uuid: Our device UUID to search for
+ :return: True if invisible/403 error, False if visible, None if check failed
+ """
+ zone_topology_service = None
+ for service in upnp_device.all_services:
+ if "ZoneGroupTopology" in service.service_type:
+ zone_topology_service = service
+ break
+
+ if not zone_topology_service:
+ return None
+
+ try:
+ action = zone_topology_service.action("GetZoneGroupState")
+ if not action:
+ return None
+
+ result = await action.async_call()
+ zone_group_state_xml = result.get("ZoneGroupState", "")
+ if not zone_group_state_xml:
+ return None
+
+ root = DefusedET.fromstring(zone_group_state_xml)
+ for member in root.iter("ZoneGroupMember"):
+ if member.get("UUID", "").upper() == our_uuid.upper():
+ return str(member.get("Invisible", "0")) == "1"
+
+ except UpnpResponseError as err:
+ # 403 Forbidden indicates passive satellite (blocks topology queries)
+ if "403" in str(err):
+ self.logger.debug(
+ "Sonos device %s returned 403 - treating as passive satellite",
+ our_uuid,
+ )
+ return True
+ self.logger.log(
+ VERBOSE_LOG_LEVEL,
+ "Error checking Sonos zone topology: %s",
+ err,
+ )
+ except (UpnpError, DefusedET.ParseError) as err:
+ self.logger.log(
+ VERBOSE_LOG_LEVEL,
+ "Error checking Sonos zone topology: %s",
+ err,
+ )
+
+ return None
def set_static_attributes(self) -> None:
"""Set static attributes."""
await self.stop()
didl_metadata = create_didl_metadata(media)
title = media.title or media.uri
- await self.device.async_set_transport_uri(media.uri, title, didl_metadata)
+ url = await self.provider.mass.streams.resolve_stream_url(self.player_id, media)
+ await self.device.async_set_transport_uri(url, title, didl_metadata)
# Play it
await self.device.async_wait_for_can_play(10)
# optimistically set this timestamp to help in case of a player
raise PlayerUnavailableError from err
finally:
self.force_poll = False
+
+ @staticmethod
+ def _extract_mac_from_uuid(uuid_value: str) -> str | None:
+ """Try to extract MAC address from UUID.
+
+ Many UPnP devices embed the MAC address in the last 12 hex characters of the UUID.
+ E.g., uuid:4d691234-444c-164e-1234-001f33eaacf1 -> 00:1f:33:ea:ac:f1
+
+ :param uuid_value: The UUID string (without 'uuid:' prefix).
+ :return: MAC address string in XX:XX:XX:XX:XX:XX format, or None if not extractable.
+ """
+ # Remove dashes and get last 12 hex characters
+ hex_chars = uuid_value.replace("-", "")
+ if len(hex_chars) < 12:
+ return None
+
+ mac_hex = hex_chars[-12:]
+
+ # Validate it looks like a MAC (all hex characters)
+ try:
+ int(mac_hex, 16)
+ except ValueError:
+ return None
+
+ # Check if it could be a valid MAC (not all zeros or all ones)
+ if mac_hex in ("000000000000", "ffffffffffff", "FFFFFFFFFFFF"):
+ return None
+
+ # Format as XX:XX:XX:XX:XX:XX
+ return ":".join(mac_hex[i : i + 2].upper() for i in range(0, 12, 2))
assert ssdp_udn is not None # for type checking
- if "rincon" in ssdp_udn.lower():
- # ignore Sonos devices
- return
-
discovered_devices.add(ssdp_udn)
await self._device_discovered(ssdp_udn, discovery_info["location"])
player_id=udn,
description_url=description_url,
)
- # will be updated later.
+ # will be updated later when device connects
dlna_player._attr_device_info = DeviceInfo(
model="unknown",
manufacturer="unknown",
)
self.dlnaplayers[udn] = dlna_player
- await dlna_player.setup()
+
+ # Setup will return False if the device should be ignored (e.g., passive speaker)
+ if not await dlna_player.setup():
+ # Remove from dict if it was just added
+ self.dlnaplayers.pop(udn, None)
import time
from typing import TYPE_CHECKING
-from music_assistant_models.enums import PlaybackState, PlayerFeature, PlayerType
+from music_assistant_models.enums import IdentifierType, PlaybackState, PlayerFeature
from music_assistant_models.errors import PlayerCommandFailed, PlayerUnavailableError
from music_assistant.constants import CONF_ENTRY_OUTPUT_CODEC_DEFAULT_MP3
super().__init__(provider, player_id)
self.fully_kiosk = fully_kiosk
# Set player attributes
- self._attr_type = PlayerType.PLAYER
- self._attr_supported_features = {PlayerFeature.VOLUME_SET}
+ self._attr_supported_features = {PlayerFeature.PLAY_MEDIA, PlayerFeature.VOLUME_SET}
self._attr_name = self.fully_kiosk.deviceInfo["deviceName"]
self._attr_device_info = DeviceInfo(
model=self.fully_kiosk.deviceInfo["deviceModel"],
manufacturer=self.fully_kiosk.deviceInfo["deviceManufacturer"],
)
- self._attr_device_info.ip_address = address
+ self._attr_device_info.add_identifier(IdentifierType.IP_ADDRESS, address)
self._attr_available = True
self._attr_needs_poll = True
self._attr_poll_interval = 10
async def play_media(self, media: PlayerMedia) -> None:
"""Handle PLAY MEDIA on given player."""
- await self.fully_kiosk.playSound(media.uri, AUDIOMANAGER_STREAM_MUSIC)
+ url = await self.provider.mass.streams.resolve_stream_url(self.player_id, media)
+ await self.fully_kiosk.playSound(url, AUDIOMANAGER_STREAM_MUSIC)
self._attr_current_media = media
self._attr_elapsed_time = 0
self._attr_elapsed_time_last_updated = time.time()
from hass_client.exceptions import FailedCommand
from music_assistant_models.enums import (
+ IdentifierType,
ImageType,
MediaType,
PlaybackState,
PlayerFeature,
- PlayerType,
)
from music_assistant_models.media_items import MediaItemImage
class HomeAssistantPlayer(Player):
"""Home Assistant Player implementation."""
- _attr_type = PlayerType.PLAYER
-
def __init__(
self,
provider: PlayerProvider,
self._extra_data = extra_player_data
# Set base attributes from Home Assistant state
self._attr_available = hass_state["state"] not in UNAVAILABLE_STATES
- self._attr_device_info = DeviceInfo.from_dict(dev_info)
+ self._attr_device_info = DeviceInfo(
+ model=dev_info.get("model", ""),
+ manufacturer=dev_info.get("manufacturer", ""),
+ software_version=dev_info.get("software_version"),
+ )
+ if mac_address := dev_info.get("mac_address"):
+ self._attr_device_info.add_identifier(IdentifierType.MAC_ADDRESS, mac_address)
self._attr_playback_state = StateMap.get(hass_state["state"], PlaybackState.IDLE)
# Work out supported features
- self._attr_supported_features = set()
+ self._attr_supported_features = {PlayerFeature.PLAY_MEDIA}
hass_supported_features = MediaPlayerEntityFeature(
hass_state["attributes"]["supported_features"]
)
async def play_media(self, media: PlayerMedia) -> None:
"""Handle PLAY MEDIA on given player."""
+ url = await self.provider.mass.streams.resolve_stream_url(self.player_id, media)
extra_data: dict[str, Any] = {
# passing metadata to the player
# so far only supported by google cast, but maybe others can follow
extra_data["bypass_proxy"] = True
# stop the player if it is already playing
- if self.playback_state == PlaybackState.PLAYING:
+ if self._attr_playback_state == PlaybackState.PLAYING:
await self.stop()
await self.hass.call_service(
service="play_media",
target={"entity_id": self.player_id},
service_data={
- "media_content_id": media.uri,
+ "media_content_id": url,
"media_content_type": "music",
"enqueue": "replace",
"extra": extra_data,
def update_player_from_state_msg(entity_id: str, state: CompressedState) -> None:
"""Handle updating MA player with updated info in a HA CompressedState."""
- player = cast("HomeAssistantPlayer | None", self.mass.players.get(entity_id))
+ player = cast("HomeAssistantPlayer | None", self.mass.players.get_player(entity_id))
if player is None:
# edge case - one of our subscribed entities was not available at startup
# and now came available - we should still set it up
async def play_media(self, media: PlayerMedia) -> None:
"""Handle PLAY MEDIA command on given player."""
- await self._device.play_url(media.uri)
+ url = await self.provider.mass.streams.resolve_stream_url(self.player_id, media)
+ await self._device.play_url(url)
self._attr_current_media = media
self._attr_active_source = self.player_id
self.logger.debug("Controller event received: %s", event)
if event == const.EVENT_GROUPS_CHANGED:
- for player in self.mass.players.all(provider_filter=self.instance_id):
+ for player in self.mass.players.all_players(provider_filter=self.instance_id):
assert isinstance(player, HeosPlayer) # for type checking
await player.build_group_list()
devices = await self._heos.get_players()
for device in devices.values():
player_id = str(device.player_id)
- if player := cast("HeosPlayer", self.mass.players.get(player_id)):
+ if player := cast("HeosPlayer", self.mass.players.get_player(player_id)):
self.logger.debug(
"Updating existing HEOS player: %s (%s)", device.name, player_id
)
from aiomusiccast.exceptions import MusicCastGroupException
from aiomusiccast.pyamaha import MusicCastConnectionException
from music_assistant_models.config_entries import ConfigEntry, ConfigValueOption, ConfigValueType
-from music_assistant_models.enums import ConfigEntryType, PlaybackState, PlayerFeature
+from music_assistant_models.enums import (
+ ConfigEntryType,
+ IdentifierType,
+ PlaybackState,
+ PlayerFeature,
+)
from music_assistant_models.player import (
DeviceInfo,
PlayerMedia,
def set_static_attributes(self) -> None:
"""Set static properties."""
self._attr_supported_features = {
+ PlayerFeature.PLAY_MEDIA,
PlayerFeature.VOLUME_SET,
PlayerFeature.VOLUME_MUTE,
PlayerFeature.PAUSE, # for non MA control, see pause method
model=self.physical_device.device.data.model_name or "unknown model",
software_version=(self.physical_device.device.data.system_version or "unknown version"),
)
+ if device_ip := self.physical_device.device.device.ip:
+ self._attr_device_info.add_identifier(IdentifierType.IP_ADDRESS, device_ip)
+ if device_id := self.physical_device.device.data.device_id:
+ self._attr_device_info.add_identifier(IdentifierType.UUID, device_id)
+ # device_id is the MAC address (12 hex chars), format as XX:XX:XX:XX:XX:XX
+ if len(device_id) == 12:
+ mac = ":".join(device_id[i : i + 2].upper() for i in range(0, 12, 2))
+ self._attr_device_info.add_identifier(IdentifierType.MAC_ADDRESS, mac)
# polling
self._attr_needs_poll = True
elif self.zone_device.is_client:
_server = self.zone_device.group_server
_server_id = self._get_player_id_from_zone_device(_server)
- _server_player = cast("MusicCastPlayer | None", self.mass.players.get(_server_id))
+ _server_player = cast(
+ "MusicCastPlayer | None", self.mass.players.get_player(_server_id)
+ )
_server_update_helper: None | UpnpUpdateHelper = None
if _server_player is not None:
_server_update_helper = _server_player.upnp_update_helper
elif self.zone_device.is_client:
_server = self.zone_device.group_server
_server_id = self._get_player_id_from_zone_device(_server)
- _server_player = cast("MusicCastPlayer | None", self.mass.players.get(_server_id))
+ _server_player = cast(
+ "MusicCastPlayer | None", self.mass.players.get_player(_server_id)
+ )
if _server_player is not None and _server_player.upnp_update_helper is not None:
self._attr_active_source = (
self.zone_device.source_id
)
# verify that this source actually exists and is non net
_allowed_sources = self._get_allowed_sources_zone_switch(zone_player)
- mass_player = self.mass.players.get(player_id)
+ mass_player = self.mass.players.get_player(player_id)
if mass_player is None:
# Do not assert here, should the player not yet exist
return
# set other zone unavailable
for zone_device in self.zone_device.other_zones:
- if zone_device_player := self.mass.players.get(
+ if zone_device_player := self.mass.players.get_player(
self._get_player_id_from_zone_device(zone_device)
):
assert isinstance(zone_device_player, MusicCastPlayer) # for type checking
# just in case
if self.zone_device.source_id != "server":
await self.select_source("server")
+ media.uri = await self.provider.mass.streams.resolve_stream_url(self.player_id, media)
await avt_set_url(self.mass.http_session, self.physical_device, player_media=media)
await avt_play(self.mass.http_session, self.physical_device)
# Removing players
if player_ids_to_remove:
for player_id in player_ids_to_remove:
- if player := self.mass.players.get(player_id):
+ if player := self.mass.players.get_player(player_id):
assert isinstance(player, MusicCastPlayer) # for type checking
await player.ungroup()
children_zones: list[str] = [] # list[ma_player_id]
player_ids_to_add = [] if player_ids_to_add is None else player_ids_to_add
for child_id in player_ids_to_add:
- if child_player := self.mass.players.get(child_id):
+ if child_player := self.mass.players.get_player(child_id):
assert isinstance(child_player, MusicCastPlayer) # for type checking
_other_zone_mc: MusicCastZoneDevice | None = None
for x in child_player.zone_device.other_zones:
children.add(child_id)
for child_id in children_zones:
- child_player = self.mass.players.get(child_id)
+ child_player = self.mass.players.get_player(child_id)
if TYPE_CHECKING:
child_player = cast("MusicCastPlayer", child_player)
if child_player.zone_device.state == MusicCastPlayerState.OFF:
child_player_zone_devices: list[MusicCastZoneDevice] = []
for child_id in children:
- child_player = self.mass.players.get(child_id)
+ child_player = self.mass.players.get_player(child_id)
if TYPE_CHECKING:
child_player = cast("MusicCastPlayer", child_player)
child_player_zone_devices.append(child_player.zone_device)
async def unload(self, is_removed: bool = False) -> None:
"""Call on unload."""
- for mc_player in self.mass.players.all(provider_filter=self.instance_id):
+ for mc_player in self.mass.players.all_players(provider_filter=self.instance_id):
assert isinstance(mc_player, MusicCastPlayer) # for type checking
mc_player.physical_device.remove()
if not check:
return
- if self.mass.players.get(device_id) is not None:
+ if self.mass.players.get_player(device_id) is not None:
return
mc_player_known = self.musiccast_player_helpers.get(device_id)
if mc_player_known is not None and (
player_name_default = None
if values and values.get(CONF_MASS_PLAYER_ID):
player_id = str(values.get(CONF_MASS_PLAYER_ID))
- if player := mass.players.get(player_id):
+ if player := mass.players.get_player(player_id):
player_name_default = player.display_name
return (
options=[
ConfigValueOption(x.display_name, x.player_id)
for x in sorted(
- mass.players.all(False, False), key=lambda p: p.display_name.lower()
+ mass.players.all_players(False, False), key=lambda p: p.display_name.lower()
)
],
),
)
# Now try to setup the player instance
- player = self.mass.players.get(self.mass_player_id)
+ player = self.mass.players.get_player(self.mass_player_id)
if not player:
self.logger.info(
f"Player {self.mass_player_id} not found yet, waiting for PLAYER_ADDED event"
self.logger.error("Cannot setup player instance: Plex provider not available")
return
- player = self.mass.players.get(self.mass_player_id)
+ player = self.mass.players.get_player(self.mass_player_id)
if not player:
self.logger.warning(f"Player {self.mass_player_id} not found")
return
# Flag to prevent circular updates when we modify the queue ourselves
self._updating_from_plex = False
- self.player = self.provider.mass.players.get(self._ma_player_id) # type: ignore[arg-type]
+ self.player = self.provider.mass.players.get_player(self._ma_player_id) # type: ignore[arg-type]
self.device_name = f"{self.player.display_name}" if self.player else "Music Assistant"
# Get player name
player_name = "Music Assistant"
if self._ma_player_id:
- player = self.provider.mass.players.get(self._ma_player_id)
+ player = self.provider.mass.players.get_player(self._ma_player_id)
if player:
player_name = player.display_name
async def _ungroup_player_if_needed(self, player_id: str) -> None:
"""Ungroup player before playback if it's part of a group/sync."""
- player = self.provider.mass.players.get(player_id)
+ player = self.provider.mass.players.get_player(player_id)
if not player or player.type == PlayerType.GROUP:
return
- if not (player.synced_to or player.group_members or player.active_group):
+ if not (player.state.synced_to or player.state.group_members or player.state.active_group):
return
LOGGER.debug("Ungrouping player %s before starting playback from Plex", player.display_name)
# Use set_members directly on the group to bypass static member check
if (
- player.active_group
- and (group := self.provider.mass.players.get(player.active_group))
+ player.state.active_group
+ and (group := self.provider.mass.players.get_player(player.state.active_group))
and group.supports_feature(PlayerFeature.SET_MEMBERS)
):
await group.set_members(player_ids_to_remove=[player_id])
elif (
- player.synced_to
- and (sync_leader := self.provider.mass.players.get(player.synced_to))
+ player.state.synced_to
+ and (sync_leader := self.provider.mass.players.get_player(player.state.synced_to))
and sync_leader.supports_feature(PlayerFeature.SET_MEMBERS)
):
await sync_leader.set_members(player_ids_to_remove=[player_id])
- elif player.group_members and player.supports_feature(PlayerFeature.SET_MEMBERS):
+ elif player.state.group_members and player.supports_feature(PlayerFeature.SET_MEMBERS):
await player.set_members(player_ids_to_remove=player.group_members)
async def handle_play_media(self, request: web.Request) -> web.Response:
# Get player name
player_name = "Music Assistant"
if self._ma_player_id:
- player = self.provider.mass.players.get(self._ma_player_id)
+ player = self.provider.mass.players.get_player(self._ma_player_id)
if player:
player_name = player.display_name
# Get player state
state = "stopped"
if self._ma_player_id:
- player = self.provider.mass.players.get(self._ma_player_id)
+ player = self.provider.mass.players.get_player(self._ma_player_id)
if player and player.state:
state_value = (
player.state.value if hasattr(player.state, "value") else str(player.state)
player_id = self._ma_player_id
# Get MA player and queue
- player = self.provider.mass.players.get(player_id) if player_id else None
+ player = self.provider.mass.players.get_player(player_id) if player_id else None
queue = self.provider.mass.player_queues.get(player_id) if player_id else None
# Controllable features for music
return
try:
- player = self.provider.mass.players.get(self._ma_player_id)
+ player = self.provider.mass.players.get_player(self._ma_player_id)
queue = self.provider.mass.player_queues.get(self._ma_player_id)
if (
from typing import TYPE_CHECKING, Any, cast
from urllib.parse import urlencode
-from music_assistant_models.enums import MediaType, PlaybackState, PlayerFeature, PlayerType
+from music_assistant_models.enums import MediaType, PlaybackState, PlayerFeature
from music_assistant.constants import CONF_ENTRY_HTTP_PROFILE
from music_assistant.models.player import Player, PlayerMedia
self.roku = roku
self.queued = queued
self._attr_name = roku_name
- self._attr_type = PlayerType.PLAYER
self._attr_supported_features = {
+ PlayerFeature.PLAY_MEDIA,
PlayerFeature.POWER, # if the player can be turned on/off
PlayerFeature.PAUSE,
PlayerFeature.VOLUME_MUTE,
async def play_media(self, media: PlayerMedia) -> None:
"""Play media command."""
+ stream_url = await self.provider.mass.streams.resolve_stream_url(self.player_id, media)
try:
device_info = await self.roku.update()
)
f_media = {
- "u": media.uri,
+ "u": stream_url,
"t": "a",
"albumName": media.album or "",
"songName": media.title,
if "position" in media_state:
try:
position = int(media_state["position"].split(" ", 1)[0]) / 1000
- if self.elapsed_time is not None:
- if abs(position - self.elapsed_time) > 10:
+ if self._attr_elapsed_time is not None:
+ if abs(position - self._attr_elapsed_time) > 10:
self._attr_current_media = self.queued
self._attr_elapsed_time = position
self._attr_elapsed_time_last_updated = time.time()
self.update_state()
- if not self.current_media or self._attr_playback_state != PlaybackState.PLAYING:
+ if (
+ not self.state.current_media
+ or self._attr_playback_state != PlaybackState.PLAYING
+ ):
return
- image_url = self.current_media.image_url or ""
+ image_url = self.state.current_media.image_url or ""
- album_name = self.current_media.album or ""
- song_name = self.current_media.title or ""
- artist_name = self.current_media.artist or ""
+ album_name = self.state.current_media.album or ""
+ song_name = self.state.current_media.title or ""
+ artist_name = self.state.current_media.artist or ""
if app_running and self.flow_mode:
await self.roku_input(
{
from typing import TYPE_CHECKING, cast
from async_upnp_client.search import async_search
+from music_assistant_models.enums import IdentifierType
from music_assistant_models.player import DeviceInfo
from rokuecp import Roku
# nothing to do, device is already connected
return
# update description url to newly discovered one
- roku_player.device_info.ip_address = ip
+ roku_player.device_info.add_identifier(IdentifierType.IP_ADDRESS, ip)
else:
roku_player = MediaAssistantPlayer(
provider=self,
model_id=device.info.model_number,
manufacturer=device.info.brand,
)
- roku_player._attr_device_info.ip_address = ip
+ roku_player._attr_device_info.add_identifier(IdentifierType.IP_ADDRESS, ip)
+ roku_player._attr_device_info.add_identifier(
+ IdentifierType.SERIAL_NUMBER, device.info.serial_number
+ )
+ if device.info.ethernet_mac:
+ roku_player._attr_device_info.add_identifier(
+ IdentifierType.MAC_ADDRESS, device.info.ethernet_mac
+ )
+ elif device.info.wifi_mac:
+ roku_player._attr_device_info.add_identifier(
+ IdentifierType.MAC_ADDRESS, device.info.wifi_mac
+ )
self.roku_players[player_id] = roku_player
await roku_player.setup()
class SendspinPlayer(Player):
"""A sendspin audio player in Music Assistant."""
+ _attr_type = PlayerType.PROTOCOL
+
api: SendspinClient
unsub_event_cb: Callable[[], None]
unsub_group_event_cb: Callable[[], None]
self.logger = self.provider.logger.getChild(player_id)
# init some static variables
self._attr_name = sendspin_client.name
- self._attr_type = PlayerType.PLAYER
self._attr_supported_features = {
+ PlayerFeature.PLAY_MEDIA,
PlayerFeature.SET_MEMBERS,
PlayerFeature.MULTI_DEVICE_DSP,
PlayerFeature.VOLUME_SET,
self._attr_group_members = []
# 3. assign new leader if there are members left
if len(group_members) > 0 and (
- new_leader := self.mass.players.get(group_members[0])
+ new_leader := self.mass.players.get_player(group_members[0])
):
new_leader = cast("SendspinPlayer", new_leader)
new_leader._attr_group_members = group_members[1:]
"set_members called: adding %s, removing %s", player_ids_to_add, player_ids_to_remove
)
for player_id in player_ids_to_remove or []:
- player = self.mass.players.get(player_id, True)
+ player = self.mass.players.get_player(player_id, True)
player = cast("SendspinPlayer", player) # For type checking
await self.api.group.remove_client(player.api)
for player_id in player_ids_to_add or []:
- player = self.mass.players.get(player_id, True)
+ player = self.mass.players.get_player(player_id, True)
player = cast("SendspinPlayer", player) # For type checking
await self.api.group.add_client(player.api)
# self.group_members will be updated by the group event callback
# Only leader sends metadata
return
- if self.current_media is None:
+ if self.state.current_media is None:
# Clear metadata when no media loaded
self.api.group.set_metadata(Metadata())
return
"""Send the current media metadata to the sendspin group."""
if not self.available:
return
- current_media = self.current_media
+ current_media = self.state.current_media
if current_media is None:
return
# check if we are playing a MA queue item
from contextlib import suppress
from typing import TYPE_CHECKING, TypedDict, cast
-from music_assistant_models.enums import MediaType, PlaybackState, PlayerFeature
+from music_assistant_models.config_entries import ConfigEntry, ConfigValueType
+from music_assistant_models.enums import (
+ IdentifierType,
+ MediaType,
+ PlaybackState,
+ PlayerFeature,
+ PlayerType,
+)
from music_assistant_models.player import DeviceInfo, PlayerMedia
from propcache import under_cached_property as cached_property
-from music_assistant.constants import (
- ATTR_ANNOUNCEMENT_IN_PROGRESS,
- CONF_ENTRY_HTTP_PROFILE_HIDDEN,
- SYNCGROUP_PREFIX,
-)
+from music_assistant.constants import ATTR_ANNOUNCEMENT_IN_PROGRESS, CONF_ENTRY_HTTP_PROFILE_HIDDEN
from music_assistant.models.player import Player
from music_assistant.providers.snapcast.constants import CONF_ENTRY_SAMPLE_RATES_SNAPCAST
from music_assistant.providers.snapcast.ma_stream import SnapcastMAStream
+from music_assistant.providers.sync_group.constants import SGP_PREFIX
if TYPE_CHECKING:
from music_assistant_models.config_entries import ConfigEntry, ConfigValueType
from music_assistant.providers.snapcast.provider import SnapCastProvider
- from music_assistant.providers.snapcast.snap_cntrl_proto import (
- SnapclientProto,
- SnapstreamProto,
- )
+ from music_assistant.providers.snapcast.snap_cntrl_proto import SnapclientProto, SnapstreamProto
class TrackedPlayerState(TypedDict, total=False):
class SnapCastPlayer(Player):
"""SnapCastPlayer."""
+ _attr_type = PlayerType.PROTOCOL
+
def __init__(
self,
provider: SnapCastProvider,
if len(grp_player_ids) < 2 or grp_name not in grp_player_ids:
return None
- if leader_player := self.mass.players.get(grp_name):
+ if leader_player := self.mass.players.get_player(grp_name):
return grp_name if leader_player.available else None
return None
self._attr_available = self.snap_client.connected
host_dict = self.snap_client._client.get("host", {})
- os, arch, ip = (host_dict.get(key, "") for key in ["os", "arch", "ip"])
+ os, arch, ip, mac = (host_dict.get(key, "") for key in ["os", "arch", "ip", "mac"])
self._attr_device_info = DeviceInfo(
model=os,
manufacturer=arch,
)
- self._attr_device_info.ip_address = ip
+ if ip and (host := self.snap_client._client.get("host")):
+ self._attr_device_info.add_identifier(IdentifierType.IP_ADDRESS, host.get("ip"))
+ if mac:
+ self._attr_device_info.add_identifier(IdentifierType.MAC_ADDRESS, mac)
self._attr_supported_features = {
+ PlayerFeature.PLAY_MEDIA,
PlayerFeature.SET_MEMBERS,
PlayerFeature.VOLUME_SET,
PlayerFeature.VOLUME_MUTE,
]
curr_stream_id = player_group.stream
- sync_group_player = None
if curr_ma_stream := self.snap_provider.get_snap_ma_stream(curr_stream_id):
media = curr_ma_stream.media
if media.media_type == MediaType.PLUGIN_SOURCE:
custom_data = media.custom_data or {}
assigned_player = custom_data.get("player_id", "")
- if assigned_player.startswith(SYNCGROUP_PREFIX):
- sync_group_player = self.mass.players.get(assigned_player)
+ if assigned_player.startswith(SGP_PREFIX):
+ sync_group_player = self.mass.players.get_player(assigned_player)
else:
media_src_id = media.source_id or ""
- if media_src_id.startswith(SYNCGROUP_PREFIX):
- sync_group_player = self.mass.players.get(media_src_id)
-
+ if media_src_id.startswith(SGP_PREFIX):
+ sync_group_player = self.mass.players.get_player(media_src_id)
if sync_group_player and self.player_id in (player_ids_to_remove or []):
# players in sync_group_player.group_members will be rejoined
# remove others first
pl.available
for cl_id in members
if (pl_id := self.snap_provider._get_ma_id(cl_id))
- and (pl := self.mass.players.get(pl_id))
+ and (pl := self.mass.players.get_player(pl_id))
],
}
return ""
return snap_group.name
- @cached_property
- def _current_media(self) -> PlayerMedia | None:
- """
- Return the current media being played by the player.
-
- Note that this is NOT the final current media of the player,
- as it may be overridden by a active group/sync membership.
- Hence it's marked as a private property.
- The final current media can be retrieved by using the 'current_media' property.
- """
+ @property
+ def current_media(self) -> PlayerMedia | None:
+ """Return the current media being played by the player."""
if snap_ma_stream := self.active_snap_ma_stream:
return snap_ma_stream.media
return None
@property
- def _active_source(self) -> str | None:
- """
- Return the (id of) the active source of the player.
-
- Only required if the player supports PlayerFeature.SELECT_SOURCE.
-
- Set to None if the player is not currently playing a source or
- the player_id if the player is currently playing a MA queue.
-
- Note that this is NOT the final active source of the player,
- as it may be overridden by a active group/sync membership.
- Hence it's marked as a private property.
- The final active source can be retrieved by using the 'active_source' property.
- """
+ def active_source(self) -> str | None:
+ """Return the (id of) the active source of the player."""
grp = self.snap_client.group
if grp is None or grp.stream is None:
return None
return [
ma_player
for ma_id in self._get_player_ids_of_curr_group()
- if (ma_player := self.mass.players.get(ma_id))
+ if (ma_player := self.mass.players.get_player(ma_id))
]
for snap_client in self._snapserver.clients:
player_id = self._get_ma_id(snap_client.identifier)
- if not (player := self.mass.players.get(player_id, raise_unavailable=False)):
+ if not (player := self.mass.players.get_player(player_id, raise_unavailable=False)):
continue
if player.playback_state != PlaybackState.PLAYING:
continue
def _handle_player_init(self, snap_client: SnapclientProto) -> SnapCastPlayer:
"""Process Snapcast add to Player controller."""
player_id = self._generate_and_register_id(snap_client.identifier)
- player = self.mass.players.get(player_id, raise_unavailable=False)
+ player = self.mass.players.get_player(player_id, raise_unavailable=False)
if not player:
snap_client = self._snapserver.client(self._get_snapclient_id(player_id))
player = SnapCastPlayer(
self._snapserver.synchronize(res)
for client_id in group_members:
ma_player_id = self._get_ma_id(client_id)
- if ma_player := cast("SnapCastPlayer", self.mass.players.get(ma_player_id)):
+ if ma_player := cast("SnapCastPlayer", self.mass.players.get_player(ma_player_id)):
client = self._snapserver.client(client_id)
if client is not None:
if client.group is not None:
if player_id is None:
return None
- if ma_player := self.mass.players.get(player_id):
+ if ma_player := self.mass.players.get_player(player_id):
assert isinstance(ma_player, SnapCastPlayer) # for type checking
return ma_player
SOURCE_TV = "tv"
SOURCE_RADIO = "radio"
-CONF_AIRPLAY_MODE = "airplay_mode"
-
PLAYER_SOURCE_MAP = {
SOURCE_LINE_IN: PlayerSource(
id=SOURCE_LINE_IN,
from aiosonos.const import EventType as SonosEventType
from aiosonos.const import SonosEvent
from aiosonos.exceptions import ConnectionFailed, FailedCommand
-from music_assistant_models.config_entries import ConfigEntry, ConfigValueType
from music_assistant_models.enums import (
- ConfigEntryType,
- EventType,
+ IdentifierType,
MediaType,
PlaybackState,
PlayerFeature,
RepeatMode,
)
from music_assistant_models.errors import PlayerCommandFailed
-from music_assistant_models.player import PlayerMedia
+from music_assistant_models.player import OutputProtocol, PlayerMedia
from music_assistant.constants import (
CONF_ENTRY_HTTP_PROFILE_DEFAULT_2,
create_sample_rates_config_entry,
)
from music_assistant.helpers.tags import async_parse_tags
-from music_assistant.helpers.upnp import get_xml_soap_set_next_url, get_xml_soap_set_url
from music_assistant.models.player import Player
from music_assistant.providers.sonos.const import (
- CONF_AIRPLAY_MODE,
PLAYBACK_STATE_MAP,
PLAYER_SOURCE_MAP,
SOURCE_AIRPLAY,
if TYPE_CHECKING:
from aiosonos.api.models import DiscoveryInfo as SonosDiscoveryInfo
- from music_assistant_models.event import MassEvent
+ from music_assistant_models.config_entries import ConfigEntry, ConfigValueType
from .provider import SonosPlayerProvider
SUPPORTED_FEATURES = {
+ PlayerFeature.PLAY_MEDIA,
PlayerFeature.PAUSE,
PlayerFeature.SEEK,
PlayerFeature.SELECT_SOURCE,
self.discovery_info = discovery_info
self.connected: bool = False
self._listen_task: asyncio.Task | None = None
- # Sonos speakers can optionally have airplay (most S2 speakers do)
- # and this airplay player can also be a player within MA.
- # We can do some smart stuff if we link them together where possible.
- # The player we can just guess from the sonos player id (mac address).
- self.airplay_player_id = f"ap{self.player_id[7:-5].lower()}"
self.sonos_queue: SonosQueue = SonosQueue()
- @property
- def airplay_mode_enabled(self) -> bool:
- """Return if airplay mode is enabled for the player."""
- return self.mass.config.get_raw_player_config_value(
- self.player_id, CONF_AIRPLAY_MODE, False
- )
-
- @property
- def airplay_mode_active(self) -> bool:
- """Return if airplay mode is active for the player."""
- return (
- self.airplay_mode_enabled
- and self.client.player.is_coordinator
- and (airplay_player := self.get_linked_airplay_player(False))
- and airplay_player.playback_state in (PlaybackState.PLAYING, PlaybackState.PAUSED)
- )
-
@property
def synced_to(self) -> str | None:
"""
if not self.client.player.has_fixed_volume:
_supported_features.add(PlayerFeature.VOLUME_SET)
_supported_features.add(PlayerFeature.VOLUME_MUTE)
- if not self.get_linked_airplay_player(False):
- _supported_features.add(PlayerFeature.NEXT_PREVIOUS)
- if not self.get_linked_airplay_player(True):
- _supported_features.add(PlayerFeature.ENQUEUE)
+ _supported_features.add(PlayerFeature.NEXT_PREVIOUS)
+ _supported_features.add(PlayerFeature.ENQUEUE)
self._attr_supported_features = _supported_features
self._attr_name = (
self._attr_device_info.manufacturer = self._provider.manifest.name
self._attr_can_group_with = {self._provider.instance_id}
+ # Add identifiers for matching with other protocols (like AirPlay, DLNA)
+ # The player_id is the Sonos UUID (e.g., RINCON_xxxxxxxxxxxx)
+ self._attr_device_info.add_identifier(IdentifierType.UUID, self.player_id)
+ # Extract MAC address from Sonos player_id (RINCON_XXXXXXXXXXXX01400)
+ # The middle part contains the MAC address (last 6 bytes in hex)
+ mac_address = self._extract_mac_from_player_id()
+ if mac_address:
+ self._attr_device_info.add_identifier(IdentifierType.MAC_ADDRESS, mac_address)
+
if SonosCapability.LINE_IN in self.discovery_info["device"]["capabilities"]:
self._attr_source_list.append(PLAYER_SOURCE_MAP[SOURCE_LINE_IN])
if SonosCapability.HT_PLAYBACK in self.discovery_info["device"]["capabilities"]:
),
)
)
- # register callback for airplay player state changes
- self._on_unload_callbacks.append(
- self.mass.subscribe(
- self._on_airplay_player_event,
- (EventType.PLAYER_UPDATED, EventType.PLAYER_ADDED),
- self.airplay_player_id,
- )
- )
async def get_config_entries(
self,
values: dict[str, ConfigValueType] | None = None,
) -> list[ConfigEntry]:
"""Return all (provider/player specific) Config Entries for the player."""
- base_entries = [
+ return [
CONF_ENTRY_HTTP_PROFILE_DEFAULT_2,
create_sample_rates_config_entry(
# set safe max bit depth to 16 bits because the older Sonos players
hidden=False,
),
]
- return [
- *base_entries,
- ConfigEntry(
- key="airplay_detected",
- type=ConfigEntryType.BOOLEAN,
- label="airplay_detected",
- hidden=True,
- required=False,
- default_value=self.get_linked_airplay_player(False) is not None,
- ),
- ConfigEntry(
- key=CONF_AIRPLAY_MODE,
- type=ConfigEntryType.BOOLEAN,
- label="Enable AirPlay mode",
- description="Almost all newer Sonos speakers have AirPlay support. "
- "If you have the AirPlay provider enabled in Music Assistant, "
- "your Sonos speaker will also be detected as a AirPlay speaker, meaning "
- "you can group them with other AirPlay speakers.\n\n"
- "By default, Music Assistant uses the Sonos protocol for playback but with this "
- "feature enabled, it will use the AirPlay protocol instead by redirecting "
- "the playback related commands to the linked AirPlay player in Music Assistant, "
- "allowing you to mix and match Sonos speakers with AirPlay speakers. \n\n"
- "NOTE: You need to have the AirPlay provider enabled as well as "
- "the AirPlay version of this player.",
- required=False,
- default_value=False,
- depends_on="airplay_detected",
- hidden=SonosCapability.AIRPLAY not in self.discovery_info["device"]["capabilities"],
- ),
- ]
-
- def get_linked_airplay_player(self, enabled_only: bool = True) -> Player | None:
- """Return the linked airplay player if available/enabled."""
- if enabled_only and not self.airplay_mode_enabled:
- return None
- if not (airplay_player := self.mass.players.get(self.airplay_player_id)):
- return None
- if not airplay_player.available:
- return None
- return airplay_player
async def volume_set(self, volume_level: int) -> None:
"""
:param volume_level: volume level (0..100) to set on the player.
"""
await self.client.player.set_volume(volume_level)
- # sync volume level with airplay player
- if airplay_player := self.get_linked_airplay_player(False):
- if airplay_player.playback_state not in (PlaybackState.PLAYING, PlaybackState.PAUSED):
- airplay_player._attr_volume_level = volume_level
async def volume_mute(self, muted: bool) -> None:
"""
async def play(self) -> None:
"""Handle PLAY command on the player."""
if self.client.player.is_passive:
- self.logger.debug("Ignore STOP command: Player is synced to another player.")
+ self.logger.debug("Ignore PLAY command: Player is synced to another player.")
return
- if airplay_player := self.get_linked_airplay_player(True):
- # linked airplay player is active, redirect the command
- self.logger.debug("Redirecting PLAY command to linked airplay player.")
- await airplay_player.play()
- else:
- await self.client.player.group.play()
+ await self.client.player.group.play()
async def stop(self) -> None:
"""Handle STOP command on the player."""
if self.client.player.is_passive:
self.logger.debug("Ignore STOP command: Player is synced to another player.")
return
- if (airplay_player := self.get_linked_airplay_player(True)) and self.airplay_mode_active:
- # linked airplay player is active, redirect the command
- self.logger.debug("Redirecting STOP command to linked airplay player.")
- await airplay_player.stop()
- else:
- await self.client.player.group.stop()
+ await self.client.player.group.stop()
self.update_state()
async def pause(self) -> None:
Will only be called if the player reports PlayerFeature.PAUSE is supported.
"""
if self.client.player.is_passive:
- self.logger.debug("Ignore STOP command: Player is synced to another player.")
- return
- if (airplay_player := self.get_linked_airplay_player(True)) and self.airplay_mode_active:
- # linked airplay player is active, redirect the command
- self.logger.debug("Redirecting PAUSE command to linked airplay player.")
- await airplay_player.pause()
+ self.logger.debug("Ignore PAUSE command: Player is synced to another player.")
return
active_source = self._attr_active_source
if self.mass.player_queues.get(active_source):
raise PlayerCommandFailed(msg)
# for now always reset the active session
self.client.player.group.active_session_id = None
- if airplay_player := self.get_linked_airplay_player(True):
- # airplay mode is enabled, redirect the command
- self.logger.debug("Redirecting PLAY_MEDIA command to linked airplay player.")
- await self._play_media_airplay(airplay_player, media)
- return
if media.source_id:
await self._set_sonos_queue_from_mass_queue(media.source_id)
# play duration-less (long running) radio streams
# enforce AAC here because Sonos really does not support FLAC streams without duration
- media.uri = media.uri.replace(".flac", ".aac").replace(".wav", ".aac")
+ stream_url = await self.provider.mass.streams.resolve_stream_url(self.player_id, media)
+ stream_url = stream_url.replace(".flac", ".aac").replace(".wav", ".aac")
if media.source_id and media.queue_item_id:
object_id = f"mass:{media.source_id}:{media.queue_item_id}"
else:
- object_id = media.uri
+ object_id = stream_url
await self.client.player.group.play_stream_url(
- media.uri,
+ stream_url,
{
"name": media.title,
"type": "track",
"""
player_ids_to_add = player_ids_to_add or []
player_ids_to_remove = player_ids_to_remove or []
- if airplay_player := self.get_linked_airplay_player(False):
- # if airplay mode is enabled, we could possibly receive child player id's that are
- # not Sonos players, but AirPlay players. We redirect those.
- airplay_player_ids_to_add = {x for x in player_ids_to_add if x.startswith("ap")}
- airplay_player_ids_to_remove = {x for x in player_ids_to_remove if x.startswith("ap")}
- if airplay_player_ids_to_add or airplay_player_ids_to_remove:
- await self.mass.players.cmd_set_members(
- airplay_player.player_id,
- player_ids_to_add=list(airplay_player_ids_to_add),
- player_ids_to_remove=list(airplay_player_ids_to_remove),
- )
- sonos_player_ids_to_add = {x for x in player_ids_to_add if not x.startswith("ap")}
- sonos_player_ids_to_remove = {x for x in player_ids_to_remove if not x.startswith("ap")}
- if sonos_player_ids_to_add or sonos_player_ids_to_remove:
+ if player_ids_to_add or player_ids_to_remove:
await self.client.player.group.modify_group_members(
- player_ids_to_add=list(sonos_player_ids_to_add),
- player_ids_to_remove=list(sonos_player_ids_to_remove),
+ player_ids_to_add=player_ids_to_add,
+ player_ids_to_remove=player_ids_to_remove,
)
async def ungroup(self) -> None:
self._attr_volume_muted = self.client.player.volume_muted
group_parent = None
- airplay_player = self.get_linked_airplay_player(False)
if self.client.player.is_coordinator:
- # player is group coordinator
+ # player is group coordinator - always report native group members
active_group = self.client.player.group
if len(self.client.player.group_members) > 1:
self._attr_group_members = list(self.client.player.group_members)
else:
self._attr_group_members.clear()
- # append airplay child's to group childs
- if self.airplay_mode_enabled and airplay_player:
- airplay_childs = [
- x for x in airplay_player._attr_group_members if x != airplay_player.player_id
- ]
- self._attr_group_members.extend(airplay_childs)
- airplay_prov = airplay_player.provider
- self._attr_can_group_with.update(
- x.player_id
- for x in airplay_prov.players
- if x.player_id != airplay_player.player_id
- )
- else:
- self._attr_can_group_with = {self._provider.instance_id}
+ self._attr_can_group_with = {self._provider.instance_id}
else:
# player is group child (synced to another player)
- group_parent: SonosPlayer = self.mass.players.get(
+ group_parent: SonosPlayer = self.mass.players.get_player(
self.client.player.group.coordinator_id
)
if not group_parent or not group_parent.client or not group_parent.client.player:
elif container_type in (ContainerType.HOME_THEATER_HDMI, ContainerType.HOME_THEATER_SPDIF):
self._attr_active_source = SOURCE_TV
elif container_type == ContainerType.AIRPLAY:
- # check if the MA airplay player is active
- if airplay_player and airplay_player.playback_state in (
- PlaybackState.PLAYING,
- PlaybackState.PAUSED,
- ):
- self._attr_playback_state = airplay_player.playback_state
- self._attr_active_source = airplay_player.active_source
- self._attr_elapsed_time = airplay_player.elapsed_time
- self._attr_elapsed_time_last_updated = airplay_player.elapsed_time_last_updated
- self._attr_current_media = airplay_player.current_media
- # return early as we dont need further info
- return
self._attr_active_source = SOURCE_AIRPLAY
elif (
container_type == ContainerType.STATION
self._attr_current_media = current_media
+ async def on_protocol_playback(
+ self,
+ output_protocol: OutputProtocol,
+ ) -> None:
+ """Handle callback when playback starts on a protocol output."""
+ # Only handle AirPlay protocol
+ if output_protocol.protocol_domain != "airplay":
+ return
+
+ # Only if this player is a coordinator with group members
+ if not self.client.player.is_coordinator:
+ return
+
+ current_members = list(self.client.player.group_members)
+ if len(current_members) <= 1:
+ # No group members to worry about
+ return
+
+ # Workaround for Sonos AirPlay ungrouping bug: when AirPlay playback starts
+ # on a Sonos speaker that has native group members, Sonos dissolves the group.
+ # We capture the group state here and restore it via AirPlay protocol after a delay.
+
+ self.logger.debug(
+ "AirPlay playback starting on %s with native group members %s - "
+ "scheduling restoration to avoid Sonos ungrouping bug",
+ self.name,
+ current_members,
+ )
+ members_to_restore = [m for m in current_members if m != self.player_id]
+
+ async def _restore_airplay_group() -> None:
+ try:
+ # we call set_members on the PlayerController here so it
+ # can try to regroup via the preferred protocol (which may be AirPlay),
+ await self.mass.players.cmd_set_members(
+ self.player_id, player_ids_to_add=members_to_restore
+ )
+ except Exception as err:
+ self.logger.warning("Failed to restore AirPlay group: %s", err)
+
+ # Schedule restoration after 4 seconds to let AirPlay settle
+ self.mass.call_later(
+ 4,
+ _restore_airplay_group,
+ task_id=f"restore_airplay_group_{self.player_id}",
+ )
+
def update_elapsed_time(self, elapsed_time: float | None = None) -> None:
"""Update the elapsed time of the current media."""
if elapsed_time is not None:
await self.client.connect()
except (ConnectionFailed, ClientConnectorError) as err:
self.logger.warning("Failed to connect to Sonos player: %s", err)
- if not retry_on_fail or not self.mass.players.get(self.player_id):
+ if not retry_on_fail or not self.mass.players.get_player(self.player_id):
raise
self._attr_available = False
self.update_state()
await self.client.disconnect()
self.logger.debug("Disconnected from player API")
- def _on_airplay_player_event(self, event: MassEvent) -> None:
- """Handle incoming event from linked airplay player."""
- if not self.mass.config.get_raw_player_config_value(self.player_id, CONF_AIRPLAY_MODE):
- return
- if event.object_id != self.airplay_player_id:
- return
- self.update_attributes()
- self.update_state()
-
async def sync_play_modes(self, queue_id: str) -> None:
"""Sync the play modes between MA and Sonos."""
queue = self.mass.player_queues.get(queue_id)
# this may happen at race conditions
raise
- async def _play_media_airplay(
- self,
- airplay_player: Player,
- media: PlayerMedia,
- ) -> None:
- """Handle PLAY MEDIA using the legacy upnp api."""
- player_id = self.player_id
- if (
- airplay_player.playback_state == PlaybackState.PLAYING
- and airplay_player.active_source == media.source_id
- ):
- # if the airplay player is already playing,
- # the stream will be reused so no need to do the whole grouping thing below
- await self.mass.players.play_media(airplay_player.player_id, media)
- return
-
- # Sonos has an annoying bug (for years already, and they dont seem to care),
- # where it looses its sync childs when airplay playback is (re)started.
- # Try to handle it here with this workaround.
- org_group_childs = {x for x in self.client.player.group.player_ids if x != player_id}
- if org_group_childs:
- # ungroup all childs first
- await self.client.player.group.modify_group_members(
- player_ids_to_add=[], player_ids_to_remove=list(org_group_childs)
- )
- # start playback on the airplay player
- await self.mass.players.play_media(airplay_player.player_id, media)
- # re-add the original group childs to the sonos player if needed
- if org_group_childs:
- # wait a bit to let the airplay playback start
- await asyncio.sleep(3)
- await self.client.player.group.modify_group_members(
- player_ids_to_add=list(org_group_childs),
- player_ids_to_remove=[],
- )
-
- async def _play_media_legacy(
- self,
- media: PlayerMedia,
- ) -> None:
- """Handle PLAY MEDIA using the legacy upnp api."""
- xml_data, soap_action = get_xml_soap_set_url(media)
- player_ip = self.device_info.ip_address
- async with self.mass.http_session_no_ssl.post(
- f"http://{player_ip}:1400/MediaRenderer/AVTransport/Control",
- headers={
- "SOAPACTION": soap_action,
- "Content-Type": "text/xml; charset=utf-8",
- "Connection": "close",
- },
- data=xml_data,
- ) as resp:
- if resp.status != 200:
- raise PlayerCommandFailed(
- f"Failed to send command to Sonos player: {resp.status} {resp.reason}"
- )
- await self.play()
-
- async def _enqueue_next_legacy(
- self,
- media: PlayerMedia,
- ) -> None:
- """Handle enqueuing of the next (queue) item on the player using legacy upnp api."""
- xml_data, soap_action = get_xml_soap_set_next_url(media)
- player_ip = self.device_info.ip_address
- async with self.mass.http_session_no_ssl.post(
- f"http://{player_ip}:1400/MediaRenderer/AVTransport/Control",
- headers={
- "SOAPACTION": soap_action,
- "Content-Type": "text/xml; charset=utf-8",
- "Connection": "close",
- },
- data=xml_data,
- ) as resp:
- if resp.status != 200:
- raise PlayerCommandFailed(
- f"Failed to send command to Sonos player: {resp.status} {resp.reason}"
- )
-
async def _set_sonos_queue_from_mass_queue(self, queue_id: str) -> None:
"""Set the SonosQueue items from the given MA PlayerQueue."""
items: list[PlayerMedia] = []
media = await self.mass.player_queues.player_media_from_queue_item(
queue_item, False
)
+ media.uri = await self.provider.mass.streams.resolve_stream_url(
+ self.player_id, media
+ )
items.append(media)
# Add the current item
media = await self.mass.player_queues.player_media_from_queue_item(
current_item, False
)
+ media.uri = await self.provider.mass.streams.resolve_stream_url(
+ self.player_id, media
+ )
items.append(media)
# Use get_next_item to fetch next items, which accounts for repeat mode
if next_item is None:
break
media = await self.mass.player_queues.player_media_from_queue_item(next_item, False)
+ media.uri = await self.provider.mass.streams.resolve_stream_url(self.player_id, media)
items.append(media)
last_index = next_item.queue_item_id
self.player_id,
[x.title for x in self.sonos_queue.items],
)
+
+ def _extract_mac_from_player_id(self) -> str | None:
+ """Extract MAC address from Sonos player_id.
+
+ Sonos player_ids follow the format RINCON_XXXXXXXXXXXX01400 where
+ the middle 12 hex characters represent the MAC address.
+
+ :return: MAC address string in XX:XX:XX:XX:XX:XX format, or None if not extractable.
+ """
+ # Remove RINCON_ prefix if present
+ player_id = self.player_id
+ player_id = player_id.removeprefix("RINCON_") # Remove "RINCON_"
+
+ # Remove the 01400 suffix (or similar) - should be last 5 chars
+ if len(player_id) >= 17: # 12 hex chars for MAC + 5 chars suffix
+ mac_hex = player_id[:12]
+ else:
+ return None
+
+ # Validate it looks like a MAC (all hex characters)
+ try:
+ int(mac_hex, 16)
+ except ValueError:
+ return None
+
+ # Format as XX:XX:XX:XX:XX:XX
+ return ":".join(mac_hex[i : i + 2].upper() for i in range(0, 12, 2))
from aiohttp.client_exceptions import ClientError
from aiosonos.api.models import SonosCapability
from aiosonos.utils import get_discovery_info
-from music_assistant_models.enums import PlaybackState
+from music_assistant_models.enums import IdentifierType
from zeroconf import ServiceStateChange
from music_assistant.constants import (
from .player import SonosPlayer
if TYPE_CHECKING:
- from music_assistant_models.config_entries import PlayerConfig
from music_assistant_models.player import PlayerMedia
from zeroconf.asyncio import AsyncServiceInfo
continue
player_id = discovery_info["device"]["id"]
sonos_player = SonosPlayer(self, player_id, discovery_info=discovery_info)
- sonos_player.device_info.ip_address = ip_address
+ sonos_player.device_info.add_identifier(IdentifierType.IP_ADDRESS, ip_address)
await sonos_player.setup()
async def unload(self, is_removed: bool = False) -> None:
name = name.split("@", 1)[1] if "@" in name else name
player_id = info.decoded_properties["uuid"]
# handle update for existing device
- if sonos_player := self.mass.players.get(player_id):
+ if sonos_player := self.mass.players.get_player(player_id):
assert isinstance(sonos_player, SonosPlayer), (
"Player ID already exists but is not a SonosPlayer"
)
sonos_player.device_info.ip_address,
cur_address,
)
- sonos_player.device_info.ip_address = cur_address
+ sonos_player.device_info.add_identifier(IdentifierType.IP_ADDRESS, cur_address)
if not sonos_player.connected:
self.logger.debug("Player back online: %s", sonos_player.display_name)
sonos_player.client.player_ip = cur_address
task_id = f"setup_sonos_{player_id}"
self.mass.call_later(5, self._setup_player, player_id, name, info, task_id=task_id)
- async def on_player_config_change(self, config: PlayerConfig, changed_keys: set[str]) -> None:
- """Call (by config manager) when the configuration of a player changes."""
- await super().on_player_config_change(config, changed_keys)
- if "values/airplay_mode" in changed_keys and (
- (sonos_player := self.mass.players.get(config.player_id))
- and (airplay_player := sonos_player.get_linked_airplay_player(False))
- and airplay_player.playback_state in (PlaybackState.PLAYING, PlaybackState.PAUSED)
- ):
- # edge case: we switched from airplay mode to sonos mode (or vice versa)
- # we need to make sure that playback gets stopped on the airplay player
- await airplay_player.stop()
- # We also need to run setup again on the Sonos player to ensure the supported
- # features are updated.
- await sonos_player.setup()
-
async def _setup_player(self, player_id: str, name: str, info: AsyncServiceInfo) -> None:
"""Handle setup of a new player that is discovered using mdns."""
- assert not self.mass.players.get(player_id)
+ assert not self.mass.players.get_player(player_id)
address = get_primary_ip_address(info)
if address is None:
return
return
self.logger.debug("Discovered Sonos device %s on %s", name, address)
sonos_player = SonosPlayer(self, player_id, discovery_info=discovery_info)
- sonos_player.device_info.ip_address = address
+ sonos_player.device_info.add_identifier(IdentifierType.IP_ADDRESS, address)
await sonos_player.setup()
async def _handle_sonos_cloud_queue_request(self, request: web.Request) -> web.Response:
if len(path_parts) != 4 or path_parts[0] != "sonos_queue":
return web.Response(status=404)
player_id = path_parts[1]
- if not (sonos_player := self.mass.players.get(player_id)):
+ if not (sonos_player := self.mass.players.get_player(player_id)):
return web.Response(status=501)
if TYPE_CHECKING:
assert isinstance(sonos_player, SonosPlayer)
# Player Features
PLAYER_FEATURES = (
+ PlayerFeature.PLAY_MEDIA,
PlayerFeature.SET_MEMBERS,
PlayerFeature.VOLUME_MUTE,
PlayerFeature.VOLUME_SET,
from collections.abc import Callable, Coroutine
from typing import TYPE_CHECKING, Any, cast
-from music_assistant_models.enums import MediaType, PlaybackState, PlayerState, PlayerType
+from music_assistant_models.enums import IdentifierType, MediaType, PlaybackState, PlayerState
from music_assistant_models.errors import PlayerCommandFailed
from soco import SoCoException
from soco.core import MUSIC_SRC_RADIO, SoCo
self.subscriptions: list[SubscriptionBase] = []
# Set player attributes
- self._attr_type = PlayerType.PLAYER
self._attr_supported_features = set(PLAYER_FEATURES)
self._attr_name = soco.player_name
self._attr_device_info = DeviceInfo(
model=soco.speaker_info["model_name"],
manufacturer="Sonos",
)
- self._attr_device_info.ip_address = soco.ip_address
+ self._attr_device_info.add_identifier(IdentifierType.IP_ADDRESS, soco.ip_address)
+ self._attr_device_info.add_identifier(IdentifierType.UUID, soco.uid)
+ mac_address = self._extract_mac_from_player_id()
+ if mac_address:
+ self._attr_device_info.add_identifier(IdentifierType.MAC_ADDRESS, mac_address)
self._attr_needs_poll = True
self._attr_poll_interval = 5
self._attr_available = True
subscribed_services = {sub.service.service_type for sub in self._subscriptions}
return SUBSCRIPTION_SERVICES - subscribed_services
+ def _extract_mac_from_player_id(self) -> str | None:
+ """Extract MAC address from Sonos player_id.
+
+ Sonos player_ids follow the format RINCON_XXXXXXXXXXXX01400 where
+ the middle 12 hex characters represent the MAC address.
+
+ :return: MAC address string in XX:XX:XX:XX:XX:XX format, or None if not extractable.
+ """
+ # Remove RINCON_ prefix if present
+ player_id = self.player_id
+ player_id = player_id.removeprefix("RINCON_")
+
+ # Remove the 01400 suffix (or similar) - should be last 5 chars
+ if len(player_id) >= 17: # 12 hex chars for MAC + 5 chars suffix
+ mac_hex = player_id[:12]
+ else:
+ return None
+
+ # Validate it looks like a MAC (all hex characters)
+ try:
+ int(mac_hex, 16)
+ except ValueError:
+ return None
+
+ # Format as XX:XX:XX:XX:XX:XX
+ return ":".join(mac_hex[i : i + 2].upper() for i in range(0, 12, 2))
+
async def setup(self) -> None:
"""Set up the player."""
self._attr_volume_level = self.soco.volume
is_announcement = media.media_type == MediaType.ANNOUNCEMENT
force_radio = False if is_announcement else not media.duration
+ stream_url = await self.provider.mass.streams.resolve_stream_url(self.player_id, media)
await asyncio.to_thread(
- self.soco.play_uri, media.uri, meta=didl_metadata, force_radio=force_radio
+ self.soco.play_uri, stream_url, meta=didl_metadata, force_radio=force_radio
)
self.mass.call_later(2, self.poll)
if player_ids_to_remove:
for player_id in player_ids_to_remove:
- if player_to_remove := cast("SonosPlayer", self.mass.players.get(player_id)):
+ if player_to_remove := cast("SonosPlayer", self.mass.players.get_player(player_id)):
await asyncio.to_thread(player_to_remove.soco.unjoin)
self.mass.call_later(2, player_to_remove.poll)
if player_ids_to_add:
for player_id in player_ids_to_add:
- if player_to_add := cast("SonosPlayer", self.mass.players.get(player_id)):
+ if player_to_add := cast("SonosPlayer", self.mass.players.get_player(player_id)):
await asyncio.to_thread(player_to_add.soco.join, self.soco)
self.mass.call_later(2, player_to_add.poll)
model=self._attr_device_info.model,
manufacturer=self._attr_device_info.manufacturer,
)
- self._attr_device_info.ip_address = ip_address
+ self._attr_device_info.add_identifier(IdentifierType.IP_ADDRESS, ip_address)
+ self._attr_device_info.add_identifier(IdentifierType.UUID, self.soco.uid)
+ mac_address = self._extract_mac_from_player_id()
+ if mac_address:
+ self._attr_device_info.add_identifier(IdentifierType.MAC_ADDRESS, mac_address)
self.update_player()
async def _check_availability(self) -> None:
group_members_ids = []
for uid in group:
- speaker = self.mass.players.get(uid)
+ speaker = self.mass.players.get_player(uid)
if speaker:
group_members_ids.append(uid)
else:
except TimeoutError:
self.logger.warning("Timeout waiting for target groups %s", groups)
- if players := self.mass.players.all(provider_filter=_provider.instance_id):
+ if players := self.mass.players.all_players(provider_filter=_provider.instance_id):
any_speaker = cast("SonosPlayer", players[0])
any_speaker.soco.zone_group_state.clear_cache()
while self._discovery_running:
await asyncio.sleep(0.5)
# Clean up subscriptions and connections
- for sonos_player in self.mass.players.all(provider_filter=self.instance_id):
+ for sonos_player in self.mass.players.all_players(provider_filter=self.instance_id):
sonos_player = cast("SonosPlayer", sonos_player)
await sonos_player.offline()
# Stop the async event listener
"""Set up a discovered Sonos player."""
player_id = soco.uid
- if existing := cast("SonosPlayer", self.mass.players.get(player_id=player_id)):
+ if existing := cast("SonosPlayer", self.mass.players.get_player(player_id=player_id)):
if existing.soco.ip_address != soco.ip_address:
existing.update_ip(soco.ip_address)
return
## Related Documentation
- **PluginSource Model**: See `music_assistant/models/plugin.py`
-- **Player Controller**: See `music_assistant/controllers/players/player_controller.py`
+- **Player Controller**: See `music_assistant/controllers/players/`
- **Spotify Provider**: See `music_assistant/providers/spotify/`
- **librespot**: https://github.com/librespot-org/librespot
*(
ConfigValueOption(x.display_name, x.player_id)
for x in sorted(
- mass.players.all(False, False), key=lambda p: p.display_name.lower()
+ mass.players.all_players(False, False), key=lambda p: p.display_name.lower()
)
),
],
# If there's an active player (source was selected on a player), use it
if self._active_player_id:
# Validate that the active player still exists
- if self.mass.players.get(self._active_player_id):
+ if self.mass.players.get_player(self._active_player_id):
return self._active_player_id
# Active player no longer exists, clear it
self._active_player_id = None
# Handle auto selection
if self._default_player_id == PLAYER_ID_AUTO:
- all_players = list(self.mass.players.all(False, False))
+ all_players = list(self.mass.players.all_players(False, False))
# First, try to find a playing player
for player in all_players:
if player.state.playback_state == PlaybackState.PLAYING:
return None
# Use the specific default player if configured and it still exists
- if self.mass.players.get(self._default_player_id):
+ if self.mass.players.get_player(self._default_player_id):
return self._default_player_id
self.logger.warning(
"Configured default player '%s' no longer exists", self._default_player_id
# Get initial volume from default player if available, or use 20 as fallback
initial_volume = 20
if self._default_player_id and self._default_player_id != PLAYER_ID_AUTO:
- if _player := self.mass.players.get(self._default_player_id):
+ if _player := self.mass.players.get_player(self._default_player_id):
if _player.volume_level:
initial_volume = _player.volume_level
args: list[str] = [
from music_assistant_models.config_entries import ConfigEntry, ConfigValueOption, ConfigValueType
from music_assistant_models.enums import (
ConfigEntryType,
+ IdentifierType,
MediaType,
PlaybackState,
PlayerFeature,
CACHE_CATEGORY_PREV_STATE = 0 # category for caching previous player state
+PLAYER_DEVICE_TYPES = {
+ # list of device types that are considered real hardware players
+ "squeezebox",
+ "squeezebox2",
+ "transporter",
+ "receiver",
+ "controller",
+ "boom",
+}
+
class SqueezelitePlayer(Player):
"""Squeezelite Player implementation."""
- _attr_type = PlayerType.PLAYER
-
def __init__(
self,
provider: SqueezelitePlayerProvider,
self._provider: SqueezelitePlayerProvider = provider
# Set static player attributes
self._attr_supported_features = {
+ PlayerFeature.PLAY_MEDIA,
PlayerFeature.POWER,
PlayerFeature.SET_MEMBERS,
PlayerFeature.MULTI_DEVICE_DSP,
if not self.group_members:
# Simple, single-player playback
+ stream_url = await self.provider.mass.streams.resolve_stream_url(self.player_id, media)
await self._handle_play_url_for_slimplayer(
self.client,
- url=media.uri,
+ url=stream_url,
media=media,
send_flush=True,
auto_play=False,
if player_id == self.player_id or player_id in self.group_members:
# nothing to do: player is already part of the group
continue
- child_player = cast("SqueezelitePlayer | None", self.mass.players.get(player_id))
+ child_player = cast("SqueezelitePlayer | None", self.mass.players.get_player(player_id))
if not child_player:
# should not happen, but guard against it
continue
if (
(players_added or player_ids_to_remove)
- and self.current_media
- and self.playback_state == PlaybackState.PLAYING
+ and self.state.current_media
+ and self._attr_playback_state == PlaybackState.PLAYING
):
# restart stream session if it was already playing
# for now, we dont support late joining into an existing stream
def update_attributes(self) -> None:
"""Update player attributes from slim player."""
# Update player state from slim player
+ self._attr_type = (
+ PlayerType.PLAYER
+ if self.client.device_type in PLAYER_DEVICE_TYPES
+ else PlayerType.PROTOCOL
+ )
self._attr_available = self.client.connected
self._attr_name = self.client.name
self._attr_powered = self.client.powered
model=self.client.device_model,
manufacturer=self.client.device_type,
)
- self._attr_device_info.ip_address = self.client.device_address
- self._attr_device_info.mac_address = self.client.player_id
+ self._attr_device_info.add_identifier(IdentifierType.IP_ADDRESS, self.client.device_address)
+ # player_id is the MAC address in slimproto
+ self._attr_device_info.add_identifier(IdentifierType.MAC_ADDRESS, self.client.player_id)
if (
old_state != PlaybackState.PLAYING
and self._attr_playback_state == PlaybackState.PLAYING
def _handle_player_heartbeat(self) -> None:
"""Process SlimClient elapsed_time update."""
- if self.playback_state != PlaybackState.PLAYING:
+ if self._attr_playback_state != PlaybackState.PLAYING:
# ignore server heartbeats when not playing
# Some players keep sending heartbeat with increasing elapsed time
# even when paused (e.g. WiiM)
self.mass.create_task(player.setup())
return
- if not (mass_player := self.mass.players.get(event.player_id)):
+ if not (mass_player := self.mass.players.get_player(event.player_id)):
return # guard for unknown player
player = cast("SqueezelitePlayer", mass_player)
if not child_player_id:
raise web.HTTPNotFound(reason="Missing child_player_id parameter")
- if not (sync_parent := self.mass.players.get(player_id)):
+ if not (sync_parent := self.mass.players.get_player(player_id)):
raise web.HTTPNotFound(reason=f"Unknown player: {player_id}")
sync_parent = cast("SqueezelitePlayer", sync_parent)
- if not (child_player := self.mass.players.get(child_player_id)):
+ if not (child_player := self.mass.players.get_player(child_player_id)):
raise web.HTTPNotFound(reason=f"Unknown player: {child_player_id}")
if not (stream := sync_parent.multi_client_stream) or stream.done:
--- /dev/null
+"""
+Sync Group Player provider.
+
+Create sync groups to group compatible speakers to play audio in sync.
+"""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from music_assistant_models.enums import ProviderFeature
+
+from .player import SyncGroupPlayer
+from .provider import SyncGroupProvider
+
+if TYPE_CHECKING:
+ from music_assistant_models.config_entries import ConfigEntry, ConfigValueType, ProviderConfig
+ from music_assistant_models.provider import ProviderManifest
+
+ from music_assistant import MusicAssistant
+ from music_assistant.models import ProviderInstanceType
+
+SUPPORTED_FEATURES = {ProviderFeature.CREATE_GROUP_PLAYER, ProviderFeature.REMOVE_GROUP_PLAYER}
+
+
+async def setup(
+ mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
+) -> ProviderInstanceType:
+ """Initialize provider(instance) with given configuration."""
+ return SyncGroupProvider(mass, manifest, config, SUPPORTED_FEATURES)
+
+
+async def get_config_entries(
+ mass: MusicAssistant, # noqa: ARG001
+ instance_id: str | None = None, # noqa: ARG001
+ action: str | None = None, # noqa: ARG001
+ values: dict[str, ConfigValueType] | None = None, # noqa: ARG001
+) -> tuple[ConfigEntry, ...]:
+ """
+ Return Config entries to setup this provider.
+
+ :param mass: MusicAssistant instance.
+ :param instance_id: id of an existing provider instance (None if new instance setup).
+ :param action: [optional] action key called from config entries UI.
+ :param values: the (intermediate) raw values for config entries sent with the action.
+ """
+ # nothing to configure (for now)
+ return ()
+
+
+__all__ = (
+ "SyncGroupPlayer",
+ "SyncGroupProvider",
+ "get_config_entries",
+ "setup",
+)
--- /dev/null
+"""Sync Group Player constants."""
+
+from __future__ import annotations
+
+from typing import Final
+
+from music_assistant_models.config_entries import ConfigEntry
+from music_assistant_models.enums import ConfigEntryType, PlayerFeature
+
+SGP_PREFIX: Final[str] = "syncgroup_"
+
+CONF_ENTRY_SGP_NOTE = ConfigEntry(
+ key="sgp_note",
+ type=ConfigEntryType.ALERT,
+ label="Sync groups allow you to group compatible players together to play audio in sync. "
+ "Players can only be grouped together if they support the same sync protocol",
+ required=False,
+)
+
+SUPPORT_DYNAMIC_LEADER = {
+ # providers that support dynamic leader selection in a syncgroup
+ # meaning that if you would remove the current leader from the group,
+ # the provider will automatically select a new leader from the remaining members
+ # and the music keeps playing uninterrupted.
+ "airplay",
+ "squeezelite",
+ "snapcast",
+ # TODO: Get this working with Sonos as well (need to handle range requests)
+}
+
+
+EXTRA_FEATURES_FROM_MEMBERS: Final[set[PlayerFeature]] = {
+ PlayerFeature.ENQUEUE,
+ PlayerFeature.GAPLESS_PLAYBACK,
+ PlayerFeature.PAUSE,
+ PlayerFeature.VOLUME_SET,
+ PlayerFeature.VOLUME_MUTE,
+ PlayerFeature.MULTI_DEVICE_DSP,
+}
--- /dev/null
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+ viewBox="0 0 512 512"
+ version="1.1"
+ id="svg1"
+ sodipodi:docname="icon-ugp.svg"
+ width="512"
+ height="512"
+ inkscape:version="1.3.2 (091e20e, 2023-11-25, custom)"
+ xml:space="preserve"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns:xlink="http://www.w3.org/1999/xlink"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:svg="http://www.w3.org/2000/svg"><defs
+ id="defs1" /><sodipodi:namedview
+ id="namedview1"
+ pagecolor="#46ffff"
+ bordercolor="#000000"
+ borderopacity="0.25"
+ inkscape:showpageshadow="2"
+ inkscape:pageopacity="0.0"
+ inkscape:pagecheckerboard="0"
+ inkscape:deskcolor="#d1d1d1"
+ inkscape:zoom="1.633343"
+ inkscape:cx="251.93728"
+ inkscape:cy="256.83521"
+ inkscape:window-width="1920"
+ inkscape:window-height="1129"
+ inkscape:window-x="1912"
+ inkscape:window-y="-8"
+ inkscape:window-maximized="1"
+ inkscape:current-layer="svg1" /><image
+ width="512"
+ height="512"
+ preserveAspectRatio="none"
+ style="image-rendering:optimizeSpeed"
+ xlink:href="data:image/png;base64,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 YII= "
+ id="image1"
+ x="-0.083149381"
+ y="-0.08315631" /></svg>
--- /dev/null
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+ viewBox="0 0 512 512"
+ version="1.1"
+ id="svg1"
+ sodipodi:docname="icon_monochrome.svg"
+ width="512"
+ height="512"
+ inkscape:version="1.3.2 (091e20e, 2023-11-25, custom)"
+ xml:space="preserve"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns:xlink="http://www.w3.org/1999/xlink"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:svg="http://www.w3.org/2000/svg"><defs
+ id="defs1" /><sodipodi:namedview
+ id="namedview1"
+ pagecolor="#46ffff"
+ bordercolor="#000000"
+ borderopacity="0.25"
+ inkscape:showpageshadow="2"
+ inkscape:pageopacity="0.0"
+ inkscape:pagecheckerboard="0"
+ inkscape:deskcolor="#d1d1d1"
+ inkscape:zoom="1.633343"
+ inkscape:cx="251.93728"
+ inkscape:cy="256.83521"
+ inkscape:window-width="1920"
+ inkscape:window-height="1129"
+ inkscape:window-x="1912"
+ inkscape:window-y="-8"
+ inkscape:window-maximized="1"
+ inkscape:current-layer="svg1" /><image
+ width="512"
+ height="512"
+ preserveAspectRatio="none"
+ style="image-rendering:optimizeSpeed"
+ xlink:href="data:image/png;base64,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 "
+ id="image1-0"
+ x="-0.083145902"
+ y="1.1413358" /></svg>
--- /dev/null
+{
+ "type": "player",
+ "domain": "sync_group",
+ "stage": "stable",
+ "name": "Sync Group Player",
+ "description": "Create (permanent) sync groups to group speakers of compatible protocols/ecosystems to play audio in sync.",
+ "codeowners": ["@music-assistant"],
+ "requirements": [],
+ "documentation": "https://music-assistant.io/faq/groups/",
+ "multi_instance": false,
+ "builtin": true,
+ "allow_disable": false
+}
--- /dev/null
+"""Sync Group Player implementation."""
+
+from __future__ import annotations
+
+import asyncio
+from typing import TYPE_CHECKING, cast
+
+from music_assistant_models.config_entries import ConfigEntry, ConfigValueOption, ConfigValueType
+from music_assistant_models.enums import ConfigEntryType, PlaybackState, PlayerFeature, PlayerType
+from music_assistant_models.errors import UnsupportedFeaturedException
+from propcache import under_cached_property as cached_property
+
+from music_assistant.constants import (
+ APPLICATION_NAME,
+ CONF_DYNAMIC_GROUP_MEMBERS,
+ CONF_GROUP_MEMBERS,
+)
+from music_assistant.models.player import DeviceInfo, GroupPlayer, Player, PlayerMedia
+
+from .constants import CONF_ENTRY_SGP_NOTE, EXTRA_FEATURES_FROM_MEMBERS, SUPPORT_DYNAMIC_LEADER
+
+if TYPE_CHECKING:
+ from .provider import SyncGroupProvider
+
+
+class SyncGroupPlayer(GroupPlayer):
+ """Sync Group Player implementation."""
+
+ _attr_type: PlayerType = PlayerType.GROUP
+ sync_leader: Player | None = None
+ """The active sync leader player for this syncgroup."""
+
+ def __init__(
+ self,
+ provider: SyncGroupProvider,
+ player_id: str,
+ ) -> None:
+ """Initialize SyncGroupPlayer instance."""
+ super().__init__(provider, player_id)
+ self._attr_name = self.config.name or self.config.default_name or f"SyncGroup {player_id}"
+ self._attr_available = True
+ self._attr_device_info = DeviceInfo(model=provider.name, manufacturer=APPLICATION_NAME)
+ # Allow grouping with any player that supports syncing
+ # The actual compatibility is checked via can_group_with on each player
+ self._attr_can_group_with = set()
+
+ @cached_property
+ def is_dynamic(self) -> bool:
+ """Return if the player is a dynamic group player."""
+ return bool(self.config.get_value(CONF_DYNAMIC_GROUP_MEMBERS, False))
+
+ async def on_config_updated(self) -> None:
+ """Handle logic when the player is loaded or updated."""
+ # Config is only available after the player was registered
+ self._cache.clear() # clear to prevent loading old is_dynamic
+ default_members = cast("list[str]", self.config.get_value(CONF_GROUP_MEMBERS, []))
+ if self.is_dynamic:
+ self._attr_static_group_members = []
+ self._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
+ else:
+ self._attr_static_group_members = default_members.copy()
+ self._attr_supported_features.discard(PlayerFeature.SET_MEMBERS)
+ self._attr_group_members = default_members.copy()
+
+ @cached_property
+ def supported_features(self) -> set[PlayerFeature]:
+ """Return the supported features of the player."""
+ # by default we don't have any features, except play_media
+ # but we can gain some features based on the capabilities of the sync leader
+ # set_members is only supported if it's a dynamic group
+ base_features: set[PlayerFeature] = {PlayerFeature.PLAY_MEDIA}
+ if self.is_dynamic:
+ base_features.add(PlayerFeature.SET_MEMBERS)
+ if not self.sync_leader:
+ return base_features
+ # add features supported by the sync leader
+ for feature in EXTRA_FEATURES_FROM_MEMBERS:
+ if feature in self.sync_leader.state.supported_features:
+ base_features.add(feature)
+ return base_features
+
+ @property
+ def playback_state(self) -> PlaybackState:
+ """Return the current playback state of the player."""
+ return self.sync_leader.state.playback_state if self.sync_leader else PlaybackState.IDLE
+
+ @property
+ def requires_flow_mode(self) -> bool:
+ """Return if the player needs flow mode."""
+ if leader := self.sync_leader:
+ return leader.flow_mode
+ return False
+
+ @property
+ def elapsed_time(self) -> float | None:
+ """Return the elapsed time in (fractional) seconds of the current track (if any)."""
+ return self.sync_leader.state.elapsed_time if self.sync_leader else None
+
+ @property
+ def elapsed_time_last_updated(self) -> float | None:
+ """Return when the elapsed time was last updated."""
+ return self.sync_leader.state.elapsed_time_last_updated if self.sync_leader else None
+
+ @property
+ def current_media(self) -> PlayerMedia | None:
+ """Return the current media item (if any) loaded in the player."""
+ return self.sync_leader.state.current_media if self.sync_leader else None
+
+ @property
+ def active_source(self) -> str | None:
+ """Return the active source id (if any) of the player."""
+ return self.sync_leader.active_source if self.sync_leader else None
+
+ @property
+ def can_group_with(self) -> set[str]:
+ """Return the id's of players this player can group with."""
+ if not self.is_dynamic:
+ # in case of static members,
+ # we can only group with the players defined in the config, so we return those directly
+ return set(self._attr_static_group_members)
+ # if we already have a sync leader, we use its can_group_with as reference
+ if self.sync_leader:
+ return {self.sync_leader.player_id, *self.sync_leader.state.can_group_with}
+ # If we have no members, but we do have default members in the config,
+ # we can group with players that are compatible with those
+ default_members = cast("list[str]", self.config.get_value(CONF_GROUP_MEMBERS, []))
+ for member_id in default_members:
+ member_player = self.mass.players.get_player(member_id)
+ if member_player and member_player.state.available:
+ return {*default_members, *member_player.state.can_group_with}
+ # Dynamic groups can potentially group with any compatible players
+ # Actual compatibility is validated when adding members
+ temp_can_group_with = set()
+ for player in self.mass.players.all_players(return_unavailable=False):
+ if not player.available or player.type == PlayerType.GROUP:
+ # let's avoid showing group players as options to group with
+ continue
+ if (
+ PlayerFeature.SET_MEMBERS in player.state.supported_features
+ and player.state.can_group_with
+ ):
+ temp_can_group_with.add(player.player_id)
+ return temp_can_group_with
+
+ async def get_config_entries(
+ self,
+ action: str | None = None,
+ values: dict[str, ConfigValueType] | None = None,
+ ) -> list[ConfigEntry]:
+ """Return all (provider/player specific) Config Entries for the given player (if any)."""
+ entries: list[ConfigEntry] = [
+ # syncgroup specific entries
+ CONF_ENTRY_SGP_NOTE,
+ ConfigEntry(
+ key=CONF_GROUP_MEMBERS,
+ type=ConfigEntryType.STRING,
+ multi_value=True,
+ label="Group members",
+ default_value=[],
+ description="Select all players you want to be part of this sync group. "
+ "Only compatible players (based on their sync protocol) can be grouped together.",
+ required=False, # needed for dynamic members (which allows empty members list)
+ options=[
+ ConfigValueOption(x.display_name, x.player_id)
+ for x in self.mass.players.all_players(True, False)
+ if x.type != PlayerType.GROUP
+ ],
+ ),
+ ConfigEntry(
+ key=CONF_DYNAMIC_GROUP_MEMBERS,
+ type=ConfigEntryType.BOOLEAN,
+ label="Enable dynamic members",
+ description="Allow (un)joining members dynamically, so the group more or less "
+ "behaves the same like manually syncing players together, "
+ "with the main difference being that the group player will hold the queue.",
+ default_value=False,
+ required=False,
+ ),
+ ]
+ return entries
+
+ async def stop(self) -> None:
+ """Send STOP command to given player."""
+ if sync_leader := self.sync_leader:
+ # Use internal handler to bypass group redirect logic and avoid infinite loop
+ # (sync_leader is part of this group, so redirect would loop back here)
+ await self.mass.players._handle_cmd_stop(sync_leader.player_id)
+ # dissolve the sync group since we stopped playback
+ await self._dissolve_syncgroup()
+
+ async def play(self) -> None:
+ """Send PLAY (unpause) command to given player."""
+ if sync_leader := self.sync_leader:
+ # Use internal handler to bypass group redirect logic and avoid infinite loop
+ await self.mass.players._handle_cmd_play(sync_leader.player_id)
+
+ async def pause(self) -> None:
+ """Send PAUSE command to given player."""
+ if sync_leader := self.sync_leader:
+ # Use internal handler to bypass group redirect logic and avoid infinite loop
+ await self.mass.players._handle_cmd_pause(sync_leader.player_id)
+
+ async def play_media(self, media: PlayerMedia) -> None:
+ """Handle PLAY MEDIA on given player."""
+ if not self.sync_leader:
+ await self._form_syncgroup()
+ # simply forward the command to the sync leader
+ if sync_leader := self.sync_leader:
+ # Use internal handler to bypass group redirect logic and preserve protocol selection
+ await self.mass.players._handle_play_media(sync_leader.player_id, media)
+ self.update_state()
+ else:
+ raise RuntimeError("An empty group cannot play media, consider adding members first")
+
+ async def enqueue_next_media(self, media: PlayerMedia) -> None:
+ """Handle enqueuing of a next media item on the player."""
+ if sync_leader := self.sync_leader:
+ # Use internal handler to bypass group redirect logic and avoid infinite loop
+ await self.mass.players._handle_enqueue_next_media(sync_leader.player_id, media)
+
+ async def set_members(
+ self,
+ player_ids_to_add: list[str] | None = None,
+ player_ids_to_remove: list[str] | None = None,
+ ) -> None:
+ """Handle SET_MEMBERS command on the player."""
+ if not self.is_dynamic:
+ raise UnsupportedFeaturedException(
+ f"Group {self.display_name} does not allow dynamically adding/removing members!"
+ )
+ prev_leader = self.sync_leader
+ cur_leader = self._select_sync_leader(new_members=player_ids_to_add)
+ # handle additions
+ final_players_to_add: list[str] = []
+ can_group_with = cur_leader.state.can_group_with.copy() if cur_leader else set()
+ for member_id in player_ids_to_add or []:
+ if member_id == self.player_id:
+ continue # can not add self as member
+ member = self.mass.players.get_player(member_id)
+ if member is None or not member.available:
+ continue
+ if member_id not in self._attr_group_members:
+ self._attr_group_members.append(member_id)
+ if not cur_leader:
+ continue
+ if member_id != cur_leader.player_id and member_id not in can_group_with:
+ self.logger.debug(
+ f"Cannot add {member.display_name} to group {self.display_name} since it's "
+ f"not compatible with the current sync leader"
+ )
+ continue
+ if member_id != cur_leader.player_id:
+ final_players_to_add.append(member_id)
+
+ # handle removals
+ final_players_to_remove: list[str] = []
+ for member_id in player_ids_to_remove or []:
+ if member_id not in self._attr_group_members:
+ continue
+ if member_id == self.player_id:
+ raise UnsupportedFeaturedException(
+ f"Cannot remove {self.display_name} from itself as a member!"
+ )
+ self._attr_group_members.remove(member_id)
+ final_players_to_remove.append(member_id)
+ self.update_state()
+ if self.playback_state != PlaybackState.PLAYING:
+ # Don't need to do anything else if the group is not active
+ # The syncing will be done once playback starts
+ return
+ if prev_leader and cur_leader is None:
+ # Edge case: we no longer have any members in the group (and thus no leader)
+ await self._handle_leader_transition(None)
+ elif prev_leader and prev_leader != cur_leader:
+ # Edge case: we had changed the leader (or just got one)
+ await self._handle_leader_transition(cur_leader)
+ elif cur_leader and (player_ids_to_add or player_ids_to_remove):
+ # if the group still has the same leader, we just need to (re)sync the members
+ await self.mass.players.cmd_set_members(
+ cur_leader.player_id,
+ player_ids_to_add=final_players_to_add,
+ player_ids_to_remove=final_players_to_remove,
+ )
+
+ async def _form_syncgroup(self) -> None:
+ """Form syncgroup by syncing all (possible) members."""
+ if not self.sync_leader:
+ self.sync_leader = self._select_sync_leader()
+
+ if not self.sync_leader:
+ # we have no members in the group, so we can't form a syncgroup
+ return
+
+ # ensure the sync leader is first in the list
+ self._attr_group_members = [
+ self.sync_leader.player_id,
+ *[x for x in self._attr_group_members if x != self.sync_leader.player_id],
+ ]
+ members_to_sync = [
+ x
+ for x in self._attr_group_members
+ if x != self.sync_leader.player_id and x not in self.sync_leader.state.group_members
+ ]
+ if members_to_sync:
+ await self.mass.players.cmd_set_members(self.sync_leader.player_id, members_to_sync)
+
+ async def _dissolve_syncgroup(self) -> None:
+ """Dissolve the current syncgroup by ungrouping all members."""
+ if sync_leader := self.sync_leader:
+ # dissolve the temporary syncgroup from the sync leader
+ sync_children = [
+ x for x in sync_leader.state.group_members if x != sync_leader.player_id
+ ]
+ if sync_children:
+ await self.mass.players.cmd_set_members(sync_leader.player_id, [], sync_children)
+ self.sync_leader = None
+ self.update_state()
+
+ async def _handle_leader_transition(self, new_leader: Player | None) -> None:
+ """Handle transition from current leader to new leader."""
+ prev_leader = self.sync_leader
+ was_playing = False
+ if prev_leader and new_leader and prev_leader != new_leader:
+ # Check if the provider(protocol) supports dynamic leader selection
+ # For cross-provider sync groups, we need to check the provider domain
+ provider_protocol = None
+ if prev_leader.active_output_protocol and (
+ proto_prov := self.mass.get_provider(prev_leader.active_output_protocol)
+ ):
+ provider_protocol = proto_prov.domain
+ else:
+ provider_protocol = prev_leader.provider.domain
+
+ if provider_protocol and provider_protocol in SUPPORT_DYNAMIC_LEADER:
+ # TODO: figure out how to handle dynamic leader transition without
+ # stopping playback, which has become complicated due
+ # to a player can support multiple protocols
+ pass
+
+ if prev_leader:
+ # Save current media and playback state for potential restart
+ was_playing = self.playback_state == PlaybackState.PLAYING
+ # Stop current playback (which also dissolves the existing syncgroup)
+ await self.stop()
+ # allow some time to propagate the changes before resyncing
+ await asyncio.sleep(2)
+
+ # Set new leader
+ self.sync_leader = new_leader
+
+ if new_leader:
+ # form a syncgroup with the new leader
+ await self._form_syncgroup()
+ # Restart playback if requested and we have media to play
+ if was_playing:
+ await self.mass.players._handle_cmd_resume(self.player_id)
+ else:
+ # We have no leader anymore, send update since we stopped playback
+ self.update_state()
+
+ def _select_sync_leader(self, new_members: list[str] | None = None) -> Player | None:
+ """Select a (new) sync leader."""
+ if self.group_members and self.sync_leader and self.sync_leader.state.available:
+ # current leader is still available, no need to select a new one
+ return self.sync_leader
+ default_members = cast("list[str]", self.config.get_value(CONF_GROUP_MEMBERS, []))
+ group_members = self.group_members or default_members or new_members or []
+ for member_id in group_members:
+ member_player = self.mass.players.get_player(member_id)
+ if member_player and member_player.state.available:
+ self.logger.debug(
+ f"Auto-selected {member_player.display_name} as sync leader for "
+ f"group {self.display_name}"
+ )
+ return member_player
+ return None
--- /dev/null
+"""Sync Group Player Provider implementation."""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+import shortuuid
+from music_assistant_models.enums import PlayerType
+
+from music_assistant.constants import CONF_DYNAMIC_GROUP_MEMBERS, CONF_GROUP_MEMBERS
+from music_assistant.models.player_provider import PlayerProvider
+
+from .constants import SGP_PREFIX
+from .player import SyncGroupPlayer
+
+if TYPE_CHECKING:
+ from music_assistant.models.player import Player
+
+
+class SyncGroupProvider(PlayerProvider):
+ """Sync Group Player Provider."""
+
+ async def create_group_player(
+ self, name: str, members: list[str], dynamic: bool = True
+ ) -> Player:
+ """
+ Create new Sync Group Player.
+
+ :param name: Name of the group player.
+ :param members: List of player ids to add to the group.
+ :param dynamic: Whether the group is dynamic (members can change).
+ """
+ # validation to ensure all members are compatible (can_group_with check)
+ members = [x for x in members if x in [y.player_id for y in self.mass.players]]
+ final_members: list[str] = []
+ can_group_with: set[str] = set()
+ for member_id in members:
+ member = self.mass.players.get_player(member_id)
+ if member is None or not member.available:
+ continue
+ if not can_group_with:
+ # first member, add all its compatible players to the can_group_with set
+ can_group_with = set(member.state.can_group_with)
+ if member_id not in can_group_with:
+ # member is not compatible with the current group, skip it
+ continue
+ final_members.append(member_id)
+ # generate a new player_id for the group player
+ player_id = f"{SGP_PREFIX}{shortuuid.random(8).lower()}"
+ self.mass.config.create_default_player_config(
+ player_id=player_id,
+ provider=self.instance_id,
+ player_type=PlayerType.GROUP,
+ name=name,
+ enabled=True,
+ values={
+ CONF_GROUP_MEMBERS: final_members,
+ CONF_DYNAMIC_GROUP_MEMBERS: dynamic,
+ },
+ )
+ return await self._register_player(player_id)
+
+ async def remove_group_player(self, player_id: str) -> None:
+ """
+ Remove a group player.
+
+ :param player_id: ID of the group player to remove.
+ """
+ # we simply permanently unregister the player and wipe its config
+ await self.mass.players.unregister(player_id, True)
+
+ async def discover_players(self) -> None:
+ """Discover players."""
+ for player_conf in await self.mass.config.get_player_configs(self.instance_id):
+ if player_conf.player_id.startswith(SGP_PREFIX):
+ await self._register_player(player_conf.player_id)
+
+ async def _register_player(self, player_id: str) -> Player:
+ """Register a sync group player."""
+ group = SyncGroupPlayer(self, player_id)
+ await self.mass.players.register_or_update(group)
+ return group
if TYPE_CHECKING:
from .provider import UniversalGroupProvider
-BASE_FEATURES = {PlayerFeature.POWER, PlayerFeature.VOLUME_SET, PlayerFeature.MULTI_DEVICE_DSP}
+BASE_FEATURES = {
+ PlayerFeature.PLAY_MEDIA,
+ PlayerFeature.POWER,
+ PlayerFeature.VOLUME_SET,
+ PlayerFeature.MULTI_DEVICE_DSP,
+}
class UniversalGroupPlayer(GroupPlayer):
f"/ugp/{self.player_id}.aac", self._serve_ugp_stream
)
)
- # allow grouping with all providers, except the ugp provider itself
- self._attr_can_group_with = {
- x.instance_id
- for x in self.mass.players.providers
- if x.instance_id != self.provider.instance_id
- }
self._set_attributes()
@property
"""Return if the player requires flow mode."""
return True
+ @property
+ def can_group_with(self) -> set[str]:
+ """Return the id's of players this player can group with."""
+ if not self.is_dynamic:
+ # in case of static members,
+ # we can only group with the players defined in the config, so we return those directly
+ return set(self._attr_static_group_members)
+ # allow grouping with all providers, except the ugp provider itself
+ return {
+ x.instance_id
+ for x in self.mass.players.providers
+ if x.instance_id != self.provider.instance_id
+ }
+
async def on_config_updated(self) -> None:
"""Handle logic when the player is loaded or updated."""
static_members = cast("list[str]", self.config.get_value(CONF_GROUP_MEMBERS, []))
required=False, # needed for dynamic members (which allows empty members list)
options=[
ConfigValueOption(x.display_name, x.player_id)
- for x in self.mass.players.all(True, False)
+ for x in self.mass.players.all_players(True, False)
if x.type != PlayerType.GROUP
],
),
"""Handle STOP command."""
async with TaskManager(self.mass) as tg:
for member in self.mass.players.iter_group_members(self, active_only=True):
- tg.create_task(member.stop())
+ # Use internal handler to get protocol selection and avoid redirect
+ tg.create_task(self.mass.players._handle_cmd_stop(member.player_id))
# abort the stream session
if self.stream and not self.stream.done:
await self.stream.stop()
async def power(self, powered: bool) -> None:
"""Handle POWER command to group player."""
# always stop at power off
- if not powered and self.playback_state in (PlaybackState.PLAYING, PlaybackState.PAUSED):
+ if not powered and self._attr_playback_state in (
+ PlaybackState.PLAYING,
+ PlaybackState.PAUSED,
+ ):
await self.stop()
# optimistically set the group state
self._attr_group_members = []
for static_group_member in self._attr_static_group_members:
if (
- (member_player := self.mass.players.get(static_group_member))
+ (member_player := self.mass.players.get_player(static_group_member))
and member_player.available
and member_player.enabled
):
and member.active_source != self.active_source
):
# stop playing existing content on member if we start the group player
- await member.stop()
- if member.active_group is not None and member.active_group != self.player_id:
+ # Use internal handler to get protocol selection and avoid redirect
+ await self.mass.players._handle_cmd_stop(member.player_id)
+ if (
+ member.state.active_group is not None
+ and member.state.active_group != self.player_id
+ ):
# collision: child player is part of multiple groups
# and another group already active !
# solve this by trying to leave the group first
- if other_group := self.mass.players.get(member.active_group):
+ if other_group := self.mass.players.get_player(member.state.active_group):
if (
other_group.supports_feature(PlayerFeature.SET_MEMBERS)
and member.player_id not in other_group.static_group_members
for member in self.mass.players.iter_group_members(
self, only_powered=True, active_only=True
):
+ # Use internal handler to get protocol selection and avoid redirect
tg.create_task(
- member.play_media(
+ self.mass.players._handle_play_media(
+ member.player_id,
PlayerMedia(
uri=f"{base_url}?player_id={member.player_id}",
media_type=MediaType.FLOW_STREAM,
title=self.display_name,
source_id=self.player_id,
- )
+ ),
)
)
raise UnsupportedFeaturedException(
f"Cannot add {self.display_name} to itself as a member!"
)
- child_player = self.mass.players.get(player_id, True)
+ child_player = self.mass.players.get_player(player_id, True)
assert child_player # for type checking
if child_player.synced_to:
# This is player is part of a syncgroup - ungroup it first
# let the newly add member join the stream if we're playing
if self.stream and not self.stream.done and self.powered:
base_url = f"{self.mass.streams.base_url}/ugp/{self.player_id}.flac"
- await child_player.play_media(
- media=PlayerMedia(
+ # Use internal handler to get protocol selection and avoid redirect
+ await self.mass.players._handle_play_media(
+ player_id,
+ PlayerMedia(
uri=f"{base_url}?player_id={player_id}",
media_type=MediaType.FLOW_STREAM,
title=self.display_name,
- source_id=child_player.player_id,
+ source_id=player_id,
),
)
# handle removals
f"Cannot remove {self.display_name} from itself as a member!"
)
self._attr_group_members.remove(player_id)
- child_player = self.mass.players.get(player_id, True)
+ child_player = self.mass.players.get_player(player_id, True)
assert child_player is not None # for type checking
if child_player.playback_state in (
PlaybackState.PLAYING,
PlaybackState.PAUSED,
):
# if the child player is playing the group stream, stop it
- await child_player.stop()
+ # Use internal handler to get protocol selection and avoid redirect
+ await self.mass.players._handle_cmd_stop(player_id)
self.update_state()
async def poll(self) -> None:
child_player_id = request.query.get("player_id") # optional!
output_format_str = request.path.rsplit(".")[-1]
- if child_player_id and (child_player := self.mass.players.get(child_player_id)):
+ if child_player_id and (child_player := self.mass.players.get_player(child_player_id)):
# Use the preferred output format of the child player
output_format = await self.mass.streams.get_output_format(
output_format_str=output_format_str,
output_format = AudioFormat(content_type=ContentType.MP3)
http_profile = "chunked"
- if not (ugp_player := self.mass.players.get(ugp_player_id)):
+ if not (ugp_player := self.mass.players.get_player(ugp_player_id)):
raise web.HTTPNotFound(reason=f"Unknown UGP player: {ugp_player_id}")
if not self.stream or self.stream.done:
--- /dev/null
+# Universal Player Provider
+
+## Overview
+
+The Universal Player provider creates virtual players that merge multiple protocol players (AirPlay, Chromecast, DLNA, Squeezelite, SendSpin) for the same physical device into a single unified player.
+
+## When is a Universal Player Created?
+
+A Universal Player is automatically created by the PlayerController when:
+
+1. **Multiple protocol players are detected for the same device** - Based on MAC address or IP matching
+2. **No native player provider exists** - e.g., a Denon AVR with Chromecast, AirPlay, and DLNA but no native Denon integration
+
+## Example Scenario
+
+Consider a Denon AVR receiver that supports:
+- Chromecast built-in
+- AirPlay 2
+- DLNA
+
+Without a native Denon provider in Music Assistant, the system would normally show three separate players:
+- "Living Room (Chromecast)"
+- "Living Room (AirPlay)"
+- "Living Room (DLNA)"
+
+With the Universal Player provider, these are merged into a single:
+- "Living Room" (Universal Player)
+ - Output protocols: Chromecast, AirPlay, DLNA
+
+## How It Works
+
+### Device Matching
+
+Protocol players are matched to the same device using:
+1. **MAC address** - Most reliable, extracted from device info
+2. **IP address** - Fallback when MAC is not available
+
+### Player Creation Flow
+
+```
+1. Chromecast player registers → No native parent, no other protocols → Stays as regular player
+2. AirPlay player registers → Matches Chromecast by MAC → PlayerController creates UniversalPlayer
+3. DLNA player registers → Matches existing UniversalPlayer → Added as linked protocol
+```
+
+### Feature Aggregation
+
+The Universal Player aggregates features from all linked protocols:
+- Volume control from the protocol that supports it best
+- Power control from any protocol that supports it
+- Pause/Play from active protocol
+
+### Playback Routing
+
+The Universal Player does NOT have `PLAY_MEDIA` capability. Instead:
+1. User selects "Living Room" and starts playback
+2. PlayerController uses `_select_best_output_protocol()` to choose best protocol
+3. Playback is routed to the selected protocol player (e.g., Chromecast)
+4. User can switch to different protocol in player settings
+
+## Configuration
+
+Universal Players are auto-created and require no user configuration. However, users can:
+- Rename the player
+- Choose preferred output protocol
+- Disable/enable the player
+
+## Cleanup
+
+When all protocol players for a device are removed (e.g., provider unloaded), the Universal Player is automatically cleaned up.
+
+If a native provider is later installed (e.g., Denon integration), the Universal Player is replaced by the native player, with all protocols linked to it instead.
+
+## Technical Details
+
+### Player ID Format
+
+Universal players use the format: `up{device_key}`
+
+Where `device_key` is typically the normalized MAC address.
+
+### File Structure
+
+```
+universal_player/
+├── __init__.py # Provider setup
+├── provider.py # UniversalPlayerProvider class
+├── player.py # UniversalPlayer class
+├── constants.py # Constants (prefix, etc.)
+├── manifest.json # Provider manifest (builtin)
+└── README.md # This file
+```
+
+### Provider Features
+
+The Universal Player provider has no special provider features - it doesn't support manual player creation via the UI. Players are only created automatically by the PlayerController.
--- /dev/null
+"""
+Universal Player provider.
+
+Auto-creates virtual players that merge multiple protocol players
+(AirPlay, Chromecast, DLNA, Squeezelite, SendSpin) for the same device
+into a single unified player when no native provider exists.
+"""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from .player import UniversalPlayer
+from .provider import UniversalPlayerProvider
+
+if TYPE_CHECKING:
+ from music_assistant_models.config_entries import ConfigEntry, ConfigValueType, ProviderConfig
+ from music_assistant_models.enums import ProviderFeature
+ from music_assistant_models.provider import ProviderManifest
+
+ from music_assistant import MusicAssistant
+ from music_assistant.models import ProviderInstanceType
+
+SUPPORTED_FEATURES: set[ProviderFeature] = set()
+
+
+async def setup(
+ mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
+) -> ProviderInstanceType:
+ """Initialize provider(instance) with given configuration."""
+ return UniversalPlayerProvider(mass, manifest, config, SUPPORTED_FEATURES)
+
+
+async def get_config_entries(
+ mass: MusicAssistant, # noqa: ARG001
+ instance_id: str | None = None, # noqa: ARG001
+ action: str | None = None, # noqa: ARG001
+ values: dict[str, ConfigValueType] | None = None, # noqa: ARG001
+) -> tuple[ConfigEntry, ...]:
+ """
+ Return Config entries to setup this provider.
+
+ instance_id: id of an existing provider instance (None if new instance setup).
+ action: [optional] action key called from config entries UI.
+ values: the (intermediate) raw values for config entries sent with the action.
+ """
+ # Nothing to configure - universal players are auto-created
+ return ()
+
+
+__all__ = (
+ "UniversalPlayer",
+ "UniversalPlayerProvider",
+ "get_config_entries",
+ "setup",
+)
--- /dev/null
+"""Universal Player constants."""
+
+from __future__ import annotations
+
+from typing import Final
+
+UNIVERSAL_PLAYER_PREFIX: Final[str] = "up"
+
+# Config key for storing linked protocol player IDs (hidden config entry)
+CONF_LINKED_PROTOCOL_IDS: Final[str] = "linked_protocol_ids"
+
+# Config key for storing device identifiers (MAC, UUID, etc.)
+CONF_DEVICE_IDENTIFIERS: Final[str] = "device_identifiers"
+
+# Config key for storing device info (model, manufacturer)
+CONF_DEVICE_INFO: Final[str] = "device_info"
--- /dev/null
+{
+ "type": "player",
+ "domain": "universal_player",
+ "stage": "stable",
+ "name": "Universal Player",
+ "description": "Provides support for players that have no native (vendor-specific) provider in Music Assistant but support one or more generic streaming protocols such as AirPlay, Chromecast, or DLNA. Automatically created for protocol-only devices.",
+ "codeowners": ["@music-assistant"],
+ "requirements": [],
+ "documentation": "https://music-assistant.io/",
+ "multi_instance": false,
+ "builtin": true,
+ "allow_disable": false,
+ "icon": "speaker"
+}
--- /dev/null
+"""
+Universal Player implementation.
+
+A virtual player for devices that have no native (vendor-specific) provider in
+Music Assistant but support one or more generic streaming protocols such as
+AirPlay, Sendspin, Chromecast, or DLNA.
+
+The Universal Player is automatically created when a protocol player with
+PlayerType.PROTOCOL is registered, providing a unified interface while delegating
+actual playback to the underlying protocol player(s).
+"""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from music_assistant_models.enums import PlayerFeature
+
+from music_assistant.constants import CONF_PREFERRED_OUTPUT_PROTOCOL
+from music_assistant.models.player import DeviceInfo, Player
+
+if TYPE_CHECKING:
+ from .provider import UniversalPlayerProvider
+
+
+class UniversalPlayer(Player):
+ """
+ Universal Player implementation.
+
+ A virtual player for devices without native Music Assistant support that use
+ generic streaming protocols. It does NOT have PLAY_MEDIA capability on its own.
+ Playback is always delegated to one of the linked protocol players via the protocol
+ linking system.
+ """
+
+ def __init__(
+ self,
+ provider: UniversalPlayerProvider,
+ player_id: str,
+ name: str,
+ device_info: DeviceInfo,
+ protocol_player_ids: list[str],
+ ) -> None:
+ """
+ Initialize UniversalPlayer instance.
+
+ :param provider: The UniversalPlayerProvider instance.
+ :param player_id: Unique player ID (typically based on MAC address).
+ :param name: Display name for the player.
+ :param device_info: Device information aggregated from protocol players.
+ :param protocol_player_ids: List of protocol player IDs to link.
+ """
+ super().__init__(provider, player_id)
+ self._protocol_player_ids = protocol_player_ids
+ # Set player attributes
+ self._attr_name = name
+ self._attr_device_info = device_info
+ # Start as unavailable - will be updated when protocol players are linked
+ self._attr_available = False
+ # a universal player does not have any features on its own,
+ # it delegates to protocol players
+ self._attr_supported_features = set()
+
+ def _get_control_target(
+ self, required_feature: PlayerFeature, require_active: bool = False
+ ) -> Player | None:
+ """Get the best player to send control commands to.
+
+ Prefers the active output protocol, otherwise uses the first available
+ protocol player that supports the needed feature.
+ """
+ # If we have an active protocol, use that
+ if (
+ self.active_output_protocol
+ and self.active_output_protocol != "native"
+ and (protocol_player := self.mass.players.get_player(self.active_output_protocol))
+ and required_feature in protocol_player.supported_features
+ ):
+ return protocol_player
+
+ # If require_active is set, and no active protocol found, return None
+ if require_active:
+ return None
+
+ # Otherwise, use the first available linked protocol
+ for protocol_player_id in self._protocol_player_ids:
+ if (
+ (protocol_player := self.mass.players.get_player(protocol_player_id))
+ and protocol_player.available
+ and required_feature in protocol_player.supported_features
+ ):
+ return protocol_player
+
+ return None
+
+ def update_from_protocol_players(self) -> None:
+ """
+ Update state from linked protocol players.
+
+ Called to sync state like volume, availability from protocol players.
+ """
+ # Aggregate availability - available if any protocol is available
+ self._attr_available = any(
+ (p := self.mass.players.get_player(pid)) and p.available
+ for pid in self._protocol_player_ids
+ )
+ # Get volume from best control target
+ if target := self._get_control_target(PlayerFeature.VOLUME_SET):
+ if target.volume_level is not None:
+ self._attr_volume_level = target.volume_level
+ if target := self._get_control_target(PlayerFeature.VOLUME_MUTE):
+ if target.volume_muted is not None:
+ self._attr_volume_muted = target.volume_muted
+
+ self.update_state()
+
+ def add_protocol_player(self, protocol_player_id: str) -> None:
+ """Add a protocol player to this universal player."""
+ if protocol_player_id not in self._protocol_player_ids:
+ self._protocol_player_ids.append(protocol_player_id)
+
+ def remove_protocol_player(self, protocol_player_id: str) -> None:
+ """Remove a protocol player from this universal player."""
+ if protocol_player_id in self._protocol_player_ids:
+ self._protocol_player_ids.remove(protocol_player_id)
+
+ def _get_preferred_protocol_player(self) -> Player | None:
+ """
+ Get the preferred protocol player for this universal player.
+
+ Selection priority:
+ 1. Active output protocol (if set and available)
+ 2. User's preferred output protocol (from settings), fallback to highest
+ priority if preferred is not available
+ """
+ # 1. Active output protocol takes precedence
+ if (
+ self.active_output_protocol
+ and self.active_output_protocol != "native"
+ and (protocol_player := self.mass.players.get_player(self.active_output_protocol))
+ and protocol_player.available
+ ):
+ return protocol_player
+
+ # 2. User's preferred output protocol (with fallback to highest priority)
+ preferred = self.mass.config.get_raw_player_config_value(
+ self.player_id, CONF_PREFERRED_OUTPUT_PROTOCOL
+ )
+ if preferred and (protocol_player := self.mass.players.get_player(str(preferred))):
+ if protocol_player.available:
+ return protocol_player
+
+ # Fallback: if user's preferred protocol is not available,
+ # use the highest priority available protocol
+ for protocol in sorted(self.linked_output_protocols, key=lambda x: x.priority):
+ if protocol_player := self.mass.players.get_player(protocol.output_protocol_id):
+ if protocol_player.available:
+ return protocol_player
+
+ return None
--- /dev/null
+"""Universal Player Provider implementation.
+
+This provider manages UniversalPlayer instances that are auto-created for devices
+that have no native (vendor-specific) provider in Music Assistant but support one
+or more generic streaming protocols such as AirPlay, Chromecast, or DLNA.
+
+The Universal Player acts as a virtual player wrapper that provides a unified
+interface while delegating actual playback to the underlying protocol player(s).
+"""
+
+from __future__ import annotations
+
+import asyncio
+from typing import TYPE_CHECKING
+
+from music_assistant_models.enums import IdentifierType, PlayerType
+
+from music_assistant.constants import CONF_PLAYERS
+from music_assistant.models.player import DeviceInfo
+from music_assistant.models.player_provider import PlayerProvider
+
+from .constants import (
+ CONF_DEVICE_IDENTIFIERS,
+ CONF_DEVICE_INFO,
+ CONF_LINKED_PROTOCOL_IDS,
+ UNIVERSAL_PLAYER_PREFIX,
+)
+from .player import UniversalPlayer
+
+if TYPE_CHECKING:
+ from music_assistant.models.player import Player
+
+
+class UniversalPlayerProvider(PlayerProvider):
+ """
+ Universal Player Provider.
+
+ Manages virtual players for devices that have no native (vendor-specific) provider
+ but support generic streaming protocols like AirPlay, Chromecast, or DLNA.
+ These players are automatically created when protocol players with PlayerType.PROTOCOL
+ are registered, providing a unified interface while delegating playback to the
+ underlying protocol player(s).
+ """
+
+ async def handle_async_init(self) -> None:
+ """Handle async initialization of the provider."""
+ # Lock to prevent race conditions during universal player creation
+ self._universal_player_locks: dict[str, asyncio.Lock] = {}
+
+ async def discover_players(self) -> None:
+ """
+ Discover players.
+
+ Universal players are created dynamically by the PlayerController,
+ not through discovery. However, we restore previously created
+ universal players from config.
+ """
+ for player_conf in await self.mass.config.get_player_configs(self.instance_id):
+ if player_conf.player_id.startswith(UNIVERSAL_PLAYER_PREFIX):
+ # Restore universal player from config
+ # The stored protocol IDs enable fast matching when protocols register
+ await self._restore_player(player_conf.player_id)
+
+ async def _restore_player(self, player_id: str) -> None:
+ """
+ Restore a universal player from config.
+
+ The stored protocol_player_ids enable fast matching when protocol players
+ register - they can be linked immediately without waiting for identifier matching.
+ Device identifiers are also restored to enable matching new protocol players.
+ """
+ # Get stored config values
+ config = self.mass.config.get(f"{CONF_PLAYERS}/{player_id}")
+ if not config:
+ return
+
+ # Get stored values
+ values = config.get("values", {})
+ stored_protocol_ids = values.get(CONF_LINKED_PROTOCOL_IDS, [])
+ stored_identifiers = values.get(CONF_DEVICE_IDENTIFIERS, {})
+ stored_device_info = values.get(CONF_DEVICE_INFO, {})
+
+ # Check if protocols have been linked to a native player (stale universal player)
+ for protocol_id in stored_protocol_ids:
+ protocol_config = self.mass.config.get(f"{CONF_PLAYERS}/{protocol_id}")
+ if protocol_config:
+ protocol_values = protocol_config.get("values", {})
+ protocol_parent_id = protocol_values.get("protocol_parent_id")
+ if protocol_parent_id and protocol_parent_id != player_id:
+ self.logger.info(
+ "Deleting stale universal player %s - protocol %s has moved to parent %s",
+ player_id,
+ protocol_id,
+ protocol_parent_id,
+ )
+ await self.mass.config.remove_player_config(player_id)
+ return
+
+ # Check if native player has this protocol in linked_protocol_player_ids
+ all_player_configs = self.mass.config.get(CONF_PLAYERS, {})
+ for other_player_id, other_config in all_player_configs.items():
+ if other_player_id == player_id:
+ continue
+ if other_config.get("provider") == "universal_player":
+ continue
+ other_values = other_config.get("values", {})
+ linked_protocols = other_values.get("linked_protocol_player_ids", [])
+ if protocol_id in linked_protocols:
+ self.logger.info(
+ "Deleting stale universal player %s - "
+ "protocol %s is linked to native player %s",
+ player_id,
+ protocol_id,
+ other_player_id,
+ )
+ await self.mass.config.remove_player_config(player_id)
+ return
+
+ # Restore device info with stored values or defaults
+ device_info = DeviceInfo(
+ model=stored_device_info.get("model", "Universal Player"),
+ manufacturer=stored_device_info.get("manufacturer", "Music Assistant"),
+ )
+
+ # Restore identifiers (convert string keys back to IdentifierType enum)
+ for id_type_str, value in stored_identifiers.items():
+ try:
+ id_type = IdentifierType(id_type_str)
+ device_info.add_identifier(id_type, value)
+ except ValueError:
+ self.logger.warning(
+ "Unknown identifier type %s for player %s", id_type_str, player_id
+ )
+
+ name = config.get("name", f"Universal Player {player_id}")
+
+ self.logger.debug(
+ "Restoring universal player %s with %d protocol IDs and %d identifiers",
+ player_id,
+ len(stored_protocol_ids),
+ len(stored_identifiers),
+ )
+
+ player = UniversalPlayer(
+ provider=self,
+ player_id=player_id,
+ name=name,
+ device_info=device_info,
+ protocol_player_ids=list(stored_protocol_ids),
+ )
+ await self.mass.players.register_or_update(player)
+
+ async def create_universal_player(
+ self,
+ device_key: str,
+ name: str,
+ device_info: DeviceInfo,
+ protocol_player_ids: list[str],
+ ) -> Player:
+ """
+ Create a new UniversalPlayer.
+
+ Called by the PlayerController when multiple protocol players are
+ detected for a device without a native player.
+
+ :param device_key: Unique device key (typically MAC address).
+ :param name: Display name for the player.
+ :param device_info: Aggregated device information.
+ :param protocol_player_ids: List of protocol player IDs to link.
+ :return: The created UniversalPlayer instance.
+ """
+ # Generate player_id from device_key
+ player_id = f"{UNIVERSAL_PLAYER_PREFIX}{device_key}"
+
+ # Check if player already exists
+ if existing := self.mass.players.get_player(player_id):
+ # Update existing player with new protocol players
+ if isinstance(existing, UniversalPlayer):
+ for pid in protocol_player_ids:
+ existing.add_protocol_player(pid)
+ # Merge identifiers from new device_info
+ for id_type, value in device_info.identifiers.items():
+ existing.device_info.add_identifier(id_type, value)
+ # Persist updated data to config
+ await self._save_player_data(player_id, existing)
+ existing.update_state()
+ return existing
+
+ # Create config for the new player (complex values saved separately after)
+ self.mass.config.create_default_player_config(
+ player_id=player_id,
+ provider=self.instance_id,
+ player_type=PlayerType.GROUP,
+ name=name,
+ enabled=True,
+ values={
+ CONF_LINKED_PROTOCOL_IDS: protocol_player_ids,
+ },
+ )
+
+ # Save device identifiers and info to config (these are nested dicts,
+ # not supported by ConfigValueType, so we save them directly)
+ base_key = f"{CONF_PLAYERS}/{player_id}/values"
+ self.mass.config.set(
+ f"{base_key}/{CONF_DEVICE_IDENTIFIERS}",
+ {k.value: v for k, v in device_info.identifiers.items()},
+ )
+ self.mass.config.set(
+ f"{base_key}/{CONF_DEVICE_INFO}",
+ {"model": device_info.model, "manufacturer": device_info.manufacturer},
+ )
+
+ self.logger.info(
+ "Creating universal player %s with protocol players: %s",
+ player_id,
+ protocol_player_ids,
+ )
+
+ # Create the player instance
+ player = UniversalPlayer(
+ provider=self,
+ player_id=player_id,
+ name=name,
+ device_info=device_info,
+ protocol_player_ids=protocol_player_ids,
+ )
+
+ await self.mass.players.register_or_update(player)
+ return player
+
+ async def _save_protocol_ids(self, player_id: str, protocol_player_ids: list[str]) -> None:
+ """Save protocol player IDs to config for persistence across restarts."""
+ conf_key = f"{CONF_PLAYERS}/{player_id}/values/{CONF_LINKED_PROTOCOL_IDS}"
+ self.mass.config.set(conf_key, protocol_player_ids)
+ self.logger.debug(
+ "Saved protocol IDs for %s: %s",
+ player_id,
+ protocol_player_ids,
+ )
+
+ async def _save_player_data(self, player_id: str, player: UniversalPlayer) -> None:
+ """Save all player data to config for persistence across restarts."""
+ base_key = f"{CONF_PLAYERS}/{player_id}/values"
+
+ # Save protocol IDs
+ self.mass.config.set(
+ f"{base_key}/{CONF_LINKED_PROTOCOL_IDS}",
+ player._protocol_player_ids,
+ )
+
+ # Save identifiers (convert IdentifierType enum keys to strings)
+ self.mass.config.set(
+ f"{base_key}/{CONF_DEVICE_IDENTIFIERS}",
+ {k.value: v for k, v in player.device_info.identifiers.items()},
+ )
+
+ # Save device info (model, manufacturer)
+ self.mass.config.set(
+ f"{base_key}/{CONF_DEVICE_INFO}",
+ {
+ "model": player.device_info.model,
+ "manufacturer": player.device_info.manufacturer,
+ },
+ )
+
+ self.logger.debug(
+ "Saved player data for %s: %d protocols, %d identifiers",
+ player_id,
+ len(player._protocol_player_ids),
+ len(player.device_info.identifiers),
+ )
+
+ async def add_protocol_to_universal_player(
+ self, player_id: str, protocol_player_id: str
+ ) -> None:
+ """
+ Add a protocol player to an existing universal player.
+
+ Called when a new protocol player is discovered that matches an existing
+ universal player.
+
+ :param player_id: ID of the universal player.
+ :param protocol_player_id: ID of the protocol player to add.
+ """
+ if player := self.get_universal_player(player_id):
+ player.add_protocol_player(protocol_player_id)
+ # Save all player data (protocol IDs, identifiers, device info)
+ await self._save_player_data(player_id, player)
+ player.update_state()
+
+ async def remove_universal_player(self, player_id: str) -> None:
+ """
+ Remove a universal player.
+
+ Called when all protocol players for a device are removed.
+
+ :param player_id: ID of the universal player to remove.
+ """
+ await self.mass.players.unregister(player_id, permanent=True)
+
+ async def ensure_universal_player_for_protocols(
+ self, protocol_players: list[Player]
+ ) -> Player | None:
+ """
+ Ensure a universal player exists for a set of protocol players.
+
+ This method handles the orchestration of creating or updating a universal player
+ for the given protocol players. It uses per-device locking to prevent race
+ conditions when multiple protocols for the same device register simultaneously.
+
+ :param protocol_players: List of protocol players for the same device.
+ :return: The created or updated universal player, or None if operation failed.
+ """
+ device_key = self._get_device_key_from_players(protocol_players)
+ if not device_key:
+ return None
+
+ universal_player_id = f"{UNIVERSAL_PLAYER_PREFIX}{device_key}"
+
+ # Use a per-device lock to prevent race conditions
+ if device_key not in self._universal_player_locks:
+ self._universal_player_locks[device_key] = asyncio.Lock()
+
+ async with self._universal_player_locks[device_key]:
+ # Re-check - another task may have already handled these players
+ # Filter out players that are already linked to a parent
+ protocol_players = [p for p in protocol_players if not p.protocol_parent_id]
+ if not protocol_players:
+ return None
+
+ # Check if universal player already exists
+ if existing := self.mass.players.get_player(universal_player_id):
+ # Update existing universal player with new protocol players
+ protocol_player_ids = [p.player_id for p in protocol_players]
+ for player_id in protocol_player_ids:
+ if isinstance(existing, UniversalPlayer):
+ await self.add_protocol_to_universal_player(universal_player_id, player_id)
+ return existing
+
+ # Create new universal player
+ device_info = self._aggregate_device_info(protocol_players)
+ name = self._get_clean_player_name(protocol_players)
+ protocol_player_ids = [p.player_id for p in protocol_players]
+
+ return await self.create_universal_player(
+ device_key=device_key,
+ name=name,
+ device_info=device_info,
+ protocol_player_ids=protocol_player_ids,
+ )
+
+ def get_universal_player(self, player_id: str) -> UniversalPlayer | None:
+ """Get a UniversalPlayer by ID if it exists and is managed by this provider."""
+ if player := self.mass.players.get_player(player_id):
+ if isinstance(player, UniversalPlayer):
+ return player
+ return None
+
+ def _get_device_key_from_players(self, protocol_players: list[Player]) -> str | None:
+ """
+ Generate a device key from protocol players' identifiers.
+
+ Prefers MAC address (most stable), falls back to UUID, then player_id.
+ IP address is not used as it can change with DHCP and cause incorrect matches.
+ """
+ uuid_key: str | None = None
+ for player in protocol_players:
+ identifiers = player.device_info.identifiers
+ # Prefer MAC address (most reliable)
+ if mac := identifiers.get(IdentifierType.MAC_ADDRESS):
+ return mac.replace(":", "").replace("-", "").lower()
+ # Fall back to UUID (reliable for DLNA, Chromecast)
+ if not uuid_key and (uuid := identifiers.get(IdentifierType.UUID)):
+ # Normalize UUID: remove special characters, lowercase
+ uuid_key = uuid.replace("-", "").replace(":", "").replace("_", "").lower()
+ if uuid_key:
+ return uuid_key
+ # Last resort: use player_id as device key for protocol players without identifiers
+ # (e.g., Sendspin players that don't expose IP/MAC)
+ if protocol_players:
+ return protocol_players[0].player_id.replace(":", "").replace("-", "").lower()
+ return None
+
+ def _aggregate_device_info(self, protocol_players: list[Player]) -> DeviceInfo:
+ """Aggregate device info from protocol players."""
+ first_player = protocol_players[0]
+ device_info = DeviceInfo(
+ model=first_player.device_info.model,
+ manufacturer=first_player.device_info.manufacturer,
+ )
+ # Merge identifiers from all protocol players
+ for player in protocol_players:
+ for conn_type, value in player.device_info.identifiers.items():
+ device_info.add_identifier(conn_type, value)
+ return device_info
+
+ def _get_clean_player_name(self, protocol_players: list[Player]) -> str:
+ """
+ Get the best display name from protocol players.
+
+ Prefers names from protocols that typically provide user-friendly names
+ (Chromecast, DLNA, AirPlay) over those that may use technical identifiers
+ (Squeezelite, SendSpin). Filters out names that look like MAC addresses,
+ UUIDs, or player IDs.
+ """
+ # Protocol priority for name selection (higher priority = better names typically)
+ # Chromecast and DLNA usually have good user-configured names
+ # AirPlay also provides sensible names
+ # Squeezelite and SendSpin may use MAC addresses or technical IDs
+ name_priority = {
+ "chromecast": 1,
+ "airplay": 2,
+ "dlna": 3,
+ "squeezelite": 4,
+ "sendspin": 5,
+ }
+
+ def is_valid_name(name: str) -> bool:
+ """Check if a name looks like a real user-friendly name, not a technical ID."""
+ if not name or len(name) < 2:
+ return False
+ name_lower = name.lower().replace(":", "").replace("-", "").replace("_", "")
+ # Filter out names that look like MAC addresses (12 hex chars)
+ if len(name_lower) == 12 and all(c in "0123456789abcdef" for c in name_lower):
+ return False
+ # Filter out names that look like UUIDs
+ if len(name_lower) >= 32 and all(c in "0123456789abcdef" for c in name_lower[:32]):
+ return False
+ # Filter out names that start with common player ID prefixes
+ return not name_lower.startswith(
+ ("ap_", "cc_", "dlna_", "sq_", "sendspin_", "universal_")
+ )
+
+ # Sort players by protocol priority, then find the first valid name
+ sorted_players = sorted(
+ protocol_players,
+ key=lambda p: name_priority.get(p.provider.domain, 10),
+ )
+
+ for player in sorted_players:
+ player_name = player.state.name
+ if is_valid_name(player_name):
+ return player_name
+
+ # Fallback to first player's name if no valid name found
+ return protocol_players[0].display_name
"Programming Language :: Python :: 3.13",
]
dependencies = [
- "aiodns>=3.2.0",
- # Pin pycares to 4.11.0 until aiodns is updated to support pycares 5.0 API changes
- # pycares 5.0.0 (released 2025-12-10) has breaking changes incompatible with current aiodns
+ "aiodns>=3.2.0", # Pin pycares to 4.11.0 until aiodns is updated to support pycares 5.0 API changes # pycares 5.0.0 (released 2025-12-10) has breaking changes incompatible with current aiodns
"pycares==4.11.0",
"aiohttp_asyncmdnsresolver==0.1.1",
"Brotli>=1.0.9",
"COM812", # Conflicts with the Ruff formatter
"ASYNC109", # Not relevant, since we use helpers with configurable timeouts
"ASYNC110", # Use `asyncio.Event` instead of awaiting `asyncio.sleep` in a `while` loop
- "N818", # Just annoying, not really useful
- # TEMPORARY DISABLED rules # The below rules must be enabled later one-by-one !
+ "N818", # Just annoying, not really useful # TEMPORARY DISABLED rules # The below rules must be enabled later one-by-one !
"BLE001",
"FBT001",
"FBT002",
import asyncio
import contextlib
+import logging
import pathlib
from collections.abc import AsyncGenerator
+from unittest.mock import MagicMock
import aiofiles.os
-from music_assistant_models.enums import EventType
+from music_assistant_models.enums import EventType, IdentifierType, PlayerFeature, PlayerType
from music_assistant_models.event import MassEvent
+from music_assistant_models.player import DeviceInfo
from music_assistant.mass import MusicAssistant
+from music_assistant.models.player import Player
def _get_fixture_folder(provider: str | None = None) -> pathlib.Path:
finally:
await flag.wait()
release_cb()
+
+
+# Mock classes for testing
+
+
+def create_mock_config(name: str) -> MagicMock:
+ """Create a mock player config with the given name."""
+ config = MagicMock()
+ config.name = None # No custom name, use default
+ config.default_name = name
+ config.get_value = MagicMock(return_value="none") # Default to no power control
+ return config
+
+
+class MockProvider:
+ """Mock player provider for testing."""
+
+ def __init__(
+ self, domain: str, instance_id: str = "test_instance", mass: MagicMock | None = None
+ ) -> None:
+ """Initialize the mock provider."""
+ self.domain = domain
+ self.instance_id = instance_id
+ self.name = f"Mock {domain.title()}"
+ self.manifest = MagicMock()
+ self.manifest.name = f"Mock {domain} Provider"
+ self.mass = mass or MagicMock()
+ self.logger = logging.getLogger(f"test.{domain}")
+
+
+class MockPlayer(Player):
+ """Mock player for testing."""
+
+ def __init__(
+ self,
+ provider: MockProvider,
+ player_id: str,
+ name: str,
+ player_type: PlayerType = PlayerType.PLAYER,
+ identifiers: dict[IdentifierType, str] | None = None,
+ ) -> None:
+ """Initialize the mock player."""
+ # Set up the mock config before calling super().__init__
+ # because the parent __init__ accesses config
+ provider.mass.config.get_base_player_config.return_value = create_mock_config(name)
+
+ super().__init__(provider, player_id) # type: ignore[arg-type]
+ self._attr_name = name
+ # Set type as instance attribute (overrides class attribute)
+ self._attr_type = player_type
+ self._attr_available = True
+ self._attr_powered = True
+ self._attr_supported_features = {PlayerFeature.VOLUME_SET}
+ self._attr_can_group_with = set()
+ self._attr_group_members = []
+
+ # Set up device info with identifiers
+ self._attr_device_info = DeviceInfo(
+ model="Test Model",
+ manufacturer="Test Manufacturer",
+ )
+ if identifiers:
+ for conn_type, value in identifiers.items():
+ self._attr_device_info.add_identifier(conn_type, value)
+
+ # Clear cached properties after modifying attributes
+ self._cache.clear()
+
+ async def set_members(
+ self,
+ player_ids_to_add: list[str] | None = None,
+ player_ids_to_remove: list[str] | None = None,
+ ) -> None:
+ """Mock implementation of set_members."""
+ current_members = set(self._attr_group_members)
+
+ if player_ids_to_add:
+ current_members.update(player_ids_to_add)
+
+ if player_ids_to_remove:
+ current_members.difference_update(player_ids_to_remove)
+
+ # Always include self as first member if there are members
+ if current_members:
+ self._attr_group_members = [self.player_id] + [
+ pid for pid in current_members if pid != self.player_id
+ ]
+ else:
+ self._attr_group_members = []
+
+ # Clear cache to reflect changes
+ self._cache.clear()
+
+ async def stop(self) -> None:
+ """Stop playback - required abstract method."""
+
+
+class MockMass:
+ """Type hint for mocked MusicAssistant instance."""
--- /dev/null
+"""Tests for PlayerController high-level operations.
+
+This module tests:
+- cmd_set_members validation and execution
+- Group/ungroup commands
+- Player state management
+- Cache invalidation after grouping operations
+"""
+
+from __future__ import annotations
+
+import asyncio
+import contextlib
+from unittest.mock import MagicMock
+
+import pytest
+from music_assistant_models.enums import PlayerFeature
+from music_assistant_models.errors import UnsupportedFeaturedException
+
+from music_assistant.controllers.players import PlayerController
+from music_assistant.helpers.throttle_retry import Throttler
+from tests.common import MockPlayer, MockProvider
+
+
+@pytest.fixture
+def mock_mass() -> MagicMock:
+ """Create a mock MusicAssistant instance."""
+ mass = MagicMock()
+ mass.closing = False
+ mass.loop = None
+ mass.config = MagicMock()
+ mass.config.get = MagicMock(return_value=[])
+ mass.config.get_raw_player_config_value = MagicMock(return_value="auto")
+ # Return "GLOBAL" for log level config (standard default)
+ mass.config.get_raw_core_config_value = MagicMock(return_value="GLOBAL")
+ mass.config.set = MagicMock()
+ mass.signal_event = MagicMock()
+ mass.get_providers = MagicMock(return_value=[])
+ return mass
+
+
+@pytest.fixture
+def controller(mock_mass: MagicMock) -> PlayerController:
+ """Create a PlayerController instance."""
+ return PlayerController(mock_mass)
+
+
+class TestSetMembersValidation:
+ """Test cmd_set_members validation logic."""
+
+ def test_set_members_requires_feature(self, mock_mass: MagicMock) -> None:
+ """Test that set_members requires SET_MEMBERS feature."""
+ controller = PlayerController(mock_mass)
+ provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
+
+ leader = MockPlayer(provider, "leader", "Leader")
+ # Note: NOT adding SET_MEMBERS feature
+
+ member = MockPlayer(provider, "member", "Member")
+
+ controller._players = {"leader": leader, "member": member}
+ controller._player_throttlers = {
+ "leader": Throttler(1, 0.05),
+ "member": Throttler(1, 0.05),
+ }
+ mock_mass.players = controller
+
+ # Should raise exception because leader doesn't support SET_MEMBERS
+ with pytest.raises(UnsupportedFeaturedException):
+ asyncio.run(controller.cmd_set_members("leader", player_ids_to_add=["member"]))
+
+ def test_cannot_group_incompatible_players(self, mock_mass: MagicMock) -> None:
+ """Test that incompatible players cannot be grouped."""
+ controller = PlayerController(mock_mass)
+ provider_a = MockProvider("provider_a", instance_id="provider_a", mass=mock_mass)
+ provider_b = MockProvider("provider_b", instance_id="provider_b", mass=mock_mass)
+
+ player_a = MockPlayer(provider_a, "player_a", "Player A")
+ player_a._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
+ player_a._attr_can_group_with = {"provider_a"} # Only same provider
+
+ player_b = MockPlayer(provider_b, "player_b", "Player B")
+
+ controller._players = {"player_a": player_a, "player_b": player_b}
+ controller._player_throttlers = {
+ "player_a": Throttler(1, 0.05),
+ "player_b": Throttler(1, 0.05),
+ }
+ mock_mass.players = controller
+
+ # Should raise exception because players are incompatible
+ with pytest.raises(UnsupportedFeaturedException):
+ asyncio.run(controller.cmd_set_members("player_a", player_ids_to_add=["player_b"]))
+
+
+class TestCacheInvalidationAfterGrouping:
+ """Test that caches are invalidated after grouping operations."""
+
+ async def test_all_players_cache_cleared_after_set_members(self, mock_mass: MagicMock) -> None:
+ """
+ Test that all players' caches are cleared after set_members.
+
+ Regression test for: Stale can_group_with cache after grouping changes.
+ """
+ controller = PlayerController(mock_mass)
+ provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
+
+ leader = MockPlayer(provider, "leader", "Leader")
+ leader._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
+ leader._attr_can_group_with = {"test"}
+ leader._attr_group_members = []
+
+ member = MockPlayer(provider, "member", "Member")
+
+ other = MockPlayer(provider, "other", "Other")
+ other._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
+ other._attr_can_group_with = {"test"}
+
+ controller._players = {"leader": leader, "member": member, "other": other}
+ controller._player_throttlers = {
+ "leader": Throttler(1, 0.05),
+ "member": Throttler(1, 0.05),
+ "other": Throttler(1, 0.05),
+ }
+ mock_mass.players = controller
+
+ # Populate caches
+ _ = leader.state.can_group_with
+ _ = other.state.can_group_with
+
+ # Simulate grouping (normally done by provider's set_members implementation)
+ leader._attr_group_members = ["leader", "member"]
+
+ # Call set_members to trigger cache invalidation
+ await controller._handle_set_members_with_protocols(
+ leader, player_ids_to_add=["member"], player_ids_to_remove=[]
+ )
+
+ # Note: The actual cache clearing happens via trigger_player_update
+ # which schedules update_state to be called later
+ # In a real scenario, this would clear all players' caches
+
+
+class TestGroupUngroup:
+ """Test group and ungroup commands."""
+
+ async def test_group_command(self, mock_mass: MagicMock) -> None:
+ """Test the group command (cmd_group)."""
+ controller = PlayerController(mock_mass)
+ provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
+
+ leader = MockPlayer(provider, "leader", "Leader")
+ leader._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
+ leader._attr_can_group_with = {"member"} # Leader can group with member
+
+ member = MockPlayer(provider, "member", "Member")
+ # Make sure member is already powered on to skip power handling
+ member._attr_powered = True
+
+ controller._players = {"leader": leader, "member": member}
+ controller._player_throttlers = {
+ "leader": Throttler(1, 0.05),
+ "member": Throttler(1, 0.05),
+ }
+ mock_mass.players = controller
+
+ # Update state after modifying attributes and registering with controller
+ leader.update_state(signal_event=False)
+ member.update_state(signal_event=False)
+
+ # Track if set_members was called
+ set_members_called = False
+ original_set_members = leader.set_members
+
+ async def mock_set_members(
+ player_ids_to_add: list[str] | None = None,
+ player_ids_to_remove: list[str] | None = None,
+ ) -> None:
+ nonlocal set_members_called
+ set_members_called = True
+ # Call the original to update group_members
+ await original_set_members(player_ids_to_add, player_ids_to_remove)
+
+ leader.set_members = mock_set_members # type: ignore[method-assign]
+
+ # Mock power handling to skip power control (focus is on grouping logic)
+ async def mock_handle_cmd_power(player_id: str, powered: bool) -> None:
+ pass
+
+ controller._handle_cmd_power = mock_handle_cmd_power # type: ignore[method-assign]
+
+ # Execute group command
+ await controller.cmd_group("member", "leader")
+
+ # Verify set_members was called
+ assert set_members_called
+ # Verify member was added to leader's group
+ assert "member" in leader._attr_group_members
+
+
+class TestPlayerAvailability:
+ """Test player availability checks in grouping."""
+
+ def test_unavailable_player_rejected(self, mock_mass: MagicMock) -> None:
+ """Test that unavailable players are rejected when grouping."""
+ controller = PlayerController(mock_mass)
+ provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
+
+ leader = MockPlayer(provider, "leader", "Leader")
+ leader._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
+ leader._attr_can_group_with = {"test"}
+
+ member = MockPlayer(provider, "member", "Member")
+ member._attr_available = False # Mark as unavailable
+
+ controller._players = {"leader": leader, "member": member}
+ controller._player_throttlers = {
+ "leader": Throttler(1, 0.05),
+ "member": Throttler(1, 0.05),
+ }
+ mock_mass.players = controller
+
+ # Attempting to group with unavailable player should be handled
+ # (either silently ignored or raise exception depending on implementation)
+ # This should either skip the unavailable player or raise an exception
+ with contextlib.suppress(Exception):
+ asyncio.run(controller.cmd_set_members("leader", player_ids_to_add=["member"]))
+
+
+if __name__ == "__main__":
+ pytest.main([__file__, "-v"])
--- /dev/null
+"""Tests for player grouping logic (independent of protocols).
+
+This module tests the core grouping behavior including:
+- can_group_with filtering logic
+- Group member inclusion/exclusion
+- Sync leader behavior
+- Group state transitions
+- Cache invalidation
+"""
+
+from __future__ import annotations
+
+from unittest.mock import MagicMock
+
+import pytest
+from music_assistant_models.enums import PlaybackState, PlayerFeature
+
+from music_assistant.controllers.players import PlayerController
+from tests.common import MockPlayer, MockProvider
+
+
+@pytest.fixture
+def mock_mass() -> MagicMock:
+ """Create a mock MusicAssistant instance."""
+ mass = MagicMock()
+ mass.closing = False
+ mass.config = MagicMock()
+ mass.config.get = MagicMock(return_value=[])
+ mass.config.get_raw_player_config_value = MagicMock(return_value="auto")
+ # Return "GLOBAL" for log level config (standard default)
+ mass.config.get_raw_core_config_value = MagicMock(return_value="GLOBAL")
+ mass.config.set = MagicMock()
+ mass.signal_event = MagicMock()
+ mass.get_providers = MagicMock(return_value=[])
+ return mass
+
+
+@pytest.fixture
+def controller(mock_mass: MagicMock) -> PlayerController:
+ """Create a PlayerController instance."""
+ return PlayerController(mock_mass)
+
+
+class TestCanGroupWithBasics:
+ """Test basic can_group_with filtering logic."""
+
+ def test_ungrouped_players_can_group(self, mock_mass: MagicMock) -> None:
+ """Test that two ungrouped players can group with each other."""
+ controller = PlayerController(mock_mass)
+ provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
+
+ player_a = MockPlayer(provider, "player_a", "Player A")
+ player_a._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
+ # Use explicit player IDs instead of provider instance ID for simpler test
+ player_a._attr_can_group_with = {"player_b"}
+
+ player_b = MockPlayer(provider, "player_b", "Player B")
+ player_b._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
+ player_b._attr_can_group_with = {"player_a"}
+
+ controller._players = {"player_a": player_a, "player_b": player_b}
+ mock_mass.players = controller
+
+ # Trigger state calculation
+ player_a.update_state(signal_event=False)
+ player_b.update_state(signal_event=False)
+
+ # Both players should be able to group with each other
+ assert "player_b" in player_a.state.can_group_with
+ assert "player_a" in player_b.state.can_group_with
+
+ def test_unavailable_players_excluded(self, mock_mass: MagicMock) -> None:
+ """Test that unavailable players are excluded from can_group_with."""
+ controller = PlayerController(mock_mass)
+ provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
+
+ player_a = MockPlayer(provider, "player_a", "Player A")
+ player_a._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
+ player_a._attr_can_group_with = {"player_b"}
+
+ player_b = MockPlayer(provider, "player_b", "Player B")
+ player_b._attr_available = False # Mark as unavailable
+
+ controller._players = {"player_a": player_a, "player_b": player_b}
+ mock_mass.players = controller
+
+ # Trigger state calculation
+ player_a.update_state(signal_event=False)
+ player_b.update_state(signal_event=False)
+
+ # Unavailable player should be excluded
+ assert "player_b" not in player_a.state.can_group_with
+
+ def test_playing_players_with_different_source_excluded(self, mock_mass: MagicMock) -> None:
+ """Test that players playing different sources are NOT excluded (behavior changed).
+
+ Note: Previously, players with different active sources were excluded from grouping,
+ but this was removed as it was difficult to track reliably.
+ """
+ controller = PlayerController(mock_mass)
+ provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
+
+ player_a = MockPlayer(provider, "player_a", "Player A")
+ player_a._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
+ player_a._attr_can_group_with = {"player_b"}
+ player_a._attr_playback_state = PlaybackState.PLAYING
+ player_a._attr_active_source = "player_a"
+
+ player_b = MockPlayer(provider, "player_b", "Player B")
+ player_b._attr_playback_state = PlaybackState.PLAYING
+ player_b._attr_active_source = "player_b" # Different source
+
+ controller._players = {"player_a": player_a, "player_b": player_b}
+ mock_mass.players = controller
+
+ # Trigger state calculation
+ player_a.update_state(signal_event=False)
+ player_b.update_state(signal_event=False)
+
+ # Player with different active source is now ALLOWED (behavior changed)
+ assert "player_b" in player_a.state.can_group_with
+
+
+class TestSyncedPlayers:
+ """Test behavior with synced/grouped players."""
+
+ def test_synced_player_excluded_from_others(self, mock_mass: MagicMock) -> None:
+ """
+ Test that a player synced to another is excluded from other players' can_group_with.
+
+ Regression test for: Player synced to another showing up in third player's can_group_with.
+ """
+ controller = PlayerController(mock_mass)
+ provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
+
+ # Sync leader
+ leader = MockPlayer(provider, "leader", "Leader")
+ leader._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
+ leader._attr_can_group_with = {"synced", "other"}
+ leader._attr_group_members = ["leader", "synced"]
+ leader._attr_playback_state = PlaybackState.PLAYING # Make it playing so it gets excluded
+
+ # Synced player
+ synced = MockPlayer(provider, "synced", "Synced")
+
+ # Third player
+ other = MockPlayer(provider, "other", "Other")
+ other._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
+ other._attr_can_group_with = {"leader", "synced"}
+
+ controller._players = {"leader": leader, "synced": synced, "other": other}
+ mock_mass.players = controller
+
+ # Trigger synced_to calculation
+ leader.update_state(signal_event=False)
+ synced.update_state(signal_event=False)
+ other.update_state(signal_event=False)
+
+ # The synced player should NOT appear in other's can_group_with
+ assert "synced" not in other.state.can_group_with
+ # The leader should also NOT appear (has group members)
+ assert "leader" not in other.state.can_group_with
+ # Other should only see itself as ungrouped
+ assert other.state.can_group_with == set()
+
+ def test_sync_leader_excludes_itself_from_members_can_group_with(
+ self, mock_mass: MagicMock
+ ) -> None:
+ """Test that sync leader doesn't appear in its members' can_group_with."""
+ controller = PlayerController(mock_mass)
+ provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
+
+ leader = MockPlayer(provider, "leader", "Leader")
+ leader._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
+ leader._attr_can_group_with = {"member"}
+ leader._attr_group_members = ["leader", "member"]
+
+ member = MockPlayer(provider, "member", "Member")
+
+ controller._players = {"leader": leader, "member": member}
+ mock_mass.players = controller
+
+ # Trigger synced_to calculation
+ leader.update_state(signal_event=False)
+ member.update_state(signal_event=False)
+
+ # Member is synced, so can_group_with should be empty
+ assert member.state.can_group_with == set()
+
+ def test_group_members_included_in_leader_can_group_with(self, mock_mass: MagicMock) -> None:
+ """
+ Test that group members appear in sync leader's can_group_with.
+
+ This allows ungrouping members from the leader.
+ """
+ controller = PlayerController(mock_mass)
+ provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
+
+ leader = MockPlayer(provider, "leader", "Leader")
+ leader._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
+ leader._attr_can_group_with = {"member_a", "member_b"}
+ leader._attr_group_members = ["leader", "member_a", "member_b"]
+
+ member_a = MockPlayer(provider, "member_a", "Member A")
+ member_b = MockPlayer(provider, "member_b", "Member B")
+
+ controller._players = {
+ "leader": leader,
+ "member_a": member_a,
+ "member_b": member_b,
+ }
+ mock_mass.players = controller
+
+ # Trigger synced_to calculation
+ leader.update_state(signal_event=False)
+ member_a.update_state(signal_event=False)
+ member_b.update_state(signal_event=False)
+
+ # Leader should be able to see its own members (for ungrouping)
+ assert "member_a" in leader.state.can_group_with
+ assert "member_b" in leader.state.can_group_with
+
+
+class TestSyncLeaderBehavior:
+ """Test sync leader specific behavior."""
+
+ def test_sync_leader_excluded_from_can_group_with(self, mock_mass: MagicMock) -> None:
+ """Test that players with group members (sync leaders) are excluded."""
+ controller = PlayerController(mock_mass)
+ provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
+
+ leader = MockPlayer(provider, "leader", "Leader")
+ leader._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
+ leader._attr_can_group_with = {"member", "other"}
+ leader._attr_group_members = ["leader", "member"]
+ leader._attr_playback_state = PlaybackState.PLAYING # Make it playing so it gets excluded
+
+ member = MockPlayer(provider, "member", "Member")
+
+ other = MockPlayer(provider, "other", "Other")
+ other._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
+ other._attr_can_group_with = {"leader", "member"}
+
+ controller._players = {"leader": leader, "member": member, "other": other}
+ mock_mass.players = controller
+
+ # Trigger synced_to calculation
+ leader.update_state(signal_event=False)
+ member.update_state(signal_event=False)
+ other.update_state(signal_event=False)
+
+ # Leader should NOT appear in other's can_group_with (has group members)
+ assert "leader" not in other.state.can_group_with
+
+
+class TestCircularDependency:
+ """Test that circular dependencies are avoided."""
+
+ def test_no_circular_dependency_in_synced_to(self, mock_mass: MagicMock) -> None:
+ """
+ Test that synced_to calculation doesn't cause circular dependency.
+
+ Regression test for: synced_to calling group_members causing infinite recursion.
+ """
+ controller = PlayerController(mock_mass)
+ provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
+
+ leader = MockPlayer(provider, "leader", "Leader")
+ leader._attr_group_members = ["leader", "member"]
+
+ member = MockPlayer(provider, "member", "Member")
+
+ controller._players = {"leader": leader, "member": member}
+ mock_mass.players = controller
+
+ # Trigger synced_to calculation via update_state
+ leader.update_state(signal_event=False)
+ member.update_state(signal_event=False)
+
+ # This should not cause infinite recursion
+ assert member.state.synced_to == "leader"
+ assert leader.state.synced_to is None
+
+
+class TestCacheInvalidation:
+ """Test that caches are invalidated correctly."""
+
+ def test_can_group_with_cache_cleared_on_update_state(self, mock_mass: MagicMock) -> None:
+ """Test that can_group_with cache is cleared when update_state is called."""
+ controller = PlayerController(mock_mass)
+ provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
+
+ player_a = MockPlayer(provider, "player_a", "Player A")
+ player_a._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
+ player_a._attr_can_group_with = {"player_b"}
+
+ player_b = MockPlayer(provider, "player_b", "Player B")
+
+ controller._players = {"player_a": player_a, "player_b": player_b}
+ mock_mass.players = controller
+
+ # Update state after setting attributes and registering with controller
+ player_a.update_state(signal_event=False)
+ player_b.update_state(signal_event=False)
+
+ # Get can_group_with to populate cache
+ initial = player_a.state.can_group_with
+ assert "player_b" in initial
+
+ # Modify underlying data
+ player_a._attr_can_group_with = set()
+
+ # Cache should still have old value
+ assert player_a.state.can_group_with == initial
+
+ # Clear cache via update_state
+ player_a.update_state(signal_event=False)
+
+ # Cache should be cleared, new value should be returned
+ assert player_a.state.can_group_with == set()
+
+
+class TestProviderInstanceIdExpansion:
+ """Test expansion of provider instance IDs in can_group_with."""
+
+ def test_provider_instance_id_expands_to_all_players(self, mock_mass: MagicMock) -> None:
+ """Test that provider instance IDs expand to all available players from that provider."""
+ controller = PlayerController(mock_mass)
+ provider = MockProvider("test_provider", instance_id="test", mass=mock_mass)
+
+ player_a = MockPlayer(provider, "player_a", "Player A")
+ player_a._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
+ player_a._attr_can_group_with = {"test"} # Provider instance ID
+
+ player_b = MockPlayer(provider, "player_b", "Player B")
+ player_c = MockPlayer(provider, "player_c", "Player C")
+
+ controller._players = {
+ "player_a": player_a,
+ "player_b": player_b,
+ "player_c": player_c,
+ }
+ mock_mass.players = controller
+ # Set up get_provider to return the provider for instance ID
+ mock_mass.get_provider = MagicMock(return_value=provider)
+
+ # Trigger state calculation
+ player_a.update_state(signal_event=False)
+ player_b.update_state(signal_event=False)
+ player_c.update_state(signal_event=False)
+
+ # Provider instance ID should expand to include all players from that provider
+ can_group = player_a.state.can_group_with
+ assert "player_b" in can_group
+ assert "player_c" in can_group
+
+
+if __name__ == "__main__":
+ pytest.main([__file__, "-v"])
--- /dev/null
+"""Tests for protocol player linking and universal player creation."""
+
+import logging
+from unittest.mock import MagicMock
+
+import pytest
+from music_assistant_models.enums import (
+ IdentifierType,
+ PlayerFeature,
+ PlayerType,
+)
+from music_assistant_models.player import OutputProtocol
+
+from music_assistant.controllers.players import PlayerController
+from music_assistant.helpers.throttle_retry import Throttler
+from music_assistant.models.player import DeviceInfo, Player
+from music_assistant.providers.universal_player.provider import UniversalPlayerProvider
+
+
+def create_mock_config(name: str) -> MagicMock:
+ """Create a mock player config with the given name."""
+ config = MagicMock()
+ config.name = None # No custom name, use default
+ config.default_name = name
+ return config
+
+
+def create_mock_universal_provider(mock_mass: MagicMock) -> UniversalPlayerProvider:
+ """Create a mock UniversalPlayerProvider for testing."""
+ # Create a mock manifest
+ manifest = MagicMock()
+ manifest.domain = "universal_player"
+ manifest.name = "Universal Player"
+
+ # Create provider with the mock manifest
+ provider = UniversalPlayerProvider.__new__(UniversalPlayerProvider)
+ provider.mass = mock_mass
+ provider.manifest = manifest
+ provider.logger = logging.getLogger("test.universal_player")
+ return provider
+
+
+class MockProvider:
+ """Mock player provider for testing."""
+
+ def __init__(
+ self, domain: str, instance_id: str = "test_instance", mass: MagicMock | None = None
+ ) -> None:
+ """Initialize the mock provider."""
+ self.domain = domain
+ self.instance_id = instance_id
+ self.name = f"Mock {domain.title()}"
+ self.manifest = MagicMock()
+ self.manifest.name = f"Mock {domain} Provider"
+ self.mass = mass or MagicMock()
+ self.logger = logging.getLogger(f"test.{domain}")
+
+
+class MockPlayer(Player):
+ """Mock player for testing."""
+
+ def __init__(
+ self,
+ provider: MockProvider,
+ player_id: str,
+ name: str,
+ player_type: PlayerType = PlayerType.PLAYER,
+ identifiers: dict[IdentifierType, str] | None = None,
+ ) -> None:
+ """Initialize the mock player."""
+ # Set up the mock config before calling super().__init__
+ # because the parent __init__ accesses config
+ provider.mass.config.get_base_player_config.return_value = create_mock_config(name)
+
+ super().__init__(provider, player_id) # type: ignore[arg-type]
+ self._attr_name = name
+ # Set type as instance attribute (overrides class attribute)
+ self._attr_type = player_type
+ self._attr_available = True
+ self._attr_powered = True
+ self._attr_supported_features = {PlayerFeature.VOLUME_SET}
+ self._attr_can_group_with = set()
+
+ # Set up device info with identifiers
+ self._attr_device_info = DeviceInfo(
+ model="Test Model",
+ manufacturer="Test Manufacturer",
+ )
+ if identifiers:
+ for conn_type, value in identifiers.items():
+ self._attr_device_info.add_identifier(conn_type, value)
+
+ # Clear cached properties after modifying attributes
+ self._cache.clear()
+
+ # Update state to reflect the modified attributes
+ self.update_state(signal_event=False)
+
+ async def stop(self) -> None:
+ """Stop playback - required abstract method."""
+
+
+@pytest.fixture
+def mock_mass() -> MagicMock:
+ """Create a mock MusicAssistant instance."""
+ mass = MagicMock()
+ mass.closing = False
+ mass.config = MagicMock()
+ mass.config.get = MagicMock(return_value=[])
+ mass.config.get_raw_player_config_value = MagicMock(return_value="auto")
+ # Return "GLOBAL" for log level config (standard default)
+ mass.config.get_raw_core_config_value = MagicMock(return_value="GLOBAL")
+ mass.config.set = MagicMock()
+ mass.signal_event = MagicMock()
+ mass.get_providers = MagicMock(return_value=[])
+ return mass
+
+
+class TestIdentifiersMatch:
+ """Tests for identifier matching logic."""
+
+ def test_mac_address_match(self, mock_mass: MagicMock) -> None:
+ """Test that MAC addresses match correctly."""
+ controller = PlayerController(mock_mass)
+
+ provider = MockProvider("test")
+ player_a = MockPlayer(
+ provider,
+ "player_a",
+ "Player A",
+ identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
+ )
+ player_b = MockPlayer(
+ provider,
+ "player_b",
+ "Player B",
+ identifiers={IdentifierType.MAC_ADDRESS: "aa:bb:cc:dd:ee:ff"}, # lowercase
+ )
+
+ assert controller._identifiers_match(player_a, player_b) is True
+
+ def test_mac_address_no_match(self, mock_mass: MagicMock) -> None:
+ """Test that different MAC addresses don't match."""
+ controller = PlayerController(mock_mass)
+
+ provider = MockProvider("test")
+ player_a = MockPlayer(
+ provider,
+ "player_a",
+ "Player A",
+ identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
+ )
+ player_b = MockPlayer(
+ provider,
+ "player_b",
+ "Player B",
+ identifiers={IdentifierType.MAC_ADDRESS: "11:22:33:44:55:66"},
+ )
+
+ assert controller._identifiers_match(player_a, player_b) is False
+
+ def test_ip_address_no_match(self, mock_mass: MagicMock) -> None:
+ """Test that IP addresses don't match (IP is excluded as it's not stable)."""
+ controller = PlayerController(mock_mass)
+
+ provider = MockProvider("test")
+ player_a = MockPlayer(
+ provider,
+ "player_a",
+ "Player A",
+ identifiers={IdentifierType.IP_ADDRESS: "192.168.1.100"},
+ )
+ player_b = MockPlayer(
+ provider,
+ "player_b",
+ "Player B",
+ identifiers={IdentifierType.IP_ADDRESS: "192.168.1.100"},
+ )
+
+ # IP address matching is intentionally disabled to prevent false matches
+ assert controller._identifiers_match(player_a, player_b) is False
+
+ def test_sonos_uuid_dlna_suffix_match(self, mock_mass: MagicMock) -> None:
+ """Test Sonos UUID matching with DLNA _MR suffix."""
+ controller = PlayerController(mock_mass)
+
+ provider = MockProvider("test")
+ # Sonos native player
+ player_a = MockPlayer(
+ provider,
+ "player_a",
+ "Sonos Player",
+ identifiers={IdentifierType.UUID: "RINCON_000E58123456"},
+ )
+ # DLNA player with _MR suffix
+ player_b = MockPlayer(
+ provider,
+ "player_b",
+ "DLNA Player",
+ identifiers={IdentifierType.UUID: "RINCON_000E58123456_MR"},
+ )
+
+ assert controller._identifiers_match(player_a, player_b) is True
+
+ def test_no_identifiers_no_match(self, mock_mass: MagicMock) -> None:
+ """Test that players without identifiers don't match."""
+ controller = PlayerController(mock_mass)
+
+ provider = MockProvider("test")
+ player_a = MockPlayer(provider, "player_a", "Player A")
+ player_b = MockPlayer(provider, "player_b", "Player B")
+
+ assert controller._identifiers_match(player_a, player_b) is False
+
+
+class TestProtocolPlayerDetection:
+ """Tests for protocol player type detection."""
+
+ def test_is_protocol_player_true(self, mock_mass: MagicMock) -> None:
+ """Test that PlayerType.PROTOCOL is correctly detected."""
+ controller = PlayerController(mock_mass)
+
+ provider = MockProvider("airplay")
+ player = MockPlayer(
+ provider,
+ "ap_123456",
+ "Samsung TV (AirPlay)",
+ player_type=PlayerType.PROTOCOL,
+ )
+
+ assert controller._is_protocol_player(player) is True
+
+ def test_is_protocol_player_false(self, mock_mass: MagicMock) -> None:
+ """Test that PlayerType.PLAYER is not detected as protocol."""
+ controller = PlayerController(mock_mass)
+
+ provider = MockProvider("airplay")
+ player = MockPlayer(
+ provider,
+ "ap_123456",
+ "HomePod",
+ player_type=PlayerType.PLAYER, # Apple device with native support
+ )
+
+ assert controller._is_protocol_player(player) is False
+
+
+class TestFindMatchingProtocolPlayers:
+ """Tests for finding matching protocol players."""
+
+ def test_find_matching_by_mac(self, mock_mass: MagicMock) -> None:
+ """Test finding matching protocol players by MAC address."""
+ controller = PlayerController(mock_mass)
+
+ # Set up providers
+ airplay_provider = MockProvider("airplay")
+ chromecast_provider = MockProvider("chromecast")
+
+ # Create matching protocol players (same device, different protocols)
+ airplay_player = MockPlayer(
+ airplay_provider,
+ "ap_aabbccddee",
+ "Samsung TV (AirPlay)",
+ player_type=PlayerType.PROTOCOL,
+ identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
+ )
+ chromecast_player = MockPlayer(
+ chromecast_provider,
+ "cc_aabbccddee",
+ "Samsung TV (Chromecast)",
+ player_type=PlayerType.PROTOCOL,
+ identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
+ )
+
+ # Register players
+ controller._players = {
+ "ap_aabbccddee": airplay_player,
+ "cc_aabbccddee": chromecast_player,
+ }
+ controller._player_throttlers = {
+ "ap_aabbccddee": Throttler(1, 0.05),
+ "cc_aabbccddee": Throttler(1, 0.05),
+ }
+
+ # Find matching players for AirPlay player
+ matches = controller._find_matching_protocol_players(airplay_player)
+
+ assert len(matches) == 2
+ assert airplay_player in matches
+ assert chromecast_player in matches
+
+
+class TestGetDeviceKeyFromPlayers:
+ """Tests for device key generation."""
+
+ def test_device_key_from_mac(self, mock_mass: MagicMock) -> None:
+ """Test device key generation from MAC address."""
+ universal_provider = create_mock_universal_provider(mock_mass)
+
+ provider = MockProvider("airplay")
+ player = MockPlayer(
+ provider,
+ "ap_123456",
+ "Test Player",
+ identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
+ )
+
+ device_key = universal_provider._get_device_key_from_players([player])
+
+ assert device_key == "aabbccddeeff"
+
+ def test_device_key_from_uuid_fallback(self, mock_mass: MagicMock) -> None:
+ """Test device key generation falls back to UUID when no MAC available."""
+ universal_provider = create_mock_universal_provider(mock_mass)
+
+ provider = MockProvider("dlna")
+ player = MockPlayer(
+ provider,
+ "dlna_123456",
+ "Test Player",
+ identifiers={IdentifierType.UUID: "uuid:12345678-1234-1234-1234-123456789abc"},
+ )
+
+ device_key = universal_provider._get_device_key_from_players([player])
+
+ assert device_key == "uuid12345678123412341234123456789abc"
+
+ def test_device_key_from_ip_falls_back_to_player_id(self, mock_mass: MagicMock) -> None:
+ """Test that device key falls back to player_id for IP-only players (IP not used)."""
+ universal_provider = create_mock_universal_provider(mock_mass)
+
+ provider = MockProvider("airplay")
+ player = MockPlayer(
+ provider,
+ "ap_123456",
+ "Test Player",
+ identifiers={IdentifierType.IP_ADDRESS: "192.168.1.100"},
+ )
+
+ device_key = universal_provider._get_device_key_from_players([player])
+
+ # IP address is not used for device key - falls back to player_id
+ # This allows protocol players without MAC/UUID to still get a UniversalPlayer
+ assert device_key == "ap_123456"
+
+ def test_device_key_from_no_identifiers_falls_back_to_player_id(
+ self, mock_mass: MagicMock
+ ) -> None:
+ """Test that device key falls back to player_id when no identifiers at all."""
+ universal_provider = create_mock_universal_provider(mock_mass)
+
+ provider = MockProvider("sendspin")
+ player = MockPlayer(
+ provider,
+ "sendspin-device-abc",
+ "Test Player",
+ # No identifiers at all (like Sendspin protocol players)
+ )
+
+ device_key = universal_provider._get_device_key_from_players([player])
+
+ # Falls back to player_id when no MAC/UUID identifiers
+ assert device_key == "sendspindeviceabc"
+
+
+class TestGetCleanPlayerName:
+ """Tests for player name selection."""
+
+ def test_prefers_chromecast_name(self, mock_mass: MagicMock) -> None:
+ """Test that Chromecast names are preferred over other protocols."""
+ universal_provider = create_mock_universal_provider(mock_mass)
+
+ airplay_provider = MockProvider("airplay")
+ chromecast_provider = MockProvider("chromecast")
+
+ airplay_player = MockPlayer(
+ airplay_provider,
+ "ap_123456",
+ "Samsung TV",
+ player_type=PlayerType.PROTOCOL,
+ )
+ chromecast_player = MockPlayer(
+ chromecast_provider,
+ "cc_123456",
+ "Living Room Speaker",
+ player_type=PlayerType.PROTOCOL,
+ )
+
+ # Chromecast should be preferred (priority 1)
+ clean_name = universal_provider._get_clean_player_name([airplay_player, chromecast_player])
+ assert clean_name == "Living Room Speaker"
+
+ def test_filters_mac_address_names(self, mock_mass: MagicMock) -> None:
+ """Test that MAC address-like names are filtered out."""
+ universal_provider = create_mock_universal_provider(mock_mass)
+
+ squeezelite_provider = MockProvider("squeezelite")
+ airplay_provider = MockProvider("airplay")
+
+ # Squeezelite with MAC address as name
+ sq_player = MockPlayer(
+ squeezelite_provider,
+ "sq_123456",
+ "AA:BB:CC:DD:EE:FF",
+ player_type=PlayerType.PROTOCOL,
+ )
+ # AirPlay with proper name
+ ap_player = MockPlayer(
+ airplay_provider,
+ "ap_123456",
+ "Kitchen Speaker",
+ player_type=PlayerType.PROTOCOL,
+ )
+
+ # Should prefer Kitchen Speaker over MAC address
+ clean_name = universal_provider._get_clean_player_name([sq_player, ap_player])
+ assert clean_name == "Kitchen Speaker"
+
+ def test_filters_player_id_names(self, mock_mass: MagicMock) -> None:
+ """Test that player ID-like names are filtered out."""
+ universal_provider = create_mock_universal_provider(mock_mass)
+
+ sendspin_provider = MockProvider("sendspin")
+ dlna_provider = MockProvider("dlna")
+
+ # SendSpin with player ID as name
+ ss_player = MockPlayer(
+ sendspin_provider,
+ "sendspin_123456",
+ "sendspin_device_abc",
+ player_type=PlayerType.PROTOCOL,
+ )
+ # DLNA with proper name
+ dlna_player = MockPlayer(
+ dlna_provider,
+ "dlna_123456",
+ "Bedroom TV",
+ player_type=PlayerType.PROTOCOL,
+ )
+
+ # Should prefer Bedroom TV over player ID
+ clean_name = universal_provider._get_clean_player_name([ss_player, dlna_player])
+ assert clean_name == "Bedroom TV"
+
+ def test_valid_name_unchanged(self, mock_mass: MagicMock) -> None:
+ """Test that valid names are returned unchanged."""
+ universal_provider = create_mock_universal_provider(mock_mass)
+
+ provider = MockProvider("airplay")
+ player = MockPlayer(
+ provider,
+ "ap_123456",
+ "HomePod Mini",
+ player_type=PlayerType.PLAYER,
+ )
+
+ clean_name = universal_provider._get_clean_player_name([player])
+ assert clean_name == "HomePod Mini"
+
+
+class TestCachedProtocolParentRestore:
+ """Tests for restoring cached protocol parent links."""
+
+ def test_protocol_parent_id_restored_from_config(self, mock_mass: MagicMock) -> None:
+ """Test that cached protocol_parent_id is loaded and used for immediate linking."""
+ controller = PlayerController(mock_mass)
+
+ # Mock config to return cached parent_id when queried
+ def mock_config_get(key: str, default: str | None = None) -> str | None:
+ if "protocol_parent_id" in str(key):
+ return "native_player_id"
+ return default
+
+ mock_mass.config.get.side_effect = mock_config_get
+
+ # Create native player
+ native_provider = MockProvider("sonos", mass=mock_mass)
+ native_player = MockPlayer(
+ native_provider,
+ "native_player_id",
+ "Sonos Speaker",
+ identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
+ )
+
+ # Create protocol player
+ dlna_provider = MockProvider("dlna", mass=mock_mass)
+ protocol_player = MockPlayer(
+ dlna_provider,
+ "uuid:RINCON_AABBCCDDEEFF_MR",
+ "Sonos DLNA",
+ player_type=PlayerType.PROTOCOL,
+ )
+
+ # Register native player
+ controller._players = {"native_player_id": native_player}
+ controller._player_throttlers = {"native_player_id": Throttler(1, 0.05)}
+
+ # Try to link protocol to native - should load cached parent_id
+ controller._try_link_protocol_to_native(protocol_player)
+
+ # Verify protocol_parent_id was set
+ assert protocol_player.protocol_parent_id == "native_player_id"
+
+ # Verify protocol was linked to native player
+ assert any(
+ link.output_protocol_id == protocol_player.player_id
+ for link in native_player.linked_output_protocols
+ )
+
+ def test_protocol_parent_id_prevents_universal_player_creation(
+ self, mock_mass: MagicMock
+ ) -> None:
+ """Test that cached protocol_parent_id prevents creating universal player."""
+ controller = PlayerController(mock_mass)
+
+ # Mock config to return cached parent_id (parent not yet registered)
+ def mock_config_get(key: str, default: str | None = None) -> str | None:
+ if "protocol_parent_id" in str(key):
+ return "native_player_id"
+ return default
+
+ mock_mass.config.get.side_effect = mock_config_get
+
+ # Create protocol player
+ dlna_provider = MockProvider("dlna", mass=mock_mass)
+ protocol_player = MockPlayer(
+ dlna_provider,
+ "uuid:RINCON_AABBCCDDEEFF_MR",
+ "Sonos DLNA",
+ player_type=PlayerType.PROTOCOL,
+ )
+
+ # No native player registered yet
+ controller._players = {}
+
+ # Try to link protocol - should set parent_id and skip evaluation
+ controller._try_link_protocol_to_native(protocol_player)
+
+ # Verify protocol_parent_id was set
+ assert protocol_player.protocol_parent_id == "native_player_id"
+
+ # Since parent_id is set, delayed evaluation won't create a universal player
+
+
+class TestSelectBestOutputProtocol:
+ """Tests for output protocol selection logic."""
+
+ def test_select_native_when_preferred_is_native(self, mock_mass: MagicMock) -> None:
+ """Test that native protocol is selected when user prefers native."""
+ # Mock config to return "native" as preferred
+ mock_mass.config.get_raw_player_config_value = MagicMock(return_value="native")
+
+ controller = PlayerController(mock_mass)
+ provider = MockProvider("sonos", mass=mock_mass)
+
+ # Create native player with PLAY_MEDIA support
+ native_player = MockPlayer(
+ provider,
+ "sonos_123",
+ "Kantoor",
+ identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
+ )
+ native_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
+
+ # Wire up mock_mass.players to controller
+ mock_mass.players = controller
+
+ # Register players
+ controller._players = {"sonos_123": native_player}
+ controller._player_throttlers = {"sonos_123": Throttler(1, 0.05)}
+
+ # Select protocol
+ selected_player, output_protocol = controller._select_best_output_protocol(native_player)
+
+ # Should select native player
+ assert selected_player == native_player
+ assert output_protocol is None # None means native playback
+
+ def test_select_dlna_when_preferred_is_dlna(self, mock_mass: MagicMock) -> None:
+ """Test that DLNA protocol is selected when user prefers DLNA."""
+ # Mock config to return the full player ID as preferred
+ mock_mass.config.get_raw_player_config_value = MagicMock(return_value="dlna_AABBCCDDEEFF")
+
+ controller = PlayerController(mock_mass)
+
+ # Create native player with linked protocols
+ sonos_provider = MockProvider("sonos", mass=mock_mass)
+ native_player = MockPlayer(
+ sonos_provider,
+ "sonos_123",
+ "Kantoor",
+ identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
+ )
+ native_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
+
+ # Create DLNA protocol player
+ dlna_provider = MockProvider("dlna", mass=mock_mass)
+ dlna_player = MockPlayer(
+ dlna_provider,
+ "dlna_AABBCCDDEEFF",
+ "Kantoor DLNA",
+ player_type=PlayerType.PROTOCOL,
+ identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
+ )
+
+ # Register players
+ controller._players = {
+ "sonos_123": native_player,
+ "dlna_AABBCCDDEEFF": dlna_player,
+ }
+ controller._player_throttlers = {
+ "sonos_123": Throttler(1, 0.05),
+ "dlna_AABBCCDDEEFF": Throttler(1, 0.05),
+ }
+
+ # Link DLNA protocol to native player
+ native_player.set_linked_output_protocols(
+ [
+ OutputProtocol(
+ output_protocol_id="dlna_AABBCCDDEEFF",
+ name="DLNA",
+ protocol_domain="dlna",
+ priority=30,
+ )
+ ]
+ )
+
+ # Select protocol
+ selected_player, output_protocol = controller._select_best_output_protocol(native_player)
+
+ # Should select DLNA player, not native
+ assert selected_player == dlna_player
+ assert output_protocol is not None
+ assert output_protocol.output_protocol_id == "dlna_AABBCCDDEEFF"
+
+ def test_select_airplay_when_preferred_is_airplay(self, mock_mass: MagicMock) -> None:
+ """Test that AirPlay protocol is selected when user prefers AirPlay."""
+ # Mock config to return the full player ID as preferred
+ mock_mass.config.get_raw_player_config_value = MagicMock(
+ return_value="airplay_AABBCCDDEEFF"
+ )
+
+ controller = PlayerController(mock_mass)
+
+ # Create native player
+ sonos_provider = MockProvider("sonos", mass=mock_mass)
+ native_player = MockPlayer(
+ sonos_provider,
+ "sonos_123",
+ "Kantoor",
+ identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
+ )
+ native_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
+
+ # Create AirPlay and DLNA protocol players
+ airplay_provider = MockProvider("airplay", mass=mock_mass)
+ airplay_player = MockPlayer(
+ airplay_provider,
+ "airplay_AABBCCDDEEFF",
+ "Kantoor AirPlay",
+ player_type=PlayerType.PROTOCOL,
+ identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
+ )
+
+ dlna_provider = MockProvider("dlna", mass=mock_mass)
+ dlna_player = MockPlayer(
+ dlna_provider,
+ "dlna_AABBCCDDEEFF",
+ "Kantoor DLNA",
+ player_type=PlayerType.PROTOCOL,
+ identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
+ )
+
+ # Register players
+ controller._players = {
+ "sonos_123": native_player,
+ "airplay_AABBCCDDEEFF": airplay_player,
+ "dlna_AABBCCDDEEFF": dlna_player,
+ }
+ controller._player_throttlers = {
+ "sonos_123": Throttler(1, 0.05),
+ "airplay_AABBCCDDEEFF": Throttler(1, 0.05),
+ "dlna_AABBCCDDEEFF": Throttler(1, 0.05),
+ }
+
+ # Link protocols to native player
+ native_player.set_linked_output_protocols(
+ [
+ OutputProtocol(
+ output_protocol_id="airplay_AABBCCDDEEFF",
+ name="AirPlay",
+ protocol_domain="airplay",
+ priority=10,
+ ),
+ OutputProtocol(
+ output_protocol_id="dlna_AABBCCDDEEFF",
+ name="DLNA",
+ protocol_domain="dlna",
+ priority=30,
+ ),
+ ]
+ )
+
+ # Select protocol
+ selected_player, output_protocol = controller._select_best_output_protocol(native_player)
+
+ # Should select AirPlay player (even though DLNA has lower priority value),
+ # because user preference overrides priority
+ assert selected_player == airplay_player
+ assert output_protocol is not None
+ assert output_protocol.output_protocol_id == "airplay_AABBCCDDEEFF"
+
+ def test_fallback_to_native_when_auto(self, mock_mass: MagicMock) -> None:
+ """Test that native playback is used when preference is auto."""
+ # Mock config to return "auto" as preferred
+ mock_mass.config.get_raw_player_config_value = MagicMock(return_value="auto")
+
+ controller = PlayerController(mock_mass)
+ provider = MockProvider("sonos", mass=mock_mass)
+
+ native_player = MockPlayer(
+ provider,
+ "sonos_123",
+ "Kantoor",
+ identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"},
+ )
+ native_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
+
+ controller._players = {"sonos_123": native_player}
+ controller._player_throttlers = {"sonos_123": Throttler(1, 0.05)}
+
+ # Select protocol with auto preference
+ selected_player, output_protocol = controller._select_best_output_protocol(native_player)
+
+ # Should select native player
+ assert selected_player == native_player
+ assert output_protocol is None # None means native playback
+
+
+class TestPlayerGrouping:
+ """Tests for player grouping scenarios."""
+
+ def test_native_to_native_grouping(self, mock_mass: MagicMock) -> None:
+ """Test that native players from same provider can group together."""
+ controller = PlayerController(mock_mass)
+
+ sonos_provider = MockProvider("sonos", mass=mock_mass)
+
+ # Create two Sonos players
+ player_a = MockPlayer(
+ sonos_provider,
+ "sonos_123",
+ "Living Room",
+ identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
+ )
+ player_a._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
+ player_a._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
+ player_a._attr_can_group_with = {"sonos_456"}
+ player_a._cache.clear() # Clear cached properties after modifying attributes
+
+ player_b = MockPlayer(
+ sonos_provider,
+ "sonos_456",
+ "Kitchen",
+ identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:02"},
+ )
+ player_b._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
+ player_b._cache.clear()
+
+ controller._players = {
+ "sonos_123": player_a,
+ "sonos_456": player_b,
+ }
+ controller._player_throttlers = {
+ "sonos_123": Throttler(1, 0.05),
+ "sonos_456": Throttler(1, 0.05),
+ }
+
+ # Translate members for native grouping
+ protocol_members, native_members, _, _ = controller._translate_members_for_protocols(
+ parent_player=player_a,
+ player_ids=["sonos_456"],
+ parent_protocol_player=None,
+ parent_protocol_domain=None,
+ )
+
+ # Should use native grouping (same provider)
+ assert len(native_members) == 1
+ assert "sonos_456" in native_members
+ assert len(protocol_members) == 0
+
+ def test_protocol_to_protocol_grouping(self, mock_mass: MagicMock) -> None:
+ """Test that protocol players can group via shared protocol."""
+ controller = PlayerController(mock_mass)
+
+ # Create two players with AirPlay protocol support
+ sonos_provider = MockProvider("sonos", mass=mock_mass)
+ wiim_provider = MockProvider("wiim", mass=mock_mass)
+ airplay_provider = MockProvider("airplay", mass=mock_mass)
+
+ # Sonos player
+ sonos_player = MockPlayer(
+ sonos_provider,
+ "sonos_123",
+ "Living Room",
+ identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
+ )
+ sonos_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
+ sonos_player._cache.clear()
+
+ # WiiM player
+ wiim_player = MockPlayer(
+ wiim_provider,
+ "wiim_456",
+ "Bedroom",
+ identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:02"},
+ )
+ wiim_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
+ wiim_player._cache.clear()
+
+ # AirPlay protocol players
+ sonos_airplay = MockPlayer(
+ airplay_provider,
+ "airplay_sonos",
+ "Living Room (AirPlay)",
+ player_type=PlayerType.PROTOCOL,
+ identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
+ )
+ sonos_airplay._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
+ sonos_airplay._attr_can_group_with = {"airplay_wiim"}
+ sonos_airplay._cache.clear()
+ sonos_airplay.update_state(signal_event=False)
+
+ wiim_airplay = MockPlayer(
+ airplay_provider,
+ "airplay_wiim",
+ "Bedroom (AirPlay)",
+ player_type=PlayerType.PROTOCOL,
+ identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:02"},
+ )
+
+ # Link protocol players to native players
+ sonos_player.set_linked_output_protocols(
+ [
+ OutputProtocol(
+ output_protocol_id="airplay_sonos",
+ name="AirPlay",
+ protocol_domain="airplay",
+ priority=10,
+ available=True,
+ )
+ ]
+ )
+ wiim_player.set_linked_output_protocols(
+ [
+ OutputProtocol(
+ output_protocol_id="airplay_wiim",
+ name="AirPlay",
+ protocol_domain="airplay",
+ priority=10,
+ available=True,
+ )
+ ]
+ )
+
+ controller._players = {
+ "sonos_123": sonos_player,
+ "wiim_456": wiim_player,
+ "airplay_sonos": sonos_airplay,
+ "airplay_wiim": wiim_airplay,
+ }
+ controller._player_throttlers = {
+ "sonos_123": Throttler(1, 0.05),
+ "wiim_456": Throttler(1, 0.05),
+ "airplay_sonos": Throttler(1, 0.05),
+ "airplay_wiim": Throttler(1, 0.05),
+ }
+
+ # Translate members for protocol grouping (via AirPlay)
+ protocol_members, native_members, protocol_player, _ = (
+ controller._translate_members_for_protocols(
+ parent_player=sonos_player,
+ player_ids=["wiim_456"],
+ parent_protocol_player=sonos_airplay,
+ parent_protocol_domain="airplay",
+ )
+ )
+
+ # Should use protocol grouping (AirPlay)
+ assert len(protocol_members) == 1
+ assert "airplay_wiim" in protocol_members
+ assert len(native_members) == 0
+ assert protocol_player == sonos_airplay
+
+ def test_hybrid_grouping(self, mock_mass: MagicMock) -> None:
+ """Test hybrid grouping: native + protocol players in same group."""
+ controller = PlayerController(mock_mass)
+
+ # Create Sonos players (native grouping capability)
+ sonos_provider = MockProvider("sonos", instance_id="sonos_instance", mass=mock_mass)
+ sonos_a = MockPlayer(
+ sonos_provider,
+ "sonos_123",
+ "Living Room",
+ identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
+ )
+ sonos_a._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
+ sonos_a._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
+ sonos_a._attr_can_group_with = {"sonos_456"}
+ sonos_a._cache.clear()
+
+ sonos_b = MockPlayer(
+ sonos_provider,
+ "sonos_456",
+ "Kitchen",
+ identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:02"},
+ )
+ sonos_b._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
+ sonos_b._cache.clear()
+
+ # Create WiiM player with AirPlay protocol
+ wiim_provider = MockProvider("wiim", instance_id="wiim_instance", mass=mock_mass)
+ airplay_provider = MockProvider("airplay", instance_id="airplay_instance", mass=mock_mass)
+
+ wiim_player = MockPlayer(
+ wiim_provider,
+ "wiim_789",
+ "Bedroom",
+ identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:03"},
+ )
+ wiim_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
+ wiim_player._cache.clear()
+
+ # AirPlay protocol players
+ sonos_airplay = MockPlayer(
+ airplay_provider,
+ "airplay_sonos",
+ "Living Room (AirPlay)",
+ player_type=PlayerType.PROTOCOL,
+ identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
+ )
+ sonos_airplay._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
+ sonos_airplay._attr_can_group_with = {"airplay_wiim"}
+ sonos_airplay._cache.clear()
+ sonos_airplay.update_state(signal_event=False)
+
+ wiim_airplay = MockPlayer(
+ airplay_provider,
+ "airplay_wiim",
+ "Bedroom (AirPlay)",
+ player_type=PlayerType.PROTOCOL,
+ identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:03"},
+ )
+
+ # Link AirPlay to Sonos A
+ sonos_a.set_linked_output_protocols(
+ [
+ OutputProtocol(
+ output_protocol_id="airplay_sonos",
+ name="AirPlay",
+ protocol_domain="airplay",
+ priority=10,
+ available=True,
+ )
+ ]
+ )
+ wiim_player.set_linked_output_protocols(
+ [
+ OutputProtocol(
+ output_protocol_id="airplay_wiim",
+ name="AirPlay",
+ protocol_domain="airplay",
+ priority=10,
+ available=True,
+ )
+ ]
+ )
+ wiim_player.set_active_output_protocol("airplay_wiim")
+ wiim_player.set_protocol_parent_id("airplay_wiim")
+
+ # Wire up mock_mass.players to controller so get_linked_protocol works
+ mock_mass.players = controller
+
+ controller._players = {
+ "sonos_123": sonos_a,
+ "sonos_456": sonos_b,
+ "wiim_789": wiim_player,
+ "airplay_sonos": sonos_airplay,
+ "airplay_wiim": wiim_airplay,
+ }
+ controller._player_throttlers = {
+ "sonos_123": Throttler(1, 0.05),
+ "sonos_456": Throttler(1, 0.05),
+ "wiim_789": Throttler(1, 0.05),
+ "airplay_sonos": Throttler(1, 0.05),
+ "airplay_wiim": Throttler(1, 0.05),
+ }
+
+ # Group Sonos B (native) + WiiM (via AirPlay) to Sonos A
+ protocol_members, native_members, _protocol_player, _ = (
+ controller._translate_members_for_protocols(
+ parent_player=sonos_a,
+ player_ids=["sonos_456", "wiim_789"],
+ parent_protocol_player=sonos_airplay,
+ parent_protocol_domain="airplay",
+ )
+ )
+
+ # Should have hybrid group: native Sonos B + protocol WiiM
+ assert len(native_members) == 1
+ assert "sonos_456" in native_members
+ assert len(protocol_members) == 1
+ assert "airplay_wiim" in protocol_members
+
+ def test_protocol_selection_requires_set_members(self, mock_mass: MagicMock) -> None:
+ """Test that only protocols with SET_MEMBERS support are selected for grouping."""
+ controller = PlayerController(mock_mass)
+
+ sonos_provider = MockProvider("sonos", mass=mock_mass)
+ wiim_provider = MockProvider("wiim", mass=mock_mass)
+ dlna_provider = MockProvider("dlna", mass=mock_mass)
+ airplay_provider = MockProvider("airplay", mass=mock_mass)
+
+ # Sonos player
+ sonos_player = MockPlayer(
+ sonos_provider,
+ "sonos_123",
+ "Living Room",
+ identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
+ )
+ sonos_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
+ sonos_player._cache.clear()
+
+ # WiiM player
+ wiim_player = MockPlayer(
+ wiim_provider,
+ "wiim_456",
+ "Bedroom",
+ identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:02"},
+ )
+ wiim_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
+ wiim_player._cache.clear()
+
+ # DLNA protocol (does NOT support SET_MEMBERS)
+ sonos_dlna = MockPlayer(
+ dlna_provider,
+ "dlna_sonos",
+ "Living Room (DLNA)",
+ player_type=PlayerType.PROTOCOL,
+ identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
+ )
+ # Note: NO SET_MEMBERS feature
+
+ wiim_dlna = MockPlayer(
+ dlna_provider,
+ "dlna_wiim",
+ "Bedroom (DLNA)",
+ player_type=PlayerType.PROTOCOL,
+ identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:02"},
+ )
+
+ # AirPlay protocol (DOES support SET_MEMBERS)
+ sonos_airplay = MockPlayer(
+ airplay_provider,
+ "airplay_sonos",
+ "Living Room (AirPlay)",
+ player_type=PlayerType.PROTOCOL,
+ identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
+ )
+ sonos_airplay._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
+ sonos_airplay._attr_can_group_with = {"airplay_wiim"}
+ sonos_airplay._cache.clear()
+
+ wiim_airplay = MockPlayer(
+ airplay_provider,
+ "airplay_wiim",
+ "Bedroom (AirPlay)",
+ player_type=PlayerType.PROTOCOL,
+ identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:02"},
+ )
+ wiim_airplay._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
+ wiim_airplay._attr_can_group_with = {"airplay_sonos"}
+ wiim_airplay._cache.clear()
+
+ # Link protocols (DLNA has lower priority than AirPlay)
+ sonos_player.set_linked_output_protocols(
+ [
+ OutputProtocol(
+ output_protocol_id="dlna_sonos",
+ name="DLNA",
+ protocol_domain="dlna",
+ priority=30, # Lower priority (higher number)
+ available=True,
+ ),
+ OutputProtocol(
+ output_protocol_id="airplay_sonos",
+ name="AirPlay",
+ protocol_domain="airplay",
+ priority=10, # Higher priority (lower number)
+ available=True,
+ ),
+ ]
+ )
+ wiim_player.set_linked_output_protocols(
+ [
+ OutputProtocol(
+ output_protocol_id="dlna_wiim",
+ name="DLNA",
+ protocol_domain="dlna",
+ priority=30,
+ available=True,
+ ),
+ OutputProtocol(
+ output_protocol_id="airplay_wiim",
+ name="AirPlay",
+ protocol_domain="airplay",
+ priority=10,
+ available=True,
+ ),
+ ]
+ )
+
+ controller._players = {
+ "sonos_123": sonos_player,
+ "wiim_456": wiim_player,
+ "dlna_sonos": sonos_dlna,
+ "dlna_wiim": wiim_dlna,
+ "airplay_sonos": sonos_airplay,
+ "airplay_wiim": wiim_airplay,
+ }
+ controller._player_throttlers = {
+ "sonos_123": Throttler(1, 0.05),
+ "wiim_456": Throttler(1, 0.05),
+ "dlna_sonos": Throttler(1, 0.05),
+ "dlna_wiim": Throttler(1, 0.05),
+ "airplay_sonos": Throttler(1, 0.05),
+ "airplay_wiim": Throttler(1, 0.05),
+ }
+
+ # Update state after modifying attributes
+ sonos_airplay.update_state(signal_event=False)
+ wiim_airplay.update_state(signal_event=False)
+
+ # Translate members - should skip DLNA (no SET_MEMBERS) and select AirPlay
+ protocol_members, _native_members, protocol_player, protocol_domain = (
+ controller._translate_members_for_protocols(
+ parent_player=sonos_player,
+ player_ids=["wiim_456"],
+ parent_protocol_player=None,
+ parent_protocol_domain=None,
+ )
+ )
+
+ # Should select AirPlay (supports SET_MEMBERS) not DLNA
+ assert len(protocol_members) == 1
+ assert "airplay_wiim" in protocol_members
+ assert protocol_domain == "airplay"
+ assert protocol_player == sonos_airplay
+
+
+class TestCanGroupWith:
+ """Tests for can_group_with property with three scenarios."""
+
+ def test_scenario_1_native_active_only_native_players(self, mock_mass: MagicMock) -> None:
+ """Test Scenario 1: Native playback active -> all protocols shown (new behavior)."""
+ controller = PlayerController(mock_mass)
+
+ sonos_provider = MockProvider("sonos", instance_id="sonos_instance", mass=mock_mass)
+ airplay_provider = MockProvider("airplay", instance_id="airplay_instance", mass=mock_mass)
+
+ # Create Sonos player with native and AirPlay support
+ sonos_player = MockPlayer(
+ sonos_provider,
+ "sonos_123",
+ "Living Room",
+ identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
+ )
+ sonos_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
+ sonos_player._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
+ sonos_player._attr_can_group_with = {"sonos_456"}
+ sonos_player._cache.clear()
+ sonos_player.set_active_output_protocol("native")
+
+ # Create another Sonos player
+ sonos_player_b = MockPlayer(
+ sonos_provider,
+ "sonos_456",
+ "Kitchen",
+ identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:02"},
+ )
+
+ # Create AirPlay protocol player
+ sonos_airplay = MockPlayer(
+ airplay_provider,
+ "airplay_sonos",
+ "Living Room (AirPlay)",
+ player_type=PlayerType.PROTOCOL,
+ identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
+ )
+ sonos_airplay._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
+ sonos_airplay._attr_can_group_with = {"airplay_other"}
+ sonos_airplay._cache.clear()
+ sonos_airplay.set_protocol_parent_id("sonos_123")
+
+ sonos_player.set_linked_output_protocols(
+ [
+ OutputProtocol(
+ output_protocol_id="airplay_sonos",
+ name="AirPlay",
+ protocol_domain="airplay",
+ priority=10,
+ available=True,
+ )
+ ]
+ )
+
+ # Wire up mock_mass.players to controller so get_linked_protocol works
+ mock_mass.players = controller
+
+ controller._players = {
+ "sonos_123": sonos_player,
+ "sonos_456": sonos_player_b,
+ "airplay_sonos": sonos_airplay,
+ }
+ controller._player_throttlers = {
+ "sonos_123": Throttler(1, 0.05),
+ "sonos_456": Throttler(1, 0.05),
+ "airplay_sonos": Throttler(1, 0.05),
+ }
+
+ # Update state after modifying attributes and registering with controller
+ sonos_player.update_state(signal_event=False)
+ sonos_player_b.update_state(signal_event=False)
+ sonos_airplay.update_state(signal_event=False)
+
+ # Get can_group_with while native is active
+ groupable = sonos_player.state.can_group_with
+
+ # NEW BEHAVIOR: Should show both native AND protocol players
+ # even when native protocol is active
+ assert "sonos_456" in groupable # Native Sonos player
+ # Note: airplay_other is not registered in controller._players, so it won't appear
+ # But the logic should still allow showing AirPlay options if they were registered
+
+ def test_scenario_2_protocol_active_hybrid_groups(self, mock_mass: MagicMock) -> None:
+ """Test Scenario 2: Protocol active -> show all protocols (new behavior)."""
+ controller = PlayerController(mock_mass)
+
+ sonos_provider = MockProvider("sonos", instance_id="sonos_instance", mass=mock_mass)
+ airplay_provider = MockProvider("airplay", instance_id="airplay_instance", mass=mock_mass)
+
+ # Create Sonos player with AirPlay active
+ sonos_player = MockPlayer(
+ sonos_provider,
+ "sonos_123",
+ "Living Room",
+ identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
+ )
+ sonos_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
+ sonos_player._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
+ sonos_player._attr_can_group_with = {"sonos_456"}
+ sonos_player._cache.clear()
+
+ # Create another Sonos player
+ sonos_player_b = MockPlayer(
+ sonos_provider,
+ "sonos_456",
+ "Kitchen",
+ identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:02"},
+ )
+
+ # Create AirPlay protocol player
+ sonos_airplay = MockPlayer(
+ airplay_provider,
+ "airplay_sonos",
+ "Living Room (AirPlay)",
+ player_type=PlayerType.PROTOCOL,
+ identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
+ )
+ sonos_airplay._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
+ sonos_airplay._attr_can_group_with = {"airplay_other"}
+ sonos_airplay._cache.clear()
+ sonos_airplay.set_protocol_parent_id("sonos_123")
+
+ # Create another device with AirPlay
+ wiim_player = MockPlayer(
+ sonos_provider,
+ "wiim_789",
+ "Bedroom",
+ identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:03"},
+ )
+
+ airplay_other = MockPlayer(
+ airplay_provider,
+ "airplay_other",
+ "Bedroom (AirPlay)",
+ player_type=PlayerType.PROTOCOL,
+ identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:03"},
+ )
+ airplay_other._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
+ airplay_other._attr_can_group_with = {"airplay_sonos"}
+ airplay_other._cache.clear()
+ airplay_other.set_protocol_parent_id("wiim_789")
+
+ wiim_player.set_linked_output_protocols(
+ [
+ OutputProtocol(
+ output_protocol_id="airplay_other",
+ name="AirPlay",
+ protocol_domain="airplay",
+ priority=10,
+ available=True,
+ ),
+ ]
+ )
+
+ sonos_player.set_linked_output_protocols(
+ [
+ OutputProtocol(
+ output_protocol_id="airplay_sonos",
+ name="AirPlay",
+ protocol_domain="airplay",
+ priority=10,
+ available=True,
+ )
+ ]
+ )
+ sonos_player.set_active_output_protocol("airplay_sonos")
+
+ # Wire up mock_mass.players to controller so get_linked_protocol works
+ mock_mass.players = controller
+
+ controller._players = {
+ "sonos_123": sonos_player,
+ "sonos_456": sonos_player_b,
+ "wiim_789": wiim_player,
+ "airplay_sonos": sonos_airplay,
+ "airplay_other": airplay_other,
+ }
+ controller._player_throttlers = {
+ "sonos_123": Throttler(1, 0.05),
+ "sonos_456": Throttler(1, 0.05),
+ "wiim_789": Throttler(1, 0.05),
+ "airplay_sonos": Throttler(1, 0.05),
+ "airplay_other": Throttler(1, 0.05),
+ }
+
+ # Clear cache after setting linked protocols
+ sonos_player._cache.clear()
+ wiim_player._cache.clear()
+
+ # Update state after modifying attributes and registering with controller
+ # IMPORTANT: Update protocol players FIRST, then parent players
+ sonos_airplay.update_state(signal_event=False)
+ airplay_other.update_state(signal_event=False)
+ sonos_player.update_state(signal_event=False)
+ sonos_player_b.update_state(signal_event=False)
+ wiim_player.update_state(signal_event=False)
+
+ # Get can_group_with while AirPlay is active
+ groupable = sonos_player.state.can_group_with
+
+ # NEW BEHAVIOR: Should show ALL protocols + native players
+ # regardless of which protocol is active
+ assert "sonos_456" in groupable # Native Sonos player
+ assert "wiim_789" in groupable # Via airplay_other protocol
+
+ def test_scenario_3_no_active_output_all_protocols_shown(self, mock_mass: MagicMock) -> None:
+ """Test Scenario 3: No active output -> show all compatible protocols + native."""
+ controller = PlayerController(mock_mass)
+
+ sonos_provider = MockProvider("sonos", instance_id="sonos_instance", mass=mock_mass)
+ airplay_provider = MockProvider("airplay", instance_id="airplay_instance", mass=mock_mass)
+ dlna_provider = MockProvider("dlna", instance_id="dlna_instance", mass=mock_mass)
+
+ # Create Sonos player (no active protocol)
+ sonos_player = MockPlayer(
+ sonos_provider,
+ "sonos_123",
+ "Living Room",
+ identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
+ )
+ sonos_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
+ sonos_player._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
+ sonos_player._attr_can_group_with = {"sonos_456"}
+ sonos_player._cache.clear()
+ # No active output protocol set
+
+ # Create another Sonos player
+ sonos_player_b = MockPlayer(
+ sonos_provider,
+ "sonos_456",
+ "Kitchen",
+ identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:02"},
+ )
+
+ # Create AirPlay protocol player (supports SET_MEMBERS)
+ sonos_airplay = MockPlayer(
+ airplay_provider,
+ "airplay_sonos",
+ "Living Room (AirPlay)",
+ player_type=PlayerType.PROTOCOL,
+ identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
+ )
+ sonos_airplay._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
+ sonos_airplay._attr_can_group_with = {"airplay_other"}
+ sonos_airplay._cache.clear()
+ sonos_airplay.set_protocol_parent_id("sonos_123")
+
+ # Create DLNA protocol player (does NOT support SET_MEMBERS)
+ sonos_dlna = MockPlayer(
+ dlna_provider,
+ "dlna_sonos",
+ "Living Room (DLNA)",
+ player_type=PlayerType.PROTOCOL,
+ identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
+ )
+ # No SET_MEMBERS support
+ sonos_dlna._attr_can_group_with = {"dlna_other"}
+ sonos_dlna.set_protocol_parent_id("sonos_123")
+
+ # Another device
+ wiim_player = MockPlayer(
+ sonos_provider,
+ "wiim_789",
+ "Bedroom",
+ identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:03"},
+ )
+
+ airplay_other = MockPlayer(
+ airplay_provider,
+ "airplay_other",
+ "Bedroom (AirPlay)",
+ player_type=PlayerType.PROTOCOL,
+ identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:03"},
+ )
+ airplay_other._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
+ airplay_other._attr_can_group_with = {"airplay_sonos"}
+ airplay_other._cache.clear()
+ airplay_other.set_protocol_parent_id("wiim_789")
+
+ sonos_player.set_linked_output_protocols(
+ [
+ OutputProtocol(
+ output_protocol_id="airplay_sonos",
+ name="AirPlay",
+ protocol_domain="airplay",
+ priority=10,
+ available=True,
+ ),
+ OutputProtocol(
+ output_protocol_id="dlna_sonos",
+ name="DLNA",
+ protocol_domain="dlna",
+ priority=30,
+ available=True,
+ ),
+ ]
+ )
+
+ wiim_player.set_linked_output_protocols(
+ [
+ OutputProtocol(
+ output_protocol_id="airplay_other",
+ name="AirPlay",
+ protocol_domain="airplay",
+ priority=10,
+ available=True,
+ ),
+ ]
+ )
+
+ # Clear cache after setting linked protocols (output_protocols is cached)
+ sonos_player._cache.clear()
+ wiim_player._cache.clear()
+
+ # Wire up mock_mass.players to controller so get_linked_protocol works
+ mock_mass.players = controller
+
+ controller._players = {
+ "sonos_123": sonos_player,
+ "sonos_456": sonos_player_b,
+ "wiim_789": wiim_player,
+ "airplay_sonos": sonos_airplay,
+ "airplay_other": airplay_other,
+ "dlna_sonos": sonos_dlna,
+ }
+ controller._player_throttlers = {
+ "sonos_123": Throttler(1, 0.05),
+ "sonos_456": Throttler(1, 0.05),
+ "wiim_789": Throttler(1, 0.05),
+ "airplay_sonos": Throttler(1, 0.05),
+ "airplay_other": Throttler(1, 0.05),
+ "dlna_sonos": Throttler(1, 0.05),
+ }
+
+ # Update state after modifying attributes and registering with controller
+ # Note: set_linked_output_protocols calls trigger_player_update, but since mass.players
+ # is a MagicMock, we need to manually call update_state
+ # IMPORTANT: Update protocol players FIRST, then parent players, because parent players
+ # access protocol_player.state.can_group_with during their update_state()
+ sonos_airplay.update_state(signal_event=False)
+ airplay_other.update_state(signal_event=False)
+ sonos_dlna.update_state(signal_event=False)
+ sonos_player.update_state(signal_event=False)
+ sonos_player_b.update_state(signal_event=False)
+ wiim_player.update_state(signal_event=False)
+
+ # Get can_group_with with no active protocol
+ groupable = sonos_player.state.can_group_with
+
+ # Should show native players + AirPlay players (supports SET_MEMBERS)
+ # but NOT DLNA players (no SET_MEMBERS support)
+ assert "sonos_456" in groupable
+ assert "wiim_789" in groupable # Via AirPlay protocol
+ # DLNA players should not be shown since DLNA doesn't support SET_MEMBERS
+
+
+class TestProtocolSwitchingDuringPlayback:
+ """Tests for dynamic protocol switching when group members change during playback."""
+
+ async def test_no_protocol_set_during_grouping_without_playback(
+ self, mock_mass: MagicMock
+ ) -> None:
+ """Test that no protocol is set when grouping players without active playback."""
+ controller = PlayerController(mock_mass)
+
+ sonos_provider = MockProvider("sonos", instance_id="sonos_instance", mass=mock_mass)
+ airplay_provider = MockProvider("airplay", instance_id="airplay_instance", mass=mock_mass)
+
+ # Create Sonos player with AirPlay support
+ sonos_player = MockPlayer(
+ sonos_provider,
+ "sonos_123",
+ "Living Room",
+ identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
+ )
+ sonos_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
+ sonos_player._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
+ sonos_player._attr_can_group_with = {"sonos_456"}
+
+ # Create another Sonos player
+ sonos_player_b = MockPlayer(
+ sonos_provider,
+ "sonos_456",
+ "Kitchen",
+ identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:02"},
+ )
+ sonos_player_b._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
+
+ # Create AirPlay protocol player
+ sonos_airplay = MockPlayer(
+ airplay_provider,
+ "airplay_sonos",
+ "Living Room (AirPlay)",
+ player_type=PlayerType.PROTOCOL,
+ identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
+ )
+ sonos_airplay._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
+ sonos_airplay.set_protocol_parent_id("sonos_123")
+
+ sonos_player.set_linked_output_protocols(
+ [
+ OutputProtocol(
+ output_protocol_id="airplay_sonos",
+ name="AirPlay",
+ protocol_domain="airplay",
+ priority=10,
+ available=True,
+ )
+ ]
+ )
+
+ mock_mass.players = controller
+ controller._players = {
+ "sonos_123": sonos_player,
+ "sonos_456": sonos_player_b,
+ "airplay_sonos": sonos_airplay,
+ }
+ controller._player_throttlers = {
+ "sonos_123": Throttler(1, 0.05),
+ "sonos_456": Throttler(1, 0.05),
+ "airplay_sonos": Throttler(1, 0.05),
+ }
+
+ # Group players via protocol (simulate grouping through AirPlay)
+ # This should NOT set active_output_protocol anymore
+ await controller._forward_protocol_set_members(
+ parent_player=sonos_player,
+ parent_protocol_player=sonos_airplay,
+ protocol_members_to_add=["airplay_other"], # Add a protocol member
+ protocol_members_to_remove=[],
+ )
+
+ # NEW BEHAVIOR: Protocol should NOT be set during grouping without playback
+ # After grouping, protocol should not be activated until playback starts
+ assert sonos_player.active_output_protocol is None
+
+ async def test_protocol_selected_at_playback_time(self, mock_mass: MagicMock) -> None:
+ """Test that protocol is selected when playback starts, not during grouping."""
+ controller = PlayerController(mock_mass)
+
+ sonos_provider = MockProvider("sonos", instance_id="sonos_instance", mass=mock_mass)
+ airplay_provider = MockProvider("airplay", instance_id="airplay_instance", mass=mock_mass)
+
+ # Create Sonos player with AirPlay support
+ sonos_player = MockPlayer(
+ sonos_provider,
+ "sonos_123",
+ "Living Room",
+ identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
+ )
+ sonos_player._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
+ sonos_player._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
+
+ # Create AirPlay protocol player with group members
+ sonos_airplay = MockPlayer(
+ airplay_provider,
+ "airplay_sonos",
+ "Living Room (AirPlay)",
+ player_type=PlayerType.PROTOCOL,
+ identifiers={IdentifierType.MAC_ADDRESS: "AA:BB:CC:DD:EE:01"},
+ )
+ sonos_airplay._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
+ sonos_airplay._attr_supported_features.add(PlayerFeature.PLAY_MEDIA)
+ sonos_airplay.set_protocol_parent_id("sonos_123")
+ # Simulate that AirPlay protocol has group members (needs >1 for grouping check)
+ sonos_airplay._attr_group_members = ["airplay_sonos", "airplay_other"]
+
+ sonos_player.set_linked_output_protocols(
+ [
+ OutputProtocol(
+ output_protocol_id="airplay_sonos",
+ name="AirPlay",
+ protocol_domain="airplay",
+ priority=10,
+ available=True,
+ )
+ ]
+ )
+
+ mock_mass.players = controller
+ controller._players = {
+ "sonos_123": sonos_player,
+ "airplay_sonos": sonos_airplay,
+ }
+
+ # Update state to apply group members to state
+ sonos_airplay.update_state(signal_event=False)
+ sonos_player.update_state(signal_event=False)
+
+ # Protocol should not be set yet
+ assert sonos_player.active_output_protocol is None
+
+ # Select protocol for playback
+ selected_player, output_protocol = controller._select_best_output_protocol(sonos_player)
+
+ # Should select AirPlay protocol because it has group members (Priority 1)
+ assert selected_player == sonos_airplay
+ assert output_protocol is not None
+ assert output_protocol.output_protocol_id == "airplay_sonos"