/
/
/
1"""Sync Group Player implementation."""
2
3from __future__ import annotations
4
5import asyncio
6from typing import TYPE_CHECKING, cast
7
8from music_assistant_models.config_entries import ConfigEntry, ConfigValueOption, ConfigValueType
9from music_assistant_models.enums import ConfigEntryType, PlaybackState, PlayerFeature, PlayerType
10from music_assistant_models.errors import UnsupportedFeaturedException
11from propcache import under_cached_property as cached_property
12
13from music_assistant.constants import (
14 APPLICATION_NAME,
15 CONF_DYNAMIC_GROUP_MEMBERS,
16 CONF_GROUP_MEMBERS,
17)
18from music_assistant.models.player import DeviceInfo, GroupPlayer, Player, PlayerMedia
19
20from .constants import CONF_ENTRY_SGP_NOTE, EXTRA_FEATURES_FROM_MEMBERS, SUPPORT_DYNAMIC_LEADER
21
22if TYPE_CHECKING:
23 from .provider import SyncGroupProvider
24
25
26class SyncGroupPlayer(GroupPlayer):
27 """Sync Group Player implementation."""
28
29 _attr_type: PlayerType = PlayerType.GROUP
30 sync_leader: Player | None = None
31 """The active sync leader player for this syncgroup."""
32
33 def __init__(
34 self,
35 provider: SyncGroupProvider,
36 player_id: str,
37 ) -> None:
38 """Initialize SyncGroupPlayer instance."""
39 super().__init__(provider, player_id)
40 self._attr_name = self.config.name or self.config.default_name or f"SyncGroup {player_id}"
41 self._attr_available = True
42 self._attr_device_info = DeviceInfo(model=provider.name, manufacturer=APPLICATION_NAME)
43 # Allow grouping with any player that supports syncing
44 # The actual compatibility is checked via can_group_with on each player
45 self._attr_can_group_with = set()
46
47 @cached_property
48 def is_dynamic(self) -> bool:
49 """Return if the player is a dynamic group player."""
50 return bool(self.config.get_value(CONF_DYNAMIC_GROUP_MEMBERS, False))
51
52 async def on_config_updated(self) -> None:
53 """Handle logic when the player is loaded or updated."""
54 # Config is only available after the player was registered
55 self._cache.clear() # clear to prevent loading old is_dynamic
56 default_members = cast("list[str]", self.config.get_value(CONF_GROUP_MEMBERS, []))
57 if self.is_dynamic:
58 self._attr_static_group_members = []
59 self._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
60 else:
61 self._attr_static_group_members = default_members.copy()
62 self._attr_supported_features.discard(PlayerFeature.SET_MEMBERS)
63 self._attr_group_members = default_members.copy()
64
65 @cached_property
66 def supported_features(self) -> set[PlayerFeature]:
67 """Return the supported features of the player."""
68 # by default we don't have any features, except play_media
69 # but we can gain some features based on the capabilities of the sync leader
70 # set_members is only supported if it's a dynamic group
71 base_features: set[PlayerFeature] = {PlayerFeature.PLAY_MEDIA}
72 if self.is_dynamic:
73 base_features.add(PlayerFeature.SET_MEMBERS)
74 if not self.sync_leader:
75 return base_features
76 # add features supported by the sync leader
77 for feature in EXTRA_FEATURES_FROM_MEMBERS:
78 if feature in self.sync_leader.state.supported_features:
79 base_features.add(feature)
80 return base_features
81
82 @property
83 def playback_state(self) -> PlaybackState:
84 """Return the current playback state of the player."""
85 return self.sync_leader.state.playback_state if self.sync_leader else PlaybackState.IDLE
86
87 @property
88 def requires_flow_mode(self) -> bool:
89 """Return if the player needs flow mode."""
90 if leader := self.sync_leader:
91 return leader.flow_mode
92 return False
93
94 @property
95 def elapsed_time(self) -> float | None:
96 """Return the elapsed time in (fractional) seconds of the current track (if any)."""
97 return self.sync_leader.state.elapsed_time if self.sync_leader else None
98
99 @property
100 def elapsed_time_last_updated(self) -> float | None:
101 """Return when the elapsed time was last updated."""
102 return self.sync_leader.state.elapsed_time_last_updated if self.sync_leader else None
103
104 @property
105 def current_media(self) -> PlayerMedia | None:
106 """Return the current media item (if any) loaded in the player."""
107 return self.sync_leader.state.current_media if self.sync_leader else None
108
109 @property
110 def active_source(self) -> str | None:
111 """Return the active source id (if any) of the player."""
112 return self.sync_leader.active_source if self.sync_leader else None
113
114 @property
115 def can_group_with(self) -> set[str]:
116 """Return the id's of players this player can group with."""
117 if not self.is_dynamic:
118 # in case of static members,
119 # we can only group with the players defined in the config, so we return those directly
120 return set(self._attr_static_group_members)
121 # if we already have a sync leader, we use its can_group_with as reference
122 if self.sync_leader:
123 return {self.sync_leader.player_id, *self.sync_leader.state.can_group_with}
124 # If we have no members, but we do have default members in the config,
125 # we can group with players that are compatible with those
126 default_members = cast("list[str]", self.config.get_value(CONF_GROUP_MEMBERS, []))
127 for member_id in default_members:
128 member_player = self.mass.players.get_player(member_id)
129 if member_player and member_player.state.available:
130 return {*default_members, *member_player.state.can_group_with}
131 # Dynamic groups can potentially group with any compatible players
132 # Actual compatibility is validated when adding members
133 temp_can_group_with = set()
134 for player in self.mass.players.all_players(return_unavailable=False):
135 if not player.available or player.type == PlayerType.GROUP:
136 # let's avoid showing group players as options to group with
137 continue
138 if (
139 PlayerFeature.SET_MEMBERS in player.state.supported_features
140 and player.state.can_group_with
141 ):
142 temp_can_group_with.add(player.player_id)
143 return temp_can_group_with
144
145 async def get_config_entries(
146 self,
147 action: str | None = None,
148 values: dict[str, ConfigValueType] | None = None,
149 ) -> list[ConfigEntry]:
150 """Return all (provider/player specific) Config Entries for the given player (if any)."""
151 entries: list[ConfigEntry] = [
152 # syncgroup specific entries
153 CONF_ENTRY_SGP_NOTE,
154 ConfigEntry(
155 key=CONF_GROUP_MEMBERS,
156 type=ConfigEntryType.STRING,
157 multi_value=True,
158 label="Group members",
159 default_value=[],
160 description="Select all players you want to be part of this sync group. "
161 "Only compatible players (based on their sync protocol) can be grouped together.",
162 required=False, # needed for dynamic members (which allows empty members list)
163 options=[
164 ConfigValueOption(x.display_name, x.player_id)
165 for x in self.mass.players.all_players(True, False)
166 if x.type != PlayerType.GROUP
167 ],
168 ),
169 ConfigEntry(
170 key=CONF_DYNAMIC_GROUP_MEMBERS,
171 type=ConfigEntryType.BOOLEAN,
172 label="Enable dynamic members",
173 description="Allow (un)joining members dynamically, so the group more or less "
174 "behaves the same like manually syncing players together, "
175 "with the main difference being that the group player will hold the queue.",
176 default_value=False,
177 required=False,
178 ),
179 ]
180 return entries
181
182 async def stop(self) -> None:
183 """Send STOP command to given player."""
184 if sync_leader := self.sync_leader:
185 # Use internal handler to bypass group redirect logic and avoid infinite loop
186 # (sync_leader is part of this group, so redirect would loop back here)
187 await self.mass.players._handle_cmd_stop(sync_leader.player_id)
188 # dissolve the sync group since we stopped playback
189 await self._dissolve_syncgroup()
190
191 async def play(self) -> None:
192 """Send PLAY (unpause) command to given player."""
193 if sync_leader := self.sync_leader:
194 # Use internal handler to bypass group redirect logic and avoid infinite loop
195 await self.mass.players._handle_cmd_play(sync_leader.player_id)
196
197 async def pause(self) -> None:
198 """Send PAUSE command to given player."""
199 if sync_leader := self.sync_leader:
200 # Use internal handler to bypass group redirect logic and avoid infinite loop
201 await self.mass.players._handle_cmd_pause(sync_leader.player_id)
202
203 async def play_media(self, media: PlayerMedia) -> None:
204 """Handle PLAY MEDIA on given player."""
205 if not self.sync_leader:
206 await self._form_syncgroup()
207 # simply forward the command to the sync leader
208 if sync_leader := self.sync_leader:
209 # Use internal handler to bypass group redirect logic and preserve protocol selection
210 await self.mass.players._handle_play_media(sync_leader.player_id, media)
211 self.update_state()
212 else:
213 raise RuntimeError("An empty group cannot play media, consider adding members first")
214
215 async def enqueue_next_media(self, media: PlayerMedia) -> None:
216 """Handle enqueuing of a next media item on the player."""
217 if sync_leader := self.sync_leader:
218 # Use internal handler to bypass group redirect logic and avoid infinite loop
219 await self.mass.players._handle_enqueue_next_media(sync_leader.player_id, media)
220
221 async def set_members(
222 self,
223 player_ids_to_add: list[str] | None = None,
224 player_ids_to_remove: list[str] | None = None,
225 ) -> None:
226 """Handle SET_MEMBERS command on the player."""
227 if not self.is_dynamic:
228 raise UnsupportedFeaturedException(
229 f"Group {self.display_name} does not allow dynamically adding/removing members!"
230 )
231 prev_leader = self.sync_leader
232 cur_leader = self._select_sync_leader(new_members=player_ids_to_add)
233 # handle additions
234 final_players_to_add: list[str] = []
235 can_group_with = cur_leader.state.can_group_with.copy() if cur_leader else set()
236 for member_id in player_ids_to_add or []:
237 if member_id == self.player_id:
238 continue # can not add self as member
239 member = self.mass.players.get_player(member_id)
240 if member is None or not member.available:
241 continue
242 if member_id not in self._attr_group_members:
243 self._attr_group_members.append(member_id)
244 if not cur_leader:
245 continue
246 if member_id != cur_leader.player_id and member_id not in can_group_with:
247 self.logger.debug(
248 f"Cannot add {member.display_name} to group {self.display_name} since it's "
249 f"not compatible with the current sync leader"
250 )
251 continue
252 if member_id != cur_leader.player_id:
253 final_players_to_add.append(member_id)
254
255 # handle removals
256 final_players_to_remove: list[str] = []
257 for member_id in player_ids_to_remove or []:
258 if member_id not in self._attr_group_members:
259 continue
260 if member_id == self.player_id:
261 raise UnsupportedFeaturedException(
262 f"Cannot remove {self.display_name} from itself as a member!"
263 )
264 self._attr_group_members.remove(member_id)
265 final_players_to_remove.append(member_id)
266 self.update_state()
267 if self.playback_state != PlaybackState.PLAYING:
268 # Don't need to do anything else if the group is not active
269 # The syncing will be done once playback starts
270 return
271 if prev_leader and cur_leader is None:
272 # Edge case: we no longer have any members in the group (and thus no leader)
273 await self._handle_leader_transition(None)
274 elif prev_leader and prev_leader != cur_leader:
275 # Edge case: we had changed the leader (or just got one)
276 await self._handle_leader_transition(cur_leader)
277 elif cur_leader and (player_ids_to_add or player_ids_to_remove):
278 # if the group still has the same leader, we just need to (re)sync the members
279 await self.mass.players.cmd_set_members(
280 cur_leader.player_id,
281 player_ids_to_add=final_players_to_add,
282 player_ids_to_remove=final_players_to_remove,
283 )
284
285 async def _form_syncgroup(self) -> None:
286 """Form syncgroup by syncing all (possible) members."""
287 if not self.sync_leader:
288 self.sync_leader = self._select_sync_leader()
289
290 if not self.sync_leader:
291 # we have no members in the group, so we can't form a syncgroup
292 return
293
294 # ensure the sync leader is first in the list
295 self._attr_group_members = [
296 self.sync_leader.player_id,
297 *[x for x in self._attr_group_members if x != self.sync_leader.player_id],
298 ]
299 members_to_sync = [
300 x
301 for x in self._attr_group_members
302 if x != self.sync_leader.player_id and x not in self.sync_leader.state.group_members
303 ]
304 if members_to_sync:
305 await self.mass.players.cmd_set_members(self.sync_leader.player_id, members_to_sync)
306
307 async def _dissolve_syncgroup(self) -> None:
308 """Dissolve the current syncgroup by ungrouping all members."""
309 if sync_leader := self.sync_leader:
310 # dissolve the temporary syncgroup from the sync leader
311 sync_children = [
312 x for x in sync_leader.state.group_members if x != sync_leader.player_id
313 ]
314 if sync_children:
315 await self.mass.players.cmd_set_members(sync_leader.player_id, [], sync_children)
316 self.sync_leader = None
317 self.update_state()
318
319 async def _handle_leader_transition(self, new_leader: Player | None) -> None:
320 """Handle transition from current leader to new leader."""
321 prev_leader = self.sync_leader
322 was_playing = False
323 if prev_leader and new_leader and prev_leader != new_leader:
324 # Check if the provider(protocol) supports dynamic leader selection
325 # For cross-provider sync groups, we need to check the provider domain
326 provider_protocol = None
327 if prev_leader.active_output_protocol and (
328 proto_prov := self.mass.get_provider(prev_leader.active_output_protocol)
329 ):
330 provider_protocol = proto_prov.domain
331 else:
332 provider_protocol = prev_leader.provider.domain
333
334 if provider_protocol and provider_protocol in SUPPORT_DYNAMIC_LEADER:
335 # TODO: figure out how to handle dynamic leader transition without
336 # stopping playback, which has become complicated due
337 # to a player can support multiple protocols
338 pass
339
340 if prev_leader:
341 # Save current media and playback state for potential restart
342 was_playing = self.playback_state == PlaybackState.PLAYING
343 # Stop current playback (which also dissolves the existing syncgroup)
344 await self.stop()
345 # allow some time to propagate the changes before resyncing
346 await asyncio.sleep(2)
347
348 # Set new leader
349 self.sync_leader = new_leader
350
351 if new_leader:
352 # form a syncgroup with the new leader
353 await self._form_syncgroup()
354 # Restart playback if requested and we have media to play
355 if was_playing:
356 await self.mass.players._handle_cmd_resume(self.player_id)
357 else:
358 # We have no leader anymore, send update since we stopped playback
359 self.update_state()
360
361 def _select_sync_leader(self, new_members: list[str] | None = None) -> Player | None:
362 """Select a (new) sync leader."""
363 if self.group_members and self.sync_leader and self.sync_leader.state.available:
364 # current leader is still available, no need to select a new one
365 return self.sync_leader
366 default_members = cast("list[str]", self.config.get_value(CONF_GROUP_MEMBERS, []))
367 group_members = self.group_members or default_members or new_members or []
368 for member_id in group_members:
369 member_player = self.mass.players.get_player(member_id)
370 if member_player and member_player.state.available:
371 self.logger.debug(
372 f"Auto-selected {member_player.display_name} as sync leader for "
373 f"group {self.display_name}"
374 )
375 return member_player
376 return None
377