/
/
/
1"""MusicCastPlayer."""
2
3import asyncio
4import time
5from collections.abc import Callable, Coroutine
6from dataclasses import dataclass
7from typing import TYPE_CHECKING, Any, cast
8
9from aiohttp.client_exceptions import ClientError
10from aiomusiccast.exceptions import MusicCastGroupException
11from aiomusiccast.pyamaha import MusicCastConnectionException
12from music_assistant_models.config_entries import ConfigEntry, ConfigValueOption, ConfigValueType
13from music_assistant_models.enums import ConfigEntryType, PlaybackState, PlayerFeature
14from music_assistant_models.player import DeviceInfo, PlayerMedia, PlayerSource
15from propcache import under_cached_property as cached_property
16
17from music_assistant.models.player import Player
18from music_assistant.providers.musiccast.avt_helpers import (
19 avt_get_media_info,
20 avt_next,
21 avt_play,
22 avt_previous,
23 avt_set_url,
24 avt_stop,
25 search_xml,
26)
27from music_assistant.providers.musiccast.constants import (
28 CONF_PLAYER_HANDLE_SOURCE_DISABLED,
29 CONF_PLAYER_SWITCH_SOURCE_NON_NET,
30 CONF_PLAYER_TURN_OFF_ON_LEAVE,
31 MC_CONTROL_SOURCE_IDS,
32 MC_NETUSB_SOURCE_IDS,
33 MC_PASSIVE_SOURCE_IDS,
34 MC_POLL_INTERVAL,
35 MC_SOURCE_MAIN_SYNC,
36 MC_SOURCE_MC_LINK,
37 PLAYER_CONFIG_ENTRIES,
38 PLAYER_ZONE_SPLITTER,
39)
40from music_assistant.providers.musiccast.musiccast import (
41 MusicCastPhysicalDevice,
42 MusicCastPlayerState,
43 MusicCastZoneDevice,
44)
45
46if TYPE_CHECKING:
47 from .provider import MusicCastProvider
48
49
50@dataclass(kw_only=True)
51class UpnpUpdateHelper:
52 """UpnpUpdateHelper.
53
54 See _update_player_attributes.
55 """
56
57 last_poll: float # time.time
58 controlled_by_mass: bool
59 current_uri: str | None
60
61
62class MusicCastPlayer(Player):
63 """MusicCastPlayer in Music Assistant."""
64
65 def __init__(
66 self,
67 provider: "MusicCastProvider",
68 player_id: str,
69 physical_device: MusicCastPhysicalDevice,
70 zone_device: MusicCastZoneDevice,
71 ) -> None:
72 """Init MC Player.
73
74 Keep reference to physical and zone device.
75 """
76 super().__init__(provider, player_id)
77 self.physical_device = physical_device
78 self.zone_device = zone_device
79
80 # make this a property and update during normal state updates?
81 # refers to being controlled by upnp.
82 self.update_lock = asyncio.Lock()
83 self.upnp_update_helper: UpnpUpdateHelper | None = None
84
85 async def setup(self) -> None:
86 """Set up player in Music Assistant."""
87 self.set_static_attributes()
88
89 def set_static_attributes(self) -> None:
90 """Set static properties."""
91 self._attr_supported_features = {
92 PlayerFeature.VOLUME_SET,
93 PlayerFeature.VOLUME_MUTE,
94 PlayerFeature.PAUSE, # for non MA control, see pause method
95 PlayerFeature.POWER,
96 PlayerFeature.SELECT_SOURCE,
97 PlayerFeature.SET_MEMBERS,
98 PlayerFeature.NEXT_PREVIOUS,
99 PlayerFeature.ENQUEUE,
100 PlayerFeature.GAPLESS_PLAYBACK,
101 }
102
103 self._attr_device_info = DeviceInfo(
104 manufacturer="Yamaha Corporation",
105 model=self.physical_device.device.data.model_name or "unknown model",
106 software_version=(self.physical_device.device.data.system_version or "unknown version"),
107 )
108
109 # polling
110 self._attr_needs_poll = True
111 self._attr_poll_interval = MC_POLL_INTERVAL
112
113 # default MC name
114 if self.zone_device.zone_data is not None:
115 self._attr_name = self.zone_device.zone_data.name
116
117 # group
118 self._attr_can_group_with = {self.provider.instance_id}
119
120 self._attr_available = True
121
122 # SOURCES
123 for source_id, source_name in self.zone_device.source_mapping.items():
124 control = source_id in MC_CONTROL_SOURCE_IDS
125 passive = source_id in MC_PASSIVE_SOURCE_IDS
126 self._attr_source_list.append(
127 PlayerSource(
128 id=source_id,
129 name=source_name,
130 passive=passive,
131 can_play_pause=control,
132 can_seek=False,
133 can_next_previous=control,
134 )
135 )
136
137 async def set_dynamic_attributes(self) -> None:
138 """Update Player attributes."""
139 # ruff: noqa: PLR0915
140 self._attr_available = True
141
142 zone_data = self.zone_device.zone_data
143 if zone_data is None:
144 return
145
146 self._attr_powered = zone_data.power == "on"
147
148 # NOTE: aiomusiccast does not type hint the volume variables, and they may
149 # be none, and not only integers
150 _current_volume = cast("int | None", zone_data.current_volume)
151 _max_volume = cast("int | None", zone_data.max_volume)
152 _min_volume = cast("int | None", zone_data.min_volume)
153 if _current_volume is None:
154 self._attr_volume_level = None
155 else:
156 _min_volume = 0 if _min_volume is None else _min_volume
157 _max_volume = 100 if _max_volume is None else _max_volume
158 if _min_volume == _max_volume:
159 _max_volume += 1
160 self._attr_volume_level = int(_current_volume / (_max_volume - _min_volume) * 100)
161 self._attr_volume_muted = zone_data.mute
162
163 # STATE
164
165 match self.zone_device.state:
166 case MusicCastPlayerState.PAUSED:
167 self._attr_playback_state = PlaybackState.PAUSED
168 case MusicCastPlayerState.PLAYING:
169 self._attr_playback_state = PlaybackState.PLAYING
170 case MusicCastPlayerState.IDLE | MusicCastPlayerState.OFF:
171 self._attr_playback_state = PlaybackState.IDLE
172 self._attr_elapsed_time = self.zone_device.media_position
173 if self.zone_device.media_position_updated_at is not None:
174 self._attr_elapsed_time_last_updated = (
175 self.zone_device.media_position_updated_at.timestamp()
176 )
177 else:
178 self._attr_elapsed_time_last_updated = None
179
180 # UPDATE UPNP HELPER
181 now = time.time()
182 if self.upnp_update_helper is None or now - self.upnp_update_helper.last_poll > 5:
183 # Let's not do this too often
184 # Note: The devices always return the last UPnP xmls, even if
185 # currently another source/ playback method is used
186 try:
187 _xml_media_info = await avt_get_media_info(
188 self.mass.http_session, self.physical_device
189 )
190 except ClientError:
191 # this is regularly called, we can ignore a failing update
192 self.logger.debug("Acquiring media info failed, trying again in 5s.")
193 if self.upnp_update_helper is not None:
194 self.upnp_update_helper.last_poll = now
195 return
196 _player_current_url = search_xml(_xml_media_info, "CurrentURI")
197
198 # controlled by mass is only True, if we are directly controlled
199 # i.e. we are not a group member.
200 # the device's source id is server, if controlled by upnp, but also, if the internal
201 # dlna function of the device are used. As a fallback, we then
202 # use the item's title. This can only fail, if our current and next item
203 # has the same name as the external.
204 controlled_by_mass = False
205 if _player_current_url is not None:
206 controlled_by_mass = (
207 self.player_id in _player_current_url
208 and self.mass.streams.base_url in _player_current_url
209 and self.zone_device.source_id == "server"
210 )
211
212 self.upnp_update_helper = UpnpUpdateHelper(
213 last_poll=now,
214 controlled_by_mass=controlled_by_mass,
215 current_uri=_player_current_url,
216 )
217
218 # UPDATE PLAYBACK INFORMATION
219 # Note to self:
220 # player._current_media tells queue controller what is playing
221 # and player.set_current_media is the helper function
222 # do not access the queue controller to gain playback information here
223 if (
224 self.upnp_update_helper.current_uri is not None
225 and self.upnp_update_helper.controlled_by_mass
226 ):
227 self.set_current_media(uri=self.upnp_update_helper.current_uri, clear_all=True)
228 elif self.zone_device.is_client:
229 _server = self.zone_device.group_server
230 _server_id = self._get_player_id_from_zone_device(_server)
231 _server_player = cast("MusicCastPlayer | None", self.mass.players.get(_server_id))
232 _server_update_helper: None | UpnpUpdateHelper = None
233 if _server_player is not None:
234 _server_update_helper = _server_player.upnp_update_helper
235 if (
236 _server_update_helper is not None
237 and _server_update_helper.current_uri is not None
238 and _server_update_helper.controlled_by_mass
239 ):
240 self.set_current_media(uri=_server_update_helper.current_uri, clear_all=True)
241 else:
242 self.set_current_media(
243 uri=f"{_server_id}_{_server.source_id}",
244 title=_server.media_title,
245 artist=_server.media_artist,
246 album=_server.media_album_name,
247 image_url=_server.media_image_url,
248 )
249 else:
250 self.set_current_media(
251 uri=f"{self.player_id}_{self.zone_device.source_id}",
252 title=self.zone_device.media_title,
253 artist=self.zone_device.media_artist,
254 album=self.zone_device.media_album_name,
255 image_url=self.zone_device.media_image_url,
256 )
257
258 # SOURCE
259 self._attr_active_source = self.player_id
260 if not self.zone_device.is_client and not self.upnp_update_helper.controlled_by_mass:
261 self._attr_active_source = self.zone_device.source_id
262 elif self.zone_device.is_client:
263 _server = self.zone_device.group_server
264 _server_id = self._get_player_id_from_zone_device(_server)
265 _server_player = cast("MusicCastPlayer | None", self.mass.players.get(_server_id))
266 if _server_player is not None and _server_player.upnp_update_helper is not None:
267 self._attr_active_source = (
268 self.zone_device.source_id
269 if not _server_player.upnp_update_helper.controlled_by_mass
270 else None
271 )
272
273 # GROUPING
274 # A zone cannot be synced to another zone or main of the same device.
275 # Additionally, a zone can only be synced, if main is currently not using any netusb
276 # function.
277 # For a Zone which will be synced to main, grouping emits a "main_sync" instead
278 # of a mc link. The other way round, we log a warning.
279 if len(self.zone_device.musiccast_group) == 1:
280 if self.zone_device.musiccast_group[0] == self.zone_device:
281 # we are in a group with ourselves.
282 self._attr_group_members.clear()
283
284 elif not self.zone_device.is_client and not self.zone_device.is_server:
285 self._attr_group_members.clear()
286
287 elif self.zone_device.is_client:
288 _synced_to_id = self._get_player_id_from_zone_device(self.zone_device.group_server)
289 self._attr_group_members.clear()
290
291 elif self.zone_device.is_server:
292 self._attr_group_members = [
293 self._get_player_id_from_zone_device(x) for x in self.zone_device.musiccast_group
294 ]
295
296 self.update_state()
297
298 @cached_property
299 def synced_to(self) -> str | None:
300 """
301 Return the id of the player this player is synced to (sync leader).
302
303 If this player is not synced to another player (or is the sync leader itself),
304 this should return None.
305 If it is part of a (permanent) group, this should also return None.
306 """
307 if self.zone_device.is_network_client:
308 server_id = self._get_player_id_from_zone_device(self.zone_device.group_server)
309 return server_id if server_id != self.player_id else None
310 return None
311
312 async def _cmd_run(self, fun: Callable[..., Coroutine[Any, Any, None]], *args: Any) -> None:
313 """Help function for all player cmds."""
314 try:
315 await fun(*args)
316 except MusicCastConnectionException:
317 # should go to provider here.
318 await self._set_player_unavailable()
319 except MusicCastGroupException:
320 # can happen, user shall try again.
321 ...
322
323 async def _handle_zone_grouping(self, zone_player: MusicCastZoneDevice) -> None:
324 """Handle zone grouping.
325
326 If a device has multiple zones, only a single zone can be net controlled.
327 If another zone wants to join the group, the current net zone has to switch
328 its input to a non-net one and optionally turn off.
329
330 This methods targets another zone of this players physical device!
331 """
332 # this is not this player's id
333 player_id = self._get_player_id_from_zone_device(zone_player)
334 assert player_id is not None # for TYPE_CHECKING
335
336 # skip zone handling if disabled.
337 if bool(
338 await self.mass.config.get_player_config_value(
339 player_id, CONF_PLAYER_HANDLE_SOURCE_DISABLED
340 )
341 ):
342 return
343
344 _source = str(
345 await self.mass.config.get_player_config_value(
346 player_id, CONF_PLAYER_SWITCH_SOURCE_NON_NET
347 )
348 )
349 # verify that this source actually exists and is non net
350 _allowed_sources = self._get_allowed_sources_zone_switch(zone_player)
351 mass_player = self.mass.players.get(player_id)
352 if mass_player is None:
353 # Do not assert here, should the player not yet exist
354 return
355 if _source not in _allowed_sources:
356 msg = (
357 "The switch source you specified for "
358 f"{mass_player.display_name or mass_player.name}"
359 " is not allowed. "
360 f"The source must be any of: {', '.join(sorted(_allowed_sources))} "
361 "Will use the first available source."
362 )
363 self.logger.error(msg)
364 _source = _allowed_sources.pop()
365
366 await mass_player.select_source(_source)
367 _turn_off = bool(
368 await self.mass.config.get_player_config_value(player_id, CONF_PLAYER_TURN_OFF_ON_LEAVE)
369 )
370 if _turn_off:
371 await asyncio.sleep(2)
372 await mass_player.power(powered=False)
373
374 def _get_player_id_from_zone_device(self, zone_player: MusicCastZoneDevice) -> str:
375 device_id = zone_player.physical_device.device.data.device_id
376 assert device_id is not None
377 return f"{device_id}{PLAYER_ZONE_SPLITTER}{zone_player.zone_name}"
378
379 def _get_allowed_sources_zone_switch(self, zone_player: MusicCastZoneDevice) -> set[str]:
380 """Return non net sources for a zone player."""
381 assert zone_player.zone_data is not None, "zone data missing"
382 _input_sources: set[str] = set(zone_player.zone_data.input_list)
383 _net_sources = set(MC_NETUSB_SOURCE_IDS)
384 _net_sources.add(MC_SOURCE_MC_LINK) # mc grouping source
385 _net_sources.add(MC_SOURCE_MAIN_SYNC) # main zone sync
386 return _input_sources.difference(_net_sources)
387
388 async def _set_player_unavailable(self) -> None:
389 """Set this player and associated zone players unavailable.
390
391 Only called from a main zone player.
392 """
393 assert self.zone_device.zone_name == "main", "Call only from main player!"
394 self.logger.debug("Player %s became unavailable.", self.display_name)
395
396 if TYPE_CHECKING:
397 assert isinstance(self.provider, MusicCastProvider)
398
399 # disable polling
400 self.physical_device.remove()
401
402 async with self.update_lock:
403 self._attr_available = False
404 self.update_state()
405
406 # set other zone unavailable
407 for zone_device in self.zone_device.other_zones:
408 if zone_device_player := self.mass.players.get(
409 self._get_player_id_from_zone_device(zone_device)
410 ):
411 assert isinstance(zone_device_player, MusicCastPlayer) # for type checking
412 async with zone_device_player.update_lock:
413 zone_device_player._attr_available = False
414 zone_device_player.update_state()
415
416 async def poll(self) -> None:
417 """Poll player."""
418 if self.update_lock.locked():
419 # udp updates come in roughly every second when playing, so discard
420 return
421 if self.zone_device.zone_name != "main":
422 # we only poll main, which polls the whole device
423 return
424 async with self.update_lock:
425 # explicit polling on main
426 try:
427 await self.physical_device.fetch()
428 except (MusicCastConnectionException, MusicCastGroupException):
429 await self._set_player_unavailable()
430 return
431 except ClientError:
432 return
433 await self.set_dynamic_attributes()
434
435 def _non_async_udp_callback(self, physical_device: MusicCastPhysicalDevice) -> None:
436 """Call on UDP updates."""
437 self.mass.loop.create_task(self._async_udp_callback())
438
439 async def _async_udp_callback(self) -> None:
440 async with self.update_lock:
441 await self.set_dynamic_attributes()
442
443 async def power(self, powered: bool) -> None:
444 """Power command."""
445 if powered:
446 await self._cmd_run(self.zone_device.turn_on)
447 else:
448 await self._cmd_run(self.zone_device.turn_off)
449
450 async def volume_set(self, volume_level: int) -> None:
451 """Volume set command."""
452 await self._cmd_run(self.zone_device.volume_set, volume_level)
453
454 async def volume_mute(self, muted: bool) -> None:
455 """Volume mute command."""
456 await self._cmd_run(self.zone_device.volume_mute, muted)
457
458 async def play(self) -> None:
459 """Play command."""
460 if self.upnp_update_helper is not None and self.upnp_update_helper.controlled_by_mass:
461 await avt_play(self.mass.http_session, self.physical_device)
462 else:
463 await self._cmd_run(self.zone_device.play)
464
465 async def stop(self) -> None:
466 """Stop command."""
467 if self.upnp_update_helper is not None and self.upnp_update_helper.controlled_by_mass:
468 await avt_stop(self.mass.http_session, self.physical_device)
469 else:
470 await self._cmd_run(self.zone_device.stop)
471
472 async def pause(self) -> None:
473 """Pause command."""
474 if self.upnp_update_helper is not None and self.upnp_update_helper.controlled_by_mass:
475 # if we are controlled by MA, i.e. upnp, send a stop, since
476 # pause appears to be unreliable/ not working
477 await avt_stop(self.mass.http_session, self.physical_device)
478 else:
479 await self._cmd_run(self.zone_device.pause)
480
481 async def next_track(self) -> None:
482 """Next command."""
483 if self.upnp_update_helper is not None and self.upnp_update_helper.controlled_by_mass:
484 await avt_next(self.mass.http_session, self.physical_device)
485 else:
486 await self._cmd_run(self.zone_device.next_track)
487
488 async def previous_track(self) -> None:
489 """Previous command."""
490 if self.upnp_update_helper is not None and self.upnp_update_helper.controlled_by_mass:
491 await avt_previous(self.mass.http_session, self.physical_device)
492 else:
493 await self._cmd_run(self.zone_device.previous_track)
494
495 async def play_media(self, media: PlayerMedia) -> None:
496 """Play media command."""
497 if len(self.physical_device.zone_devices) > 1:
498 # zone handling
499 # only a single zone may have netusb capability
500 for zone_name, dev in self.physical_device.zone_devices.items():
501 if zone_name == self.zone_device.zone_name:
502 continue
503 if dev.is_netusb:
504 await self._handle_zone_grouping(dev)
505 async with self.update_lock:
506 # just in case
507 if self.zone_device.source_id != "server":
508 await self.select_source("server")
509 await avt_set_url(self.mass.http_session, self.physical_device, player_media=media)
510 await avt_play(self.mass.http_session, self.physical_device)
511
512 self.upnp_update_helper = UpnpUpdateHelper(
513 last_poll=time.time(),
514 controlled_by_mass=True,
515 current_uri=media.uri,
516 )
517
518 async def enqueue_next_media(self, media: PlayerMedia) -> None:
519 """Enqueue next command."""
520 await avt_set_url(
521 self.mass.http_session,
522 self.physical_device,
523 player_media=media,
524 enqueue=True,
525 )
526
527 async def select_source(self, source: str) -> None:
528 """Select source command."""
529 await self._cmd_run(self.zone_device.select_source, source)
530
531 async def ungroup(self) -> None:
532 """Ungroup command."""
533 if self.zone_device.zone_name.startswith("zone"):
534 # We are are zone.
535 # We do not leave an MC group, but just change our source.
536 await self._handle_zone_grouping(self.zone_device)
537 return
538 await self._cmd_run(self.zone_device.unjoin_player)
539
540 async def set_members(
541 self,
542 player_ids_to_add: list[str] | None = None,
543 player_ids_to_remove: list[str] | None = None,
544 ) -> None:
545 """Set multiple members.
546
547 This function is called on the server.
548 """
549 # Removing players
550 if player_ids_to_remove:
551 for player_id in player_ids_to_remove:
552 if player := self.mass.players.get(player_id):
553 assert isinstance(player, MusicCastPlayer) # for type checking
554 await player.ungroup()
555
556 # Adding players
557 if not player_ids_to_add:
558 return
559 children: set[str] = set() # set[ma_player_id]
560 children_zones: list[str] = [] # list[ma_player_id]
561 player_ids_to_add = [] if player_ids_to_add is None else player_ids_to_add
562 for child_id in player_ids_to_add:
563 if child_player := self.mass.players.get(child_id):
564 assert isinstance(child_player, MusicCastPlayer) # for type checking
565 _other_zone_mc: MusicCastZoneDevice | None = None
566 for x in child_player.zone_device.other_zones:
567 if x.is_netusb:
568 _other_zone_mc = x
569 if _other_zone_mc and _other_zone_mc != child_player.zone_device:
570 # of the same device, we use main_sync as input
571 if _other_zone_mc.zone_name == "main":
572 children_zones.append(child_id)
573 else:
574 self.logger.warning(
575 "It is impossible to join as a normal zone to another zone of the same "
576 "device. Only joining to main is possible. Please refer to the docs."
577 )
578 else:
579 children.add(child_id)
580
581 for child_id in children_zones:
582 child_player = self.mass.players.get(child_id)
583 if TYPE_CHECKING:
584 child_player = cast("MusicCastPlayer", child_player)
585 if child_player.zone_device.state == MusicCastPlayerState.OFF:
586 await child_player.power(powered=True)
587 await child_player.select_source(MC_SOURCE_MAIN_SYNC)
588 if not children:
589 return
590
591 child_player_zone_devices: list[MusicCastZoneDevice] = []
592 for child_id in children:
593 child_player = self.mass.players.get(child_id)
594 if TYPE_CHECKING:
595 child_player = cast("MusicCastPlayer", child_player)
596 child_player_zone_devices.append(child_player.zone_device)
597
598 await self._cmd_run(self.zone_device.join_players, child_player_zone_devices)
599
600 async def get_config_entries(
601 self,
602 action: str | None = None,
603 values: dict[str, ConfigValueType] | None = None,
604 ) -> list[ConfigEntry]:
605 """Get player config entries."""
606 base_entries = await super().get_config_entries(action=action, values=values)
607
608 zone_entries: list[ConfigEntry] = []
609 if len(self.physical_device.zone_devices) > 1:
610 source_options: list[ConfigValueOption] = []
611 allowed_sources = self._get_allowed_sources_zone_switch(self.zone_device)
612 for (
613 source_id,
614 source_name,
615 ) in self.zone_device.source_mapping.items():
616 if source_id in allowed_sources:
617 source_options.append(ConfigValueOption(title=source_name, value=source_id))
618 if len(source_options) == 0:
619 # this should never happen
620 self.logger.error(
621 "The player %s has multiple zones, but lacks a non-net source to switch to."
622 " Please report this on github or discord.",
623 self.display_name or self.name,
624 )
625 zone_entries = []
626 else:
627 zone_entries = [
628 ConfigEntry(
629 key=CONF_PLAYER_HANDLE_SOURCE_DISABLED,
630 type=ConfigEntryType.BOOLEAN,
631 label="Disable zone handling completely.",
632 default_value=False,
633 description="This disables zone handling completely. Other options "
634 "will be ignored. Enable should you encounter playback issues while "
635 "e.g. playing to main. You can also hide the player from the UI "
636 "by taking advantage of 'Hide the player in the user interface' "
637 "dropdown.",
638 ),
639 ConfigEntry(
640 key=CONF_PLAYER_SWITCH_SOURCE_NON_NET,
641 label="Switch to this non-net source when leaving a group.",
642 type=ConfigEntryType.STRING,
643 options=source_options,
644 default_value=source_options[0].value,
645 description="The zone will switch to this source when leaving a group."
646 " It must be an input which doesn't require network connectivity.",
647 ),
648 ConfigEntry(
649 key=CONF_PLAYER_TURN_OFF_ON_LEAVE,
650 type=ConfigEntryType.BOOLEAN,
651 label="Turn off the zone when it leaves a group.",
652 default_value=False,
653 description="Turn off the zone when it leaves a group.",
654 ),
655 ]
656
657 return base_entries + zone_entries + PLAYER_CONFIG_ENTRIES
658