/
/
1"""
2Generic LinkPlay player: a thin grouping/identity shell over linked protocol players.
3
4Compatible LinkPlay speakers (e.g. Edifier) speak the LinkPlay HTTP API but have no
5Music Assistant native audio path. Their playback, transport state, current media and
6volume are delegated to their linked DLNA/AirPlay protocol players; this shell only
7adds device identity and native LinkPlay multiroom grouping/topology on top.
8"""
9
10from __future__ import annotations
11
12import asyncio
13from contextlib import AsyncExitStack
14from typing import TYPE_CHECKING
15
16from music_assistant_models.enums import IdentifierType, PlayerFeature, PlayerType
17from music_assistant_models.errors import PlayerCommandFailed, UnsupportedFeaturedException
18from music_assistant_models.player import DeviceInfo
19from pywiim import WiiMClient, WiiMError
20
21from music_assistant.models.protocol_backed_player import ProtocolBackedPlayer
22
23from .constants import BACKEND_GENERIC, PLAYER_ID_PREFIX
24from .helpers import is_in_mixed_group, linkplay_group_compatible, linkplay_slave_uuid_to_udn
25
26if TYPE_CHECKING:
27 from async_upnp_client.client import UpnpDevice
28 from pywiim.models import DeviceInfo as PywiimDeviceInfo
29
30 from music_assistant.models.player import Player
31
32 from .provider import WiimProvider
33
34# The device is polled for reachability and native group topology only; playback state
35# comes from the linked protocol players, so a slow interval is plenty.
36POLL_INTERVAL = 30
37
38# A native multiroom join/leave is fire-and-forget and the group takes a few seconds to
39# settle, so the leader's topology is re-read a few times before a change is rejected.
40GROUP_VERIFY_ATTEMPTS = 5
41GROUP_VERIFY_INTERVAL = 1.5
42
43
44class LinkPlayPlayer(ProtocolBackedPlayer):
45 """
46 Generic LinkPlay player shell in Music Assistant.
47
48 Playback/state/volume are delegated to the linked DLNA/AirPlay protocol players via
49 the protocol-linking system; this player natively owns only device identity and
50 same-backend LinkPlay multiroom grouping, driven by the low-level public WiiMClient.
51 """
52
53 _attr_type = PlayerType.PLAYER
54 linkplay_backend = BACKEND_GENERIC
55
56 def __init__(
57 self,
58 provider: WiimProvider,
59 player_id: str,
60 client: WiiMClient,
61 upnp_device: UpnpDevice,
62 description_url: str,
63 mac_address: str | None = None,
64 device_info: PywiimDeviceInfo | None = None,
65 ) -> None:
66 """Initialize the LinkPlay player shell."""
67 # Health of the LinkPlay HTTP API, separate from playback availability (which the
68 # base derives from linked protocols): it gates the native grouping capability.
69 # A device info primed from the discovery probe means the API is reachable.
70 # Set before super().__init__ because the base reads supported_features during init.
71 self._linkplay_available = device_info is not None
72 super().__init__(provider, player_id)
73 # Low-level LinkPlay HTTP client over MA's shared aiohttp session. Its close()
74 # would close that shared session, so it is never closed here.
75 self._client = client
76 self._upnp_device = upnp_device
77 self._description_url = description_url
78 self._mac_address = mac_address
79 # Cached pywiim device info, refreshed on poll and passed to a follower's
80 # join_slave so it can pick the correct (router vs WiFi-Direct) join mode.
81 self._cached_device_info: PywiimDeviceInfo | None = device_info
82 self._rebuild_lock = asyncio.Lock()
83
84 self._attr_name = upnp_device.friendly_name or player_id
85 self._attr_supported_features = {PlayerFeature.SET_MEMBERS}
86 self._attr_needs_poll = True
87 self._attr_poll_interval = POLL_INTERVAL
88 self._attr_device_info = DeviceInfo(
89 model=upnp_device.model_name or "LinkPlay",
90 manufacturer=upnp_device.manufacturer or "LinkPlay",
91 )
92 self._attr_device_info.add_identifier(
93 IdentifierType.UUID, player_id.removeprefix(PLAYER_ID_PREFIX).removeprefix("uuid:")
94 )
95 if client.host:
96 self._attr_device_info.add_identifier(IdentifierType.IP_ADDRESS, client.host)
97 if mac_address:
98 self._attr_device_info.add_identifier(IdentifierType.MAC_ADDRESS, mac_address)
99
100 # --- Lifecycle ---
101
102 async def setup(self) -> None:
103 """Handle logic when the player is set up in the Player controller."""
104 # The discovery probe already fetched and validated the device info and primed it
105 # on this shell, so setup only needs an initial native-group topology read; the
106 # regular poll refreshes reachability and device info from here on.
107 if self._cached_device_info is None:
108 await self._refresh_linkplay()
109 return
110 async with self._rebuild_lock:
111 try:
112 await self._update_group_members()
113 except WiiMError as err:
114 self.logger.debug("Failed to read group topology for %s: %s", self.name, err)
115 self.update_state()
116
117 async def poll(self) -> None:
118 """Poll the device for reachability and native group topology."""
119 await self._refresh_linkplay()
120
121 # --- Properties ---
122
123 @property
124 def default_output_protocol_domain(self) -> str | None:
125 """Prefer a linked DLNA output for this device's default playback path."""
126 return "dlna"
127
128 @property
129 def cached_device_info(self) -> PywiimDeviceInfo | None:
130 """Return the last known pywiim device info (used by peers to pick a join mode)."""
131 return self._cached_device_info
132
133 @property
134 def grouping_locked(self) -> bool:
135 """Suppress grouping while the LinkPlay API is unreachable or the group is mixed."""
136 return not self._linkplay_available or self._in_mixed_group
137
138 @property
139 def prefer_native_grouping(self) -> bool:
140 """Group compatible generic LinkPlay speakers natively, not via a linked protocol."""
141 return self._linkplay_available
142
143 @property
144 def supported_features(self) -> set[PlayerFeature]:
145 """Return the supported features; native grouping needs a reachable, non-mixed group."""
146 features = super().supported_features
147 # Native multiroom grouping needs the LinkPlay HTTP API to be reachable and the
148 # device to not be in an externally-created mixed group (read-only until layer 2).
149 if self._linkplay_available and not self._in_mixed_group:
150 return features
151 return features - {PlayerFeature.SET_MEMBERS}
152
153 @property
154 def can_group_with(self) -> set[str]:
155 """Return the ids of the reachable, compatible generic LinkPlay peers to group with."""
156 if not self._linkplay_available or self._in_mixed_group:
157 return set()
158 return {
159 player.player_id
160 for player in self.provider.players
161 if isinstance(player, LinkPlayPlayer)
162 and player is not self
163 and player._linkplay_available
164 and not player._in_mixed_group
165 and linkplay_group_compatible(self._cached_device_info, player._cached_device_info)
166 }
167
168 def is_native_group_compatible(self, other: Player) -> bool:
169 """Only reachable, compatible generic LinkPlay peers group natively."""
170 return other.player_id in self.can_group_with
171
172 # --- Player commands ---
173
174 async def set_members(
175 self,
176 player_ids_to_add: list[str] | None = None,
177 player_ids_to_remove: list[str] | None = None,
178 ) -> None:
179 """Handle SET_MEMBERS command on the player."""
180 add_ids = player_ids_to_add or []
181 remove_ids = player_ids_to_remove or []
182 # Prevalidate the whole batch before taking any locks, so an obviously bad request
183 # fails fast without contending for a device that a rebuild may be using.
184 self._validate_leader_state()
185 if len(set(add_ids)) != len(add_ids) or len(set(remove_ids)) != len(remove_ids):
186 raise PlayerCommandFailed(f"Duplicate member id in grouping request on {self.name}")
187 if conflicting := set(add_ids) & set(remove_ids):
188 raise PlayerCommandFailed(
189 f"Cannot add and remove the same member on {self.name}: {conflicting}"
190 )
191 # Resolve every target to a same-backend member (rejects unknown/cross-backend/self).
192 to_add = [(mid, self._require_linkplay_member(mid)) for mid in add_ids]
193 to_remove = [(mid, self._require_linkplay_member(mid)) for mid in remove_ids]
194 self._validate_additions(to_add)
195 # Lock this leader and every affected member for the entire mutation + verification.
196 # Acquiring in sorted player_id order gives every caller the same lock ordering, so
197 # concurrent grouping requests that share a device serialize instead of deadlocking,
198 # a concurrent address rebuild on any involved device waits its turn, and unrelated
199 # players keep their locks free.
200 affected: dict[str, LinkPlayPlayer] = {self.player_id: self}
201 for _, member in (*to_add, *to_remove):
202 affected[member.player_id] = member
203 async with AsyncExitStack() as stack:
204 for player_id in sorted(affected):
205 await stack.enter_async_context(affected[player_id]._rebuild_lock)
206 # Revalidate now the locks are held: a rebuild or poll may have changed a
207 # device's reachability, swapped its client or moved its IP since prevalidation.
208 self._validate_leader_state()
209 self._validate_additions(to_add)
210 try:
211 # Identify which removals we still own from the leader's live topology and
212 # prevalidate their health before any hardware change, so a batch containing
213 # an unreachable current member fails whole rather than half-applied. Targets
214 # already absent (e.g. moved to another group) stay idempotent skips.
215 current_slaves = await self._current_slave_ids() if to_remove else set()
216 for member_id, member in to_remove:
217 if member.player_id in current_slaves and (
218 not member._linkplay_available or member._cached_device_info is None
219 ):
220 raise PlayerCommandFailed(f"{member_id} is not reachable for grouping")
221 for member_id, member in to_add:
222 await member._client.join_slave(
223 self._client.host, master_device_info=self._cached_device_info
224 )
225 await self._verify_group_change(member, member_id, expect_slave=True)
226 for member_id, member in to_remove:
227 if member.player_id not in current_slaves:
228 # a removal target that has since moved to another (possibly read-only
229 # mixed) group is left alone instead of being torn out of it
230 self.logger.debug(
231 "%s is no longer a member of %s; skipping leave",
232 member_id,
233 self.name,
234 )
235 continue
236 await member._client.leave_group()
237 await self._verify_group_change(member, member_id, expect_slave=False)
238 except WiiMError as err:
239 raise PlayerCommandFailed(f"set_members failed on {self.name}: {err}") from err
240 finally:
241 self.mass.create_task(
242 self._refresh_linkplay(), task_id=f"linkplay_refresh_{self.player_id}"
243 )
244
245 async def async_handle_address_change(
246 self, new_ip: str, upnp_device: UpnpDevice, description_url: str
247 ) -> None:
248 """
249 Rebuild the low-level client against a new device address.
250
251 The replacement is validated before the old client is dropped, so a failed
252 rebuild leaves the existing client intact for a later retry.
253
254 :param new_ip: The device's new IP address.
255 :param upnp_device: The UPnP device freshly probed at the new location.
256 :param description_url: The matched description.xml URL at the new location.
257 """
258 async with self._rebuild_lock:
259 if new_ip == self._client.host:
260 return
261 new_client = WiiMClient(new_ip, session=self.mass.http_session)
262 try:
263 device_info = await new_client.get_device_info_model()
264 except WiiMError as err:
265 self.logger.warning(
266 "Failed to reach LinkPlay device %s at %s: %s", self.name, new_ip, err
267 )
268 return
269 self._client = new_client
270 self._cached_device_info = device_info
271 self._description_url = description_url
272 self._upnp_device = upnp_device
273 self._linkplay_available = True
274 self._attr_device_info.add_identifier(IdentifierType.IP_ADDRESS, new_ip)
275 try:
276 await self._update_group_members()
277 except WiiMError as err:
278 # The rebuild succeeded; a stale topology read must not undo it.
279 self.logger.debug("Failed to read group topology for %s: %s", self.name, err)
280 self.update_state()
281
282 # --- Internals ---
283
284 def _backing_protocol_player_ids(self) -> list[str]:
285 """Return the ids of the linked protocol players backing this shell's playback."""
286 return [linked.output_protocol_id for linked in self.linked_output_protocols]
287
288 async def _refresh_linkplay(self) -> None:
289 """Refresh reachability, cached device info and native group topology."""
290 # Serialize with async_handle_address_change so an in-flight poll against the old
291 # client cannot land after a validated client swap and overwrite it with stale state.
292 async with self._rebuild_lock:
293 try:
294 self._cached_device_info = await self._client.get_device_info_model()
295 except WiiMError as err:
296 if self._linkplay_available:
297 self.logger.debug("LinkPlay API unreachable for %s: %s", self.name, err)
298 self._linkplay_available = False
299 # Keep the last known group members: an unreachable device may still be in a
300 # (possibly mixed) hardware group, and dropping the topology here would let the
301 # still-reachable side re-expose SET_MEMBERS. grouping_locked already blocks
302 # this shell's own commands while unreachable.
303 self.update_state()
304 return
305 self._linkplay_available = True
306 try:
307 await self._update_group_members()
308 except WiiMError as err:
309 # A transient topology read must not drop a real hardware group; the last
310 # known members are kept until a successful read replaces them.
311 self.logger.debug("Failed to read group topology for %s: %s", self.name, err)
312 self.update_state()
313
314 async def _update_group_members(self) -> None:
315 """Map this device's native slave list onto MA group member ids (leader first)."""
316 # get_slaves_info() raises on a failed request (unlike get_device_group_info, which
317 # masks a failed slave read as "solo"), so a transient blip cannot silently clear a
318 # real group. It returns the slaves only for a master; a follower/solo returns none.
319 slaves = await self._client.get_slaves_info()
320 members = [self.player_id]
321 for slave in slaves:
322 resolved = self._resolve_member_player_id(slave.get("uuid"))
323 if resolved and resolved not in members:
324 members.append(resolved)
325 self._attr_group_members = members if len(members) > 1 else []
326
327 async def _current_slave_ids(self) -> set[str]:
328 """Return the player ids this leader currently reports as its native group slaves."""
329 slaves = await self._client.get_slaves_info()
330 return {
331 resolved
332 for slave in slaves
333 if (resolved := self._resolve_member_player_id(slave.get("uuid"))) is not None
334 }
335
336 @property
337 def _in_mixed_group(self) -> bool:
338 """Whether this device is in an externally-created mixed (cross-backend) group."""
339 return is_in_mixed_group(self)
340
341 def _require_linkplay_member(self, player_id: str) -> LinkPlayPlayer:
342 """
343 Return a same-backend member for a grouping command.
344
345 MA-created grouping stays within the generic LinkPlay backend; a request that
346 targets this player itself or any other backend is rejected rather than cast.
347 """
348 if player_id == self.player_id:
349 raise UnsupportedFeaturedException(f"Cannot group {self.name} with itself")
350 member = self.mass.players.get_player(player_id)
351 if not isinstance(member, LinkPlayPlayer):
352 raise UnsupportedFeaturedException(
353 f"Cannot group {player_id} with {self.name}: cross-backend grouping is unsupported"
354 )
355 return member
356
357 def _validate_leader_state(self) -> None:
358 """Assert this leader's LinkPlay API is reachable and it owns a regroupable group."""
359 if not self._linkplay_available or self._cached_device_info is None:
360 raise PlayerCommandFailed(f"{self.name} is not reachable for grouping")
361 if self._in_mixed_group:
362 raise PlayerCommandFailed(
363 f"{self.name} is in an externally-created mixed group and cannot be regrouped"
364 )
365
366 def _validate_additions(self, to_add: list[tuple[str, LinkPlayPlayer]]) -> None:
367 """
368 Assert every addition is reachable and of a compatible multiroom generation.
369
370 :param to_add: The resolved (id, member) pairs that would join this leader.
371 """
372 for member_id, member in to_add:
373 if not member._linkplay_available or member._cached_device_info is None:
374 raise PlayerCommandFailed(f"{member_id} is not reachable for grouping")
375 if member._in_mixed_group:
376 raise PlayerCommandFailed(
377 f"{member_id} is in an externally-created mixed group and cannot be regrouped"
378 )
379 if not linkplay_group_compatible(self._cached_device_info, member._cached_device_info):
380 raise UnsupportedFeaturedException(
381 f"Cannot group {member_id} with {self.name}: "
382 "incompatible LinkPlay multiroom generation"
383 )
384
385 async def _verify_group_change(
386 self, member: LinkPlayPlayer, member_id: str, expect_slave: bool
387 ) -> None:
388 """Confirm a grouping operation actually took effect on THIS leader's group."""
389 for attempt in range(GROUP_VERIFY_ATTEMPTS):
390 try:
391 slaves = await self._client.get_slaves_info()
392 except WiiMError as err:
393 raise PlayerCommandFailed(
394 f"Could not verify grouping of {member_id} on {self.name}: {err}"
395 ) from err
396 current_members = {
397 resolved
398 for slave in slaves
399 if (resolved := self._resolve_member_player_id(slave.get("uuid"))) is not None
400 }
401 if (member.player_id in current_members) == expect_slave:
402 return
403 # The group is still settling; wait briefly and re-read before giving up.
404 if attempt + 1 < GROUP_VERIFY_ATTEMPTS:
405 await asyncio.sleep(GROUP_VERIFY_INTERVAL)
406 verb = "join" if expect_slave else "leave"
407 raise PlayerCommandFailed(f"{member_id} did not {verb} the group led by {self.name}")
408
409 def _resolve_member_player_id(self, slave_uuid: str | None) -> str | None:
410 """
411 Resolve a slave's UUID (24-char HTTP or full UDN form) to a registered player id.
412
413 Matches against players already registered by this provider (in either backend)
414 and ignores members that do not resolve to a known player.
415
416 :param slave_uuid: The slave's UUID from the native group topology.
417 """
418 if not slave_uuid or (udn := linkplay_slave_uuid_to_udn(slave_uuid)) is None:
419 return None
420 target_hex = udn.removeprefix("uuid:").replace("-", "").upper()
421 for player in self.provider.players:
422 player_id = player.player_id
423 if not player_id.startswith(PLAYER_ID_PREFIX):
424 continue
425 udn_hex = player_id[len(PLAYER_ID_PREFIX) :].removeprefix("uuid:").replace("-", "")
426 if udn_hex.upper() == target_hex:
427 return player_id
428 return None
429