child_player.name,
child_player.active_group,
)
- try:
- await other_group.set_members(player_ids_to_remove=[child_player.player_id])
- except UnsupportedFeaturedException as err:
+ if child_player.player_id in other_group.static_group_members:
self.logger.warning(
- "Failed to remove player %s from group %s: %s, powering it off instead",
+ "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,
- err,
)
await self.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",
# 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, player_ids_to_remove=player_ids_to_remove
+ 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")
self.logger.warning("Player %s is not available", 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 (
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
# 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 on_registered hook after the player is registered and config is set
- await player.on_registered()
+ # call hook after the player is registered and config is set
+ await player.on_config_updated()
# always call update to fix special attributes like display name, group volume etc.
player.update_state()
If the player is not registered, this will silently be ignored.
"""
- player = self._players.pop(player_id, None)
+ player = self._players.get(player_id)
if player is None:
return
+ await self._cleanup_player_memberships(player_id)
+ del self._players[player_id]
self.logger.info("Player removed: %s", player.name)
self.mass.player_queues.on_player_remove(player_id, permanent=permanent)
await player.on_unload()
# handle DSP reload of the leader when grouping/ungrouping
if ATTR_GROUP_MEMBERS in changed_values:
- new_group_members: list[str] = changed_values[ATTR_GROUP_MEMBERS][1]
- prev_group_members: list[str] = changed_values[ATTR_GROUP_MEMBERS][0] or []
- 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:
- 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_id).enabled
- if dsp_enabled and not supports_multi_device_dsp:
- # We now know that 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_id))
+ 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
if removed_player := self.get(player_id):
removed_player.update_state()
+ became_inactive = False
+ if "available" in changed_values:
+ became_inactive = changed_values["available"][1] is False
+ if not became_inactive and "enabled" in changed_values:
+ became_inactive = changed_values["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)
if not (player := self.get(config.player_id)):
return # guard against player not being registered (yet)
player.set_config(config)
+ await player.on_config_updated()
player.update_state()
resume_queue: PlayerQueue | None = (
self.mass.player_queues.get(player.active_source) if player.active_source else None
# 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)
- # check for group memberships that need to be updated
- if player_disabled and player.active_group and player_provider:
- # try to remove from the group
- group_player = self.get(player.active_group)
- assert group_player is not None # for type checking
- with suppress(UnsupportedFeaturedException, PlayerCommandFailed):
- await group_player.set_members(player_ids_to_remove=[player.player_id])
async def on_player_dsp_change(self, player_id: str) -> None:
"""Call (by config manager) when the DSP settings of a player change."""
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)
# 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))
+
def __iter__(self) -> Iterator[Player]:
"""Iterate over all players."""
return iter(self._players.values())
_attr_type: PlayerType = PlayerType.PLAYER
_attr_supported_features: set[PlayerFeature]
_attr_group_members: list[str]
+ _attr_static_group_members: list[str]
_attr_device_info: DeviceInfo
_attr_can_group_with: set[str]
_attr_source_list: list[PlayerSource]
# initialize mutable attributes
self._attr_supported_features = set()
self._attr_group_members = []
+ self._attr_static_group_members = []
self._attr_device_info = DeviceInfo()
self._attr_can_group_with = set()
self._attr_source_list = []
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]:
"""
),
]
- async def on_registered(self) -> None:
+ async def on_config_updated(self) -> None:
"""
- Handle logic when the player is registered and config is set.
+ Handle logic when the player is loaded or updated.
Override this method in your player implementation if you need
to perform any additional setup logic after the player is registered and
- the self.config was loaded.
+ the self.config was loaded, and whenever the config changes.
"""
return
self._state.volume_level = self.volume_state
self._state.volume_muted = self.volume_muted_state
self._state.group_members = UniqueList(self.group_members)
+ self._state.static_group_members = UniqueList(self.static_group_members)
self._state.can_group_with = self.can_group_with
self._state.synced_to = self.synced_to
self._state.active_source = self.active_source_state
PlayerFeature.VOLUME_SET,
}
- async def on_registered(self) -> None:
- """Complete the initialization once the player was registered."""
+ 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
- # Copy the list so not every added player becomes a static member
- self._attr_group_members = list(
- cast("list[str]", self.config.get_value(CONF_GROUP_MEMBERS, []))
- )
- # Uses self.config
+ static_members = cast("list[str]", self.config.get_value(CONF_GROUP_MEMBERS, []))
+ self._attr_static_group_members = static_members.copy()
+ if not self.powered:
+ self._attr_group_members = static_members.copy()
if self.is_dynamic:
self._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
+ else:
+ self._attr_supported_features.discard(PlayerFeature.SET_MEMBERS)
@property
def supported_features(self) -> set[PlayerFeature]:
# 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):
- try:
- other_group.check_feature(PlayerFeature.SET_MEMBERS)
+ 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])
- except UnsupportedFeaturedException:
+ 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)
self.update_state()
if powered:
+ # reset the group members to the available static members when powering on
+ self._attr_group_members = []
+ for static_group_member in self._attr_static_group_members:
+ if (
+ (member_player := self.mass.players.get(static_group_member))
+ and member_player.available
+ and member_player.enabled
+ ):
+ 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
await member.power(False)
if not powered:
- # reset the original group members when powered off and clear leader
- self._attr_group_members = list(
- cast("list[str]", self.config.get_value(CONF_GROUP_MEMBERS, []))
- )
+ # Reset to unfiltered static members list when powered off
+ # (the frontend will hide unavailable members)
+ self._attr_group_members = self._attr_static_group_members.copy()
+ # and clear the sync leader
self.sync_leader = None
async def _dissolve_syncgroup(self) -> None:
final_players_to_add.append(player_id)
# handle removals
final_players_to_remove: list[str] = []
- static_members = cast("list[str]", self.config.get_value(CONF_GROUP_MEMBERS, []))
for player_id in player_ids_to_remove or []:
if player_id not in self._attr_group_members:
continue
- if player_id in static_members:
- raise UnsupportedFeaturedException(
- f"Cannot remove {player_id} from {self.display_name} "
- "as it is a static member of this group"
- )
if player_id == self.player_id:
raise UnsupportedFeaturedException(
f"Cannot remove {self.display_name} from itself as a member!"
}
self._set_attributes()
- async def on_registered(self) -> None:
- """Complete the initialization once the player was registered."""
- # Config entries are only fully available after the player was registered
- self._attr_group_members = list(
- cast("list[str]", self.config.get_value(CONF_GROUP_MEMBERS, []))
- )
+ 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, []))
+ self._attr_static_group_members = static_members.copy()
+ if not self.powered:
+ self._attr_group_members = static_members.copy()
+ if self.is_dynamic:
+ self._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
+ elif PlayerFeature.SET_MEMBERS in self._attr_supported_features:
+ self._attr_supported_features.remove(PlayerFeature.SET_MEMBERS)
@cached_property
def is_dynamic(self) -> bool:
self._attr_powered = powered
if powered:
+ # reset the group members to the available static members when powering on
+ self._attr_group_members = []
+ for static_group_member in self._attr_static_group_members:
+ if (
+ (member_player := self.mass.players.get(static_group_member))
+ and member_player.available
+ and member_player.enabled
+ ):
+ self._attr_group_members.append(static_group_member)
# 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
# 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)
- ) and PlayerFeature.SET_MEMBERS in other_group.supported_features:
- await other_group.set_members(player_ids_to_remove=[member.player_id])
- else:
- # if the other group does not support SET_MEMBERS,
- # we need to power it off to leave the group
- await self.mass.players.cmd_power(member.active_group, False)
- await asyncio.sleep(1)
+ if other_group := self.mass.players.get(member.active_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)
+ await asyncio.sleep(1)
await asyncio.sleep(1)
if member.synced_to:
# edge case: the member is part of a syncgroup - ungroup it first
if not powered:
# reset the original group members when powered off
- self._attr_group_members = cast(
- "list[str]", self.config.get_value(CONF_GROUP_MEMBERS, [])
- )
+ self._attr_group_members = self._attr_static_group_members.copy()
self.update_state()
async def volume_set(self, volume_level: int) -> None:
),
)
# handle removals
- static_members = cast("list[str]", self.config.get_value(CONF_GROUP_MEMBERS, []))
for player_id in player_ids_to_remove or []:
if player_id not in self._attr_group_members:
continue
- if player_id in static_members:
- raise UnsupportedFeaturedException(
- f"Cannot remove {player_id} from {self.display_name} "
- "as it is a static member of this group"
- )
if player_id == self.player_id:
raise UnsupportedFeaturedException(
f"Cannot remove {self.display_name} from itself as a member!"