/
/
1"""Wiim Player implementation."""
2
3from __future__ import annotations
4
5import time
6import typing
7from typing import TYPE_CHECKING
8
9from music_assistant_models.enums import IdentifierType, PlaybackState, PlayerFeature, PlayerType
10from music_assistant_models.errors import PlayerCommandFailed, UnsupportedFeaturedException
11from music_assistant_models.player import DeviceInfo
12from wiim import PlayingStatus, WiimDevice
13from wiim.exceptions import WiimException
14from wiim.models import WiimGroupRole
15
16from music_assistant.constants import (
17 EXTERNAL_PAUSE_IDLE_TIMEOUT,
18 create_sample_rates_config_entry,
19)
20from music_assistant.helpers.upnp import create_didl_metadata
21from music_assistant.models.player import Player, PlayerMedia
22
23from .constants import (
24 BACKEND_OFFICIAL,
25 INPUT_MODE_SOURCES,
26 PASSIVE_SOURCES,
27 PLAYER_ID_PREFIX,
28 SOURCE_AIRPLAY,
29 SOURCE_ID_TO_INPUT_MODE,
30 SOURCE_NETWORK,
31 SOURCE_SPOTIFY,
32 SOURCE_UNKNOWN,
33)
34from .helpers import is_in_mixed_group
35
36if TYPE_CHECKING:
37 from async_upnp_client.client import UpnpService, UpnpStateVariable
38 from music_assistant_models.config_entries import ConfigEntry
39
40 from .provider import WiimProvider
41
42SDK_TO_MA_STATE: dict[PlayingStatus, PlaybackState] = {
43 PlayingStatus.PLAYING: PlaybackState.PLAYING,
44 PlayingStatus.PAUSED: PlaybackState.PAUSED,
45 PlayingStatus.STOPPED: PlaybackState.IDLE,
46 PlayingStatus.LOADING: PlaybackState.PLAYING,
47}
48
49
50class WiimPlayer(Player):
51 """Wiim Player in Music Assistant."""
52
53 linkplay_backend = BACKEND_OFFICIAL
54
55 # the device reports a source the app released as plain 'paused', indistinguishable
56 # from a real pause, so only the grace period can tell us the session is over.
57 _attr_external_pause_idle_timeout = EXTERNAL_PAUSE_IDLE_TIMEOUT
58
59 def __init__(
60 self,
61 provider: WiimProvider,
62 player_id: str,
63 device: WiimDevice,
64 mac_address: str | None = None,
65 ) -> None:
66 """Initialize the Player."""
67 super().__init__(provider, player_id)
68
69 self._attr_name = device.name
70 self._attr_type = PlayerType.PLAYER
71 self._attr_supported_features = {
72 PlayerFeature.PLAY_MEDIA,
73 PlayerFeature.ENQUEUE,
74 PlayerFeature.GAPLESS_PLAYBACK,
75 PlayerFeature.SEEK,
76 PlayerFeature.VOLUME_SET,
77 PlayerFeature.VOLUME_MUTE,
78 PlayerFeature.PAUSE,
79 PlayerFeature.SET_MEMBERS,
80 PlayerFeature.SELECT_SOURCE,
81 }
82 self.device = device
83 self._wiim_controller = provider.wiim_controller
84
85 self._attr_device_info = DeviceInfo(
86 model=device.model_name,
87 manufacturer=device.manufacturer or "WiiM",
88 software_version=device.firmware_version,
89 )
90 self._attr_device_info.add_identifier(IdentifierType.UUID, device.udn.removeprefix("uuid:"))
91 if device.ip_address:
92 self._attr_device_info.add_identifier(IdentifierType.IP_ADDRESS, device.ip_address)
93 if mac_address:
94 self._attr_device_info.add_identifier(IdentifierType.MAC_ADDRESS, mac_address)
95
96 device.general_event_callback = self._handle_sdk_general_device_update
97 device.rendering_control_event_callback = self._handle_sdk_rendering_control_event
98 device.av_transport_event_callback = self._handle_sdk_av_transport_event
99 device.play_queue_event_callback = self._handle_sdk_play_queue_event
100
101 self._last_logged_sdk_uri: str | None = None
102 self._last_logged_sdk_status: PlayingStatus | None = None
103 self._ma_stream_uri: str | None = None
104
105 @property
106 def supported_features(self) -> set[PlayerFeature]:
107 """Return supported features; grouping is withdrawn in a read-only mixed group."""
108 if self._in_mixed_group:
109 return self._attr_supported_features - {PlayerFeature.SET_MEMBERS}
110 return self._attr_supported_features
111
112 @property
113 def grouping_locked(self) -> bool:
114 """Suppress grouping while in a read-only externally-created mixed group."""
115 return self._in_mixed_group
116
117 @property
118 def can_group_with(self) -> set[str]:
119 """Return the ids of the other official WiiM players this player can group with."""
120 if self._in_mixed_group:
121 return set()
122 return {
123 player.player_id
124 for player in self.provider.players
125 if isinstance(player, WiimPlayer)
126 and player.available
127 and player.player_id != self.player_id
128 and not player._in_mixed_group
129 }
130
131 def is_native_group_compatible(self, other: Player) -> bool:
132 """Only other official WiiM players group natively (never a generic LinkPlay shell)."""
133 return other.player_id in self.can_group_with
134
135 # --- Lifecycle ---
136
137 async def setup(self) -> None:
138 """Handle logic when the player is set up in the Player controller."""
139 for mode_name in self.device.supported_input_modes:
140 if mode_name in INPUT_MODE_SOURCES and mode_name != SOURCE_NETWORK:
141 self._attr_source_list.append(INPUT_MODE_SOURCES[mode_name])
142 self._attr_source_list.append(PASSIVE_SOURCES[SOURCE_AIRPLAY])
143 self._attr_source_list.append(PASSIVE_SOURCES[SOURCE_SPOTIFY])
144 self._attr_source_list.append(PASSIVE_SOURCES[SOURCE_UNKNOWN])
145 self._attr_needs_poll = True
146 self._attr_poll_interval = 5
147
148 async def poll(self) -> None:
149 """Poll player for transport state and position updates."""
150 await self._sync_position()
151 await self._refresh_device_status()
152 self._attr_poll_interval = 5 if self._attr_playback_state == PlaybackState.PLAYING else 30
153
154 async def on_unload(self) -> None:
155 """Handle logic when the player is unloaded from the Player controller."""
156 await super().on_unload()
157 self.device.general_event_callback = None
158 self.device.av_transport_event_callback = None
159 self.device.rendering_control_event_callback = None
160 self.device.play_queue_event_callback = None
161 try:
162 await self._wiim_controller.remove_device(self.device.udn)
163 await self.device.disconnect()
164 except Exception:
165 self.logger.exception("Error tearing down WiiM device %s", self.name)
166 self.logger.debug("Player %s unloaded, SDK resources released", self.name)
167
168 async def get_config_entries(self) -> list[ConfigEntry]:
169 """Return player-specific config entries."""
170 return [
171 create_sample_rates_config_entry(
172 max_sample_rate=192000,
173 safe_max_sample_rate=192000,
174 max_bit_depth=24,
175 safe_max_bit_depth=24,
176 ),
177 ]
178
179 # --- Player commands ---
180
181 async def play_media(self, media: PlayerMedia) -> None:
182 """Play media command."""
183 stream_url = await self.mass.streams.resolve_stream_url(self.player_id, media)
184 didl_metadata = create_didl_metadata(media, url=stream_url)
185 self.set_current_media(
186 uri=stream_url,
187 title=media.title,
188 artist=media.artist,
189 album=media.album,
190 image_url=media.image_url,
191 duration=media.duration,
192 source_id=media.source_id,
193 clear_all=True,
194 )
195 self._attr_elapsed_time = 0
196 self._attr_elapsed_time_last_updated = time.time()
197 self._ma_stream_uri = stream_url
198 try:
199 await self.device.async_play(uri=stream_url, metadata=didl_metadata)
200 except WiimException as err:
201 # The device never took our stream, so the guard must not outlive the attempt.
202 self._ma_stream_uri = None
203 self._handle_command_error("play_media", err)
204 return
205 self._update_ma_state_from_sdk_cache()
206
207 async def enqueue_next_media(self, media: PlayerMedia) -> None:
208 """Handle enqueuing of the next queue item on the player."""
209 stream_url = await self.mass.streams.resolve_stream_url(self.player_id, media)
210 didl_metadata = create_didl_metadata(media, url=stream_url)
211 self.logger.debug(
212 "enqueue_next_media on %s: queue_item_id=%s, uri=%s",
213 self._attr_name,
214 media.queue_item_id,
215 stream_url,
216 )
217 try:
218 await self.device._invoke_upnp_action(
219 "AVTransport",
220 "SetNextAVTransportURI",
221 NextURI=stream_url,
222 NextURIMetaData=didl_metadata,
223 )
224 except WiimException as err:
225 self.logger.warning("Enqueue failed on %s: %s", self._attr_name, err)
226
227 async def play(self) -> None:
228 """Play command."""
229 try:
230 await self.device.async_play()
231 except WiimException as err:
232 self._handle_command_error("play", err)
233 return
234 await self._sync_position()
235
236 async def pause(self) -> None:
237 """Pause command."""
238 try:
239 await self.device.async_pause()
240 except WiimException as err:
241 self._handle_command_error("pause", err)
242 return
243 await self._sync_position()
244
245 async def stop(self) -> None:
246 """Stop command."""
247 self._attr_active_source = None
248 self._attr_current_media = None
249 self._ma_stream_uri = None
250 try:
251 await self.device.async_stop()
252 except WiimException as err:
253 self._handle_command_error("stop", err)
254 return
255 self._update_ma_state_from_sdk_cache()
256
257 async def seek(self, position: int) -> None:
258 """Seek to position in seconds."""
259 try:
260 await self.device.async_seek(position)
261 except WiimException as err:
262 self._handle_command_error("seek", err)
263
264 async def volume_set(self, volume_level: int) -> None:
265 """Handle VOLUME_SET command on the player."""
266 try:
267 await self.device.async_set_volume(volume_level)
268 except WiimException as err:
269 self._handle_command_error("volume_set", err)
270 return
271 self._update_ma_state_from_sdk_cache()
272
273 async def volume_mute(self, muted: bool) -> None:
274 """Handle VOLUME MUTE command on the player."""
275 try:
276 await self.device.async_set_mute(muted)
277 except WiimException as err:
278 self._handle_command_error("volume_mute", err)
279 return
280 self._update_ma_state_from_sdk_cache()
281
282 async def select_source(self, source: str) -> None:
283 """
284 Handle SELECT SOURCE command on the player.
285
286 :param source: The source(id) to select, as defined in the source_list.
287 """
288 sdk_mode = SOURCE_ID_TO_INPUT_MODE.get(source)
289 if not sdk_mode:
290 self.logger.warning("Unknown source '%s' for %s", source, self.display_name)
291 return
292 try:
293 await self.device.async_set_play_mode(sdk_mode)
294 except WiimException as err:
295 self._handle_command_error("select_source", err)
296 return
297 self._update_ma_state_from_sdk_cache()
298
299 async def set_members(
300 self,
301 player_ids_to_add: list[str] | None = None,
302 player_ids_to_remove: list[str] | None = None,
303 ) -> None:
304 """Handle SET_MEMBERS command on the player."""
305 if self._in_mixed_group:
306 raise UnsupportedFeaturedException(
307 f"{self._attr_name} is in an externally-created mixed group and cannot be regrouped"
308 )
309 add_ids = player_ids_to_add or []
310 remove_ids = player_ids_to_remove or []
311 if len(set(add_ids)) != len(add_ids) or len(set(remove_ids)) != len(remove_ids):
312 raise PlayerCommandFailed(f"Duplicate member id in grouping request on {self.name}")
313 if conflicting := set(add_ids) & set(remove_ids):
314 raise PlayerCommandFailed(
315 f"Cannot add and remove the same member on {self.name}: {conflicting}"
316 )
317 # Resolve and validate the whole batch before any hardware call, so a stale, moved,
318 # mixed or cross-backend target can never leave the group partially mutated.
319 to_add = [self._require_wiim_member(mid) for mid in add_ids]
320 to_remove = [self._require_wiim_member(mid) for mid in remove_ids]
321 if to_remove:
322 # Only ungroup members this leader still owns per the freshest SDK snapshot: a
323 # target that has since moved to another (possibly read-only mixed) group must
324 # not be torn out of it.
325 current_member_udns = set(
326 self._wiim_controller.get_group_snapshot(self.device.udn).member_udns
327 )
328 for member in to_remove:
329 if member.device.udn not in current_member_udns:
330 raise PlayerCommandFailed(
331 f"{member.player_id} is no longer a member of {self.name}"
332 )
333 queue = self.mass.player_queues.get(self.player_id)
334 pq_data = self.mass.player_queues.queue_data_or_none(self.player_id) if queue else None
335 entry_sdk_uri = self.device.current_media.uri if self.device.current_media else None
336 self.logger.debug(
337 "set_members entry on %s: add=%s, remove=%s | "
338 "queue.current_item_id=%s, queue.next_item_id=%s, "
339 "queue.next_item_id_enqueued=%s | "
340 "sdk.current_uri=%s, sdk.playing_status=%s",
341 self._attr_name,
342 player_ids_to_add,
343 player_ids_to_remove,
344 queue.current_item.queue_item_id if queue and queue.current_item else None,
345 queue.next_item.queue_item_id if queue and queue.next_item else None,
346 pq_data.next_item_id_enqueued if pq_data else None,
347 entry_sdk_uri,
348 self.device.playing_status,
349 )
350 try:
351 if to_add:
352 await self._wiim_controller.async_join_group(
353 self.device.udn, [member.device.udn for member in to_add]
354 )
355 for member in to_remove:
356 await self._wiim_controller.async_ungroup_device(member.device.udn)
357 except WiimException as err:
358 # Log and refresh state as for any command, but surface grouping failures to the
359 # caller: a swallowed error would report a join/leave as succeeded.
360 self._handle_command_error("set_members", err)
361 raise PlayerCommandFailed(f"set_members failed on {self.name}: {err}") from err
362 finally:
363 exit_sdk_uri = self.device.current_media.uri if self.device.current_media else None
364 self.logger.debug(
365 "set_members exit on %s: sdk.current_uri=%s, sdk.playing_status=%s, "
366 "queue.current_item_id=%s",
367 self._attr_name,
368 exit_sdk_uri,
369 self.device.playing_status,
370 queue.current_item.queue_item_id if queue and queue.current_item else None,
371 )
372
373 # --- SDK event handlers ---
374
375 def _handle_sdk_general_device_update(self, device: WiimDevice) -> None:
376 """Handle general updates from the SDK (availability changes)."""
377 if not device.available:
378 self.logger.debug("Device %s became unavailable", self._attr_name)
379 self._update_ma_state_from_sdk_cache()
380 return
381 if device.supports_http_api:
382 self.logger.debug("Device %s available, ensuring subscriptions", self._attr_name)
383 self.mass.create_task(self._ensure_subscriptions_and_update())
384 else:
385 self._update_ma_state_from_sdk_cache()
386
387 def _handle_sdk_av_transport_event(
388 self, service: UpnpService, state_variables: list[UpnpStateVariable[typing.Any]]
389 ) -> None:
390 """Handle AVTransport events from the SDK."""
391 event_data = self.device.event_data
392 if transport_state := event_data.get("TransportState"):
393 try:
394 sdk_status = PlayingStatus(transport_state)
395 except ValueError:
396 pass
397 else:
398 if sdk_status in (
399 PlayingStatus.PLAYING,
400 PlayingStatus.PAUSED,
401 PlayingStatus.LOADING,
402 ):
403 self.mass.create_task(self._sync_position())
404 self._update_ma_state_from_sdk_cache()
405
406 def _handle_sdk_rendering_control_event(
407 self, service: UpnpService, state_variables: list[UpnpStateVariable[typing.Any]]
408 ) -> None:
409 """Handle RenderingControl events from the SDK."""
410 self._update_ma_state_from_sdk_cache()
411 for sv in state_variables:
412 if sv.name == "LastChange" and sv.value and "Slave" in str(sv.value):
413 self.mass.create_task(self._refresh_multiroom())
414 break
415
416 def _handle_sdk_play_queue_event(
417 self, service: UpnpService, state_variables: list[UpnpStateVariable[typing.Any]]
418 ) -> None:
419 """Handle PlayQueue events from the SDK."""
420 self._update_ma_state_from_sdk_cache()
421
422 # --- Private helpers ---
423
424 def _update_ma_state_from_sdk_cache(self) -> None:
425 """Update MA state from SDK's cache/HTTP poll attributes."""
426 self._attr_available = self.device.available
427 if self.device.name != self._attr_name:
428 self._attr_name = self.device.name
429
430 if not self._attr_available:
431 self._attr_current_media = None
432 self._attr_active_source = None
433 self.update_state()
434 return
435
436 self._attr_volume_level = self.device.volume
437 self._attr_volume_muted = self.device.is_muted
438
439 # Followers have their state derived by MA from the leader.
440 snapshot = self._wiim_controller.get_group_snapshot(self.device.udn)
441 if snapshot.role == WiimGroupRole.FOLLOWER:
442 self.update_state()
443 return
444
445 media = self.device.current_media
446 device_uri = media.uri if media and media.uri else ""
447 play_mode = self.device.play_mode
448
449 # Playback state
450 if (new_state := self._resolve_playback_state(device_uri, play_mode)) is not None:
451 self._attr_playback_state = new_state
452
453 # Group members
454 self._update_group_state(snapshot.member_udns)
455
456 if play_mode and play_mode != SOURCE_NETWORK and play_mode in INPUT_MODE_SOURCES:
457 self._attr_active_source = INPUT_MODE_SOURCES[play_mode].id
458 elif play_mode == SOURCE_NETWORK:
459 ma_queue = self.mass.player_queues.get(self.player_id)
460 assert ma_queue is not None
461 if device_uri == "wiimu_airplay":
462 self._attr_active_source = SOURCE_AIRPLAY
463 elif device_uri.startswith("spotify:"):
464 self._attr_active_source = SOURCE_SPOTIFY
465 elif ma_queue.current_item:
466 self._attr_active_source = self.player_id
467 else:
468 self._attr_active_source = SOURCE_UNKNOWN
469 else:
470 self._attr_active_source = None
471
472 source_display_name: str | None = None
473 if play_mode and play_mode in INPUT_MODE_SOURCES:
474 source_display_name = INPUT_MODE_SOURCES[play_mode].name
475 elif self._attr_active_source in PASSIVE_SOURCES:
476 source_display_name = PASSIVE_SOURCES[self._attr_active_source].name
477
478 is_ma_source = self._attr_active_source == self.player_id
479 sdk_has_metadata = bool(media and (media.title or media.artist or media.album))
480
481 if self._attr_active_source is None:
482 self._attr_current_media = None
483 elif is_ma_source and not sdk_has_metadata:
484 # Keep the metadata play_media set until the SDK catches up.
485 pass
486 else:
487 self._attr_current_media = PlayerMedia(
488 uri=(media.uri if media else None) or "",
489 title=(media.title if media else None) or source_display_name,
490 artist=media.artist if media else None,
491 album=media.album if media else None,
492 image_url=media.image_url if media else None,
493 duration=media.duration if media else None,
494 source_id=self._attr_active_source,
495 )
496
497 self._log_sdk_state_change()
498 self.update_state()
499
500 def _resolve_playback_state(
501 self, device_uri: str, play_mode: str | None
502 ) -> PlaybackState | None:
503 """
504 Map the SDK playing status to a MA playback state.
505
506 Returns ``None`` when the current state should be kept: either no status
507 is known yet, or the report is implausible and should not propagate.
508
509 :param device_uri: The media URI currently loaded on the device ("" if none).
510 :param play_mode: The device's current play mode (input source).
511 """
512 # widened annotation: the SDK types playing_status as non-optional, but
513 # we stay tolerant of a None from mocks/edge paths (as the code always has)
514 sdk_status: PlayingStatus | None = self.device.playing_status
515 if sdk_status is None:
516 return None
517 new_state = SDK_TO_MA_STATE.get(sdk_status, PlaybackState.IDLE)
518 # The device acks transport commands with a transient (false) PLAYING
519 # report while no media is loaded yet (observed during group session
520 # setup). Nothing can actually play in network mode without a URI, so
521 # keep the previous state instead of propagating the false start and
522 # the PLAYING->IDLE->PLAYING flicker it causes downstream. External
523 # inputs (line-in, Bluetooth, ...) legitimately play without a URI and
524 # are not affected.
525 if new_state == PlaybackState.PLAYING and play_mode == SOURCE_NETWORK and not device_uri:
526 return None
527 return new_state
528
529 def _log_sdk_state_change(self) -> None:
530 """Log a debug line whenever the SDK-reported URI or playing_status changes."""
531 new_sdk_uri = self.device.current_media.uri if self.device.current_media else None
532 new_sdk_status = self.device.playing_status
533 if (
534 new_sdk_uri == self._last_logged_sdk_uri
535 and new_sdk_status == self._last_logged_sdk_status
536 ):
537 return
538 ma_queue = self.mass.player_queues.get(self.player_id)
539 self.logger.debug(
540 "WiiM state changed on %s: uri=%r->%r, status=%s->%s | queue.current_item_id=%s",
541 self._attr_name,
542 self._last_logged_sdk_uri,
543 new_sdk_uri,
544 self._last_logged_sdk_status,
545 new_sdk_status,
546 ma_queue.current_item.queue_item_id if ma_queue and ma_queue.current_item else None,
547 )
548 self._last_logged_sdk_uri = new_sdk_uri
549 self._last_logged_sdk_status = new_sdk_status
550
551 async def _ensure_subscriptions_and_update(self) -> None:
552 """Re-subscribe to UPnP events and update state."""
553 try:
554 await self.device.ensure_subscriptions()
555 except WiimException as err:
556 self.logger.warning("Failed to re-subscribe for %s: %s", self._attr_name, err)
557 self._update_ma_state_from_sdk_cache()
558
559 async def _refresh_device_status(self) -> None:
560 """Fetch fresh transport state, play mode and volume from the device."""
561 if not self.device.available:
562 # the SDK owns availability detection and recovery; querying a device it
563 # already gave up on only holds up the poll of every other player.
564 return
565 try:
566 await self.device.async_update_http_status()
567 except WiimException as err:
568 self.logger.debug("Failed to refresh status for %s: %s", self._attr_name, err)
569 return
570 self._update_ma_state_from_sdk_cache()
571
572 async def _sync_position(self) -> None:
573 """Fetch fresh position from the device and update state."""
574 try:
575 await self.device.sync_device_duration_and_position()
576 except WiimException as err:
577 self.logger.debug("Failed to sync position for %s: %s", self._attr_name, err)
578 return
579 media = self.device.current_media
580 device_uri = media.uri if media else None
581 if device_uri and device_uri == self._ma_stream_uri:
582 self._ma_stream_uri = None
583 # The device reports its previous content's position until it loads our stream URI.
584 if self._ma_stream_uri is None and media is not None and media.position is not None:
585 self._attr_elapsed_time = media.position
586 self._attr_elapsed_time_last_updated = time.time()
587 self._update_ma_state_from_sdk_cache()
588
589 async def _refresh_multiroom(self) -> None:
590 """Refresh multiroom status from devices, then update all WiiM players."""
591 try:
592 await self._wiim_controller.async_update_all_multiroom_status()
593 except WiimException as err:
594 self.logger.debug("Failed to refresh multiroom status: %s", err)
595 for player in self.provider.players:
596 if isinstance(player, WiimPlayer):
597 player._update_ma_state_from_sdk_cache()
598
599 def _handle_command_error(self, action: str, err: WiimException) -> None:
600 """Handle a command error by logging and refreshing state."""
601 self.logger.warning("Command '%s' failed on %s: %s", action, self._attr_name, err)
602 self._update_ma_state_from_sdk_cache()
603
604 def _update_group_state(self, member_udns: tuple[str, ...]) -> None:
605 """Map the SDK's group snapshot to the managed group members."""
606 managed_member_ids = [
607 f"{PLAYER_ID_PREFIX}{member_udn}"
608 for member_udn in member_udns
609 if member_udn != self.device.udn
610 and self.mass.players.get_player(f"{PLAYER_ID_PREFIX}{member_udn}")
611 ]
612 self._attr_group_members = (
613 [self.player_id, *managed_member_ids] if managed_member_ids else []
614 )
615
616 @property
617 def _in_mixed_group(self) -> bool:
618 """Whether this device is in an externally-created mixed (cross-backend) group."""
619 return is_in_mixed_group(self)
620
621 def _require_wiim_member(self, player_id: str) -> WiimPlayer:
622 """
623 Return a same-backend, groupable member for a grouping command.
624
625 A request targeting this player itself, an unknown player, another backend
626 (e.g. a generic LinkPlay shell) or a device locked in an externally-created
627 mixed group is rejected with a typed error rather than applied.
628
629 :param player_id: The id of the member to resolve and validate.
630 """
631 if player_id == self.player_id:
632 raise UnsupportedFeaturedException(f"Cannot group {self.name} with itself")
633 member = self.mass.players.get_player(player_id)
634 if member is None:
635 raise PlayerCommandFailed(
636 f"{player_id} is not a known player for grouping on {self.name}"
637 )
638 if not isinstance(member, WiimPlayer):
639 raise UnsupportedFeaturedException(
640 f"Cannot group {player_id} with {self.name}: cross-backend grouping is unsupported"
641 )
642 if member._in_mixed_group:
643 raise PlayerCommandFailed(
644 f"{player_id} is in an externally-created mixed group and cannot be regrouped"
645 )
646 return member
647