/
/
/
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 typing import TYPE_CHECKING, Any
14
15from music_assistant_models.enums import (
16 IdentifierType,
17 PlaybackState,
18 PlayerFeature,
19 PlayerType,
20)
21from music_assistant_models.player import DeviceInfo
22from pywiim import WiiMClient, WiiMError
23
24from music_assistant.models.protocol_backed_player import ProtocolBackedPlayer
25
26from .constants import BACKEND_GENERIC, PLAYER_ID_PREFIX
27from .grouping import NativeGroupRole
28
29if TYPE_CHECKING:
30 from async_upnp_client.client import UpnpDevice
31 from music_assistant_models.player import PlayerMedia
32 from pywiim.models import DeviceInfo as PywiimDeviceInfo
33
34 from music_assistant.models.player import Player
35
36 from .grouping import NativeGroupCoordinator
37 from .provider import WiimProvider
38
39# The device is polled for reachability and native group topology only; playback state
40# comes from the linked protocol players, so a slow interval is plenty.
41POLL_INTERVAL = 30
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 native
50 LinkPlay multiroom grouping, driven by the low-level public WiiMClient. Topology is
51 resolved by the provider's shared coordinator across both WiiM backends.
52 """
53
54 _attr_type = PlayerType.PLAYER
55 linkplay_backend = BACKEND_GENERIC
56
57 def __init__(
58 self,
59 provider: WiimProvider,
60 player_id: str,
61 client: WiiMClient,
62 upnp_device: UpnpDevice,
63 description_url: str,
64 mac_address: str | None = None,
65 device_info: PywiimDeviceInfo | None = None,
66 ) -> None:
67 """Initialize the LinkPlay player shell."""
68 # Health of the LinkPlay HTTP API, separate from playback availability (which the
69 # base derives from linked protocols): it gates the native grouping capability.
70 # A device info primed from the discovery probe means the API is reachable.
71 # Set before super().__init__ because the base reads supported_features and the
72 # follower-suppressed playback state (via the coordinator) during init.
73 self._linkplay_available = device_info is not None
74 self._native_groups: NativeGroupCoordinator = provider.native_groups
75 super().__init__(provider, player_id)
76 # Low-level LinkPlay HTTP client over MA's shared aiohttp session. Its close()
77 # would close that shared session, so it is never closed here.
78 self._client = client
79 self._upnp_device = upnp_device
80 self._description_url = description_url
81 self._mac_address = mac_address
82 # Cached pywiim device info, refreshed on poll and passed to a follower's
83 # join_slave so it can pick the correct (router vs WiFi-Direct) join mode.
84 self._cached_device_info: PywiimDeviceInfo | None = device_info
85 self._rebuild_lock = asyncio.Lock()
86
87 self._attr_name = upnp_device.friendly_name or player_id
88 self._attr_supported_features = {PlayerFeature.SET_MEMBERS}
89 self._attr_needs_poll = True
90 self._attr_poll_interval = POLL_INTERVAL
91 self._attr_device_info = DeviceInfo(
92 model=upnp_device.model_name or "LinkPlay",
93 manufacturer=upnp_device.manufacturer or "LinkPlay",
94 )
95 self._attr_device_info.add_identifier(
96 IdentifierType.UUID, player_id.removeprefix(PLAYER_ID_PREFIX).removeprefix("uuid:")
97 )
98 if client.host:
99 self._attr_device_info.add_identifier(IdentifierType.IP_ADDRESS, client.host)
100 if mac_address:
101 self._attr_device_info.add_identifier(IdentifierType.MAC_ADDRESS, mac_address)
102
103 # --- Lifecycle ---
104
105 async def setup(self) -> None:
106 """Handle logic when the player is set up in the Player controller."""
107 # The discovery probe already fetched and validated the device info and primed it on
108 # this shell, so only re-read reachability when it did not. The provider reads the
109 # live topology right after registration (a read taken here would be discarded, as
110 # setup runs before the player is registered); the regular poll refreshes both.
111 if self._cached_device_info is None:
112 await self._refresh_reachability()
113
114 async def poll(self) -> None:
115 """Poll the device for reachability and push its native group topology."""
116 await self._refresh_reachability()
117 await self._native_groups.refresh_leader(self, force=True)
118
119 async def on_unload(self) -> None:
120 """Handle logic when the player is unloaded from the Player controller."""
121 await super().on_unload()
122 self._native_groups.unregister(self.player_id)
123 self._native_groups.schedule_reconcile()
124
125 # --- Properties ---
126
127 @property
128 def default_output_protocol_domain(self) -> str | None:
129 """Prefer a linked DLNA output for this device's default playback path."""
130 return "dlna"
131
132 @property
133 def cached_device_info(self) -> PywiimDeviceInfo | None:
134 """Return the last known pywiim device info (used by peers to pick a join mode)."""
135 return self._cached_device_info
136
137 @property
138 def native_available(self) -> bool:
139 """Return whether the native LinkPlay grouping API is currently reachable."""
140 return self._linkplay_available
141
142 @property
143 def native_ip(self) -> str | None:
144 """Return the device's current IP address, if known."""
145 host: str | None = self._client.host
146 return host
147
148 @property
149 def grouping_rebuild_lock(self) -> asyncio.Lock:
150 """Return the lock the coordinator holds so grouping and address rebuilds serialize."""
151 return self._rebuild_lock
152
153 @property
154 def grouping_locked(self) -> bool:
155 """Withdraw ALL grouping only when this device cannot be safely regrouped."""
156 # grouping_locked is the broad, final lock: it suppresses native AND linked-protocol
157 # grouping, so it is reserved for a device that genuinely cannot be regrouped, not
158 # for native capability merely being unavailable. Two cases qualify:
159 # - an unknown-leader follower belongs to an external group whose leader MA cannot
160 # see, so it can be neither detached nor regrouped; and
161 # - a device already in a native hardware group (leader or follower) whose LinkPlay
162 # API has dropped: adding it to a linked-protocol group would first need a native
163 # auto-ungroup that cannot run, and core would proceed anyway, leaving it in both
164 # groups. A standalone shell has nothing to leave, so it may still group via a
165 # linked protocol while the API is down (native grouping stays gated separately by
166 # supported_features and the coordinator's can_group_with).
167 if self._native_groups.is_unknown_leader_follower(self.player_id):
168 return True
169 role = self._native_groups.role_of(self.player_id)
170 return not self._linkplay_available and role is not NativeGroupRole.STANDALONE
171
172 @property
173 def playback_state(self) -> PlaybackState:
174 """Suppress delegated playback while natively grouped as a follower."""
175 if self._is_native_follower:
176 # a native follower plays its leader's stream at the hardware level; a known
177 # leader's state is mirrored by core through synced_to, an undiscovered one
178 # reports idle rather than the shell's stale linked-protocol playback.
179 return PlaybackState.IDLE
180 return super().playback_state
181
182 @property
183 def current_media(self) -> PlayerMedia | None:
184 """Suppress delegated media while natively grouped as a follower."""
185 if self._is_native_follower:
186 return None
187 return super().current_media
188
189 @property
190 def active_source(self) -> str | None:
191 """Suppress the delegated active source while natively grouped as a follower."""
192 if self._is_native_follower:
193 return None
194 return super().active_source
195
196 @property
197 def prefer_native_grouping(self) -> bool:
198 """Group compatible LinkPlay speakers natively, not via a linked protocol."""
199 return self._linkplay_available
200
201 @property
202 def supported_features(self) -> set[PlayerFeature]:
203 """Return the supported features; native grouping needs a reachable grouping API."""
204 features = super().supported_features
205 if self._linkplay_available:
206 return features
207 return features - {PlayerFeature.SET_MEMBERS}
208
209 @property
210 def can_group_with(self) -> set[str]:
211 """Return the ids of the reachable peers of either backend this player can group with."""
212 return self._native_groups.can_group_with(self)
213
214 def is_native_group_compatible(self, other: Player) -> bool:
215 """Only reachable coordinator-approved peers of either backend group natively."""
216 return other.player_id in self.can_group_with
217
218 def make_command_client(self) -> WiiMClient:
219 """
220 Return this player's low-level LinkPlay command client.
221
222 The generic backend already owns a client bound to the shared session for the
223 device's current address; it is reused for topology reads and grouping commands
224 and must never be closed.
225 """
226 return self._client
227
228 def store_command_capabilities(self, capabilities: dict[str, Any]) -> None:
229 """
230 Ignore detected capabilities; the generic backend reuses one persistent client.
231
232 :param capabilities: The capabilities a command client detected for this device.
233 """
234
235 def on_native_group_update(self) -> None:
236 """Re-publish state after the topology coordinator changed this player's role."""
237 # role/membership are derived by the coordinator, not from this player's own
238 # attributes, so force a recalculation (e.g. synced_to) even when idle.
239 self._attr_group_members = self._native_groups.members_of(self.player_id)
240 if self._is_native_follower and self.active_output_protocol is not None:
241 # a native follower gets audio from its leader at the hardware level, not from a
242 # linked protocol. Clear any active output so the final state mirrors the leader
243 # (synced_to) or falls back to idle instead of a stale DLNA/AirPlay player, which
244 # the base otherwise prioritizes over synced_to. It is left cleared on leaving:
245 # normal playback reselects an output DLNA-first / by user preference.
246 self.set_active_output_protocol(None)
247 self.mark_state_dirty()
248 self.update_state()
249
250 # --- Player commands ---
251
252 async def set_members(
253 self,
254 player_ids_to_add: list[str] | None = None,
255 player_ids_to_remove: list[str] | None = None,
256 ) -> None:
257 """Handle SET_MEMBERS command on the player."""
258 await self._native_groups.set_members(self, player_ids_to_add, player_ids_to_remove)
259
260 async def async_handle_address_change(
261 self, new_ip: str, upnp_device: UpnpDevice, description_url: str
262 ) -> None:
263 """
264 Rebuild the low-level client against a new device address.
265
266 The replacement is validated before the old client is dropped, so a failed rebuild
267 leaves the existing client intact for a later retry.
268
269 :param new_ip: The device's new IP address.
270 :param upnp_device: The UPnP device freshly probed at the new location.
271 :param description_url: The matched description.xml URL at the new location.
272 """
273 async with self._rebuild_lock:
274 if new_ip == self._client.host:
275 return
276 new_client = WiiMClient(new_ip, session=self.mass.http_session)
277 try:
278 device_info = await new_client.get_device_info_model()
279 except WiiMError as err:
280 self.logger.warning(
281 "Failed to reach LinkPlay device %s at %s: %s", self.name, new_ip, err
282 )
283 return
284 was_available = self._linkplay_available
285 self._client = new_client
286 self._cached_device_info = device_info
287 self._description_url = description_url
288 self._upnp_device = upnp_device
289 self._linkplay_available = True
290 self._attr_device_info.add_identifier(IdentifierType.IP_ADDRESS, new_ip)
291 # Re-read the topology from the new address so a stale entry never survives a move.
292 await self._native_groups.refresh_leader(self, force=True)
293 if not was_available:
294 # recovering from an outage flips native availability, so peers must re-publish
295 # to stop excluding this device from their grouping candidates.
296 self._native_groups.schedule_republish()
297 self.update_state()
298
299 # --- Internals ---
300
301 def _backing_protocol_player_ids(self) -> list[str]:
302 """Return the ids of the linked protocol players backing this shell's playback."""
303 return [linked.output_protocol_id for linked in self.linked_output_protocols]
304
305 @property
306 def _is_native_follower(self) -> bool:
307 """Whether the coordinator currently resolves this shell as a native follower."""
308 return self._native_groups.role_of(self.player_id) == NativeGroupRole.FOLLOWER
309
310 async def _refresh_reachability(self) -> None:
311 """Refresh the LinkPlay grouping API reachability and cached device info."""
312 # Serialize with async_handle_address_change so an in-flight read against the old
313 # client cannot land after a validated client swap and overwrite it with stale state.
314 health_changed = False
315 async with self._rebuild_lock:
316 was_available = self._linkplay_available
317 try:
318 self._cached_device_info = await self._client.get_device_info_model()
319 except WiiMError as err:
320 if self._linkplay_available:
321 self.logger.debug("LinkPlay API unreachable for %s: %s", self.name, err)
322 self._linkplay_available = False
323 health_changed = was_available
324 self.update_state()
325 else:
326 self._linkplay_available = True
327 health_changed = not was_available
328 self.update_state()
329 if health_changed:
330 # this shell's native availability just flipped, which changes whether every peer
331 # can offer it as a native grouping candidate; make them re-publish.
332 self._native_groups.schedule_republish()
333