1 """Implementation of the musiccast media player."""
3 from __future__
import annotations
9 from aiomusiccast
import MusicCastGroupException, MusicCastMediaContent
10 from aiomusiccast.features
import ZoneFeature
17 MediaPlayerEntityFeature,
21 async_process_play_media_url,
35 HA_REPEAT_MODE_TO_MC_MAPPING,
36 MC_REPEAT_MODE_TO_HA_MAPPING,
40 from .coordinator
import MusicCastDataUpdateCoordinator
41 from .entity
import MusicCastDeviceEntity
43 _LOGGER = logging.getLogger(__name__)
45 MUSIC_PLAYER_BASE_SUPPORT = (
46 MediaPlayerEntityFeature.SHUFFLE_SET
47 | MediaPlayerEntityFeature.REPEAT_SET
48 | MediaPlayerEntityFeature.SELECT_SOUND_MODE
49 | MediaPlayerEntityFeature.SELECT_SOURCE
50 | MediaPlayerEntityFeature.GROUPING
51 | MediaPlayerEntityFeature.PLAY_MEDIA
58 async_add_entities: AddEntitiesCallback,
60 """Set up MusicCast sensor based on a config entry."""
61 coordinator: MusicCastDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
63 name = coordinator.data.network_name
65 media_players: list[Entity] = []
67 for zone
in coordinator.data.zones:
68 zone_name = name
if zone == DEFAULT_ZONE
else f
"{name} {zone}"
78 """The musiccast media player."""
80 _attr_media_content_type = MediaType.MUSIC
81 _attr_should_poll =
False
83 def __init__(self, zone_id, name, entry_id, coordinator):
84 """Initialize the musiccast device."""
93 coordinator=coordinator,
103 """Run when this Entity has been added to HA."""
105 self.coordinator.entities.append(self)
107 self.coordinator.musiccast.register_group_update_callback(
115 """Entity being removed from hass."""
117 self.coordinator.entities.remove(self)
119 self.coordinator.musiccast.remove_group_update_callback(
125 """Return the ip address of the musiccast device."""
126 return self.coordinator.musiccast.ip
130 """Return the zone id of the musiccast device."""
143 """Return the content ID of current playing media."""
147 def state(self) -> MediaPlayerState:
148 """Return the state of the player."""
149 if self.coordinator.data.zones[self.
_zone_id_zone_id_zone_id].power ==
"on":
150 if self.
_is_netusb_is_netusb
and self.coordinator.data.netusb_playback ==
"pause":
151 return MediaPlayerState.PAUSED
152 if self.
_is_netusb_is_netusb
and self.coordinator.data.netusb_playback ==
"stop":
153 return MediaPlayerState.IDLE
154 return MediaPlayerState.PLAYING
155 return MediaPlayerState.OFF
159 """Return a mapping of the actual source names to their labels configured in the MusicCast App."""
161 for inp
in self.coordinator.data.zones[self.
_zone_id_zone_id_zone_id].input_list:
162 label = self.coordinator.data.input_names.get(inp,
"")
163 if inp != label
and (
164 label
in self.coordinator.data.zones[self.
_zone_id_zone_id_zone_id].input_list
165 or list(self.coordinator.data.input_names.values()).count(label) > 1
175 """Return a mapping from the source label to the source name."""
176 return {v: k
for k, v
in self.
source_mappingsource_mapping.items()}
180 """Return the volume level of the media player (0..1)."""
181 if ZoneFeature.VOLUME
in self.coordinator.data.zones[self.
_zone_id_zone_id_zone_id].features:
182 volume = self.coordinator.data.zones[self.
_zone_id_zone_id_zone_id].current_volume
188 """Return boolean if volume is currently muted."""
189 if ZoneFeature.VOLUME
in self.coordinator.data.zones[self.
_zone_id_zone_id_zone_id].features:
195 """Boolean if shuffling is enabled."""
197 self.coordinator.data.netusb_shuffle ==
"on" if self.
_is_netusb_is_netusb
else False
202 """Return the current sound mode."""
203 return self.coordinator.data.zones[self.
_zone_id_zone_id_zone_id].sound_program
207 """Return a list of available sound modes."""
208 return self.coordinator.data.zones[self.
_zone_id_zone_id_zone_id].sound_program_list
212 """Return the zone of the media player."""
217 """Return the unique ID for this media_player."""
218 return f
"{self.coordinator.data.device_id}_{self._zone_id}"
221 """Turn the media player on."""
226 """Turn the media player off."""
231 """Mute the volume."""
233 await self.coordinator.musiccast.mute_volume(self.
_zone_id_zone_id_zone_id, mute)
237 """Set the volume level, range 0..1."""
238 await self.coordinator.musiccast.set_volume_level(self.
_zone_id_zone_id_zone_id, volume)
242 """Turn volume up for media player."""
246 """Turn volume down for media player."""
250 """Send play command."""
252 await self.coordinator.musiccast.netusb_play()
255 "Service play is not supported for non NetUSB sources."
259 """Send pause command."""
261 await self.coordinator.musiccast.netusb_pause()
264 "Service pause is not supported for non NetUSB sources."
268 """Send stop command."""
270 await self.coordinator.musiccast.netusb_stop()
273 "Service stop is not supported for non NetUSB sources."
277 """Enable/disable shuffle mode."""
279 await self.coordinator.musiccast.netusb_shuffle(shuffle)
282 "Service shuffle is not supported for non NetUSB sources."
286 self, media_type: MediaType | str, media_id: str, **kwargs: Any
289 if media_source.is_media_source_id(media_id):
290 play_item = await media_source.async_resolve_media(
293 media_id = play_item.url
299 parts = media_id.split(
":")
301 if parts[0] ==
"list":
302 if (index := parts[3]) ==
"-1":
305 await self.coordinator.musiccast.play_list_media(index, self.
_zone_id_zone_id_zone_id)
308 if parts[0] ==
"presets":
310 await self.coordinator.musiccast.recall_netusb_preset(
315 if parts[0]
in (
"http",
"https")
or media_id.startswith(
"/"):
318 await self.coordinator.musiccast.play_url_media(
324 "Only presets, media from media browser and http URLs are supported"
328 """Implement the websocket media browsing helper."""
329 if media_content_id
and media_source.is_media_source_id(media_content_id):
330 return await media_source.async_browse_media(
333 content_filter=
lambda item: item.media_content_type.startswith(
340 "The device has to be turned on to be able to browse media."
344 media_content_path = media_content_id.split(
":")
345 media_content_provider = await MusicCastMediaContent.browse_media(
346 self.coordinator.musiccast, self.
_zone_id_zone_id_zone_id, media_content_path, 24
348 add_media_source =
False
351 media_content_provider = MusicCastMediaContent.categories(
354 add_media_source =
True
356 def get_content_type(item):
358 return MediaClass.TRACK
359 return MediaClass.DIRECTORY
364 media_class=MEDIA_CLASS_MAPPING.get(child.content_type),
365 media_content_id=child.content_id,
366 media_content_type=get_content_type(child),
367 can_play=child.can_play,
368 can_expand=child.can_browse,
369 thumbnail=child.thumbnail,
371 for child
in media_content_provider.children
376 item = await media_source.async_browse_media(
379 content_filter=
lambda item: item.media_content_type.startswith(
384 if item.domain
is None:
385 children.extend(item.children)
387 children.append(item)
390 title=media_content_provider.title,
391 media_class=MEDIA_CLASS_MAPPING.get(media_content_provider.content_type),
392 media_content_id=media_content_provider.content_id,
393 media_content_type=get_content_type(media_content_provider),
395 can_expand=media_content_provider.can_browse,
400 """Select sound mode."""
401 await self.coordinator.musiccast.select_sound_mode(self.
_zone_id_zone_id_zone_id, sound_mode)
405 """Return the image url of current playing media."""
407 return self.
group_servergroup_server.coordinator.musiccast.media_image_url
408 return self.coordinator.musiccast.media_image_url
if self.
_is_netusb_is_netusb
else None
412 """Return the title of current playing media."""
414 return self.coordinator.data.netusb_track
416 return self.coordinator.musiccast.tuner_media_title
422 """Return the artist of current playing media (Music track only)."""
424 return self.coordinator.data.netusb_artist
426 return self.coordinator.musiccast.tuner_media_artist
432 """Return the album of current playing media (Music track only)."""
433 return self.coordinator.data.netusb_album
if self.
_is_netusb_is_netusb
else None
437 """Return current repeat mode."""
439 MC_REPEAT_MODE_TO_HA_MAPPING.get(self.coordinator.data.netusb_repeat)
446 """Flag media player features that are supported."""
447 supported_features = MUSIC_PLAYER_BASE_SUPPORT
450 if ZoneFeature.POWER
in zone.features:
451 supported_features |= (
452 MediaPlayerEntityFeature.TURN_ON | MediaPlayerEntityFeature.TURN_OFF
454 if ZoneFeature.VOLUME
in zone.features:
455 supported_features |= (
456 MediaPlayerEntityFeature.VOLUME_SET
457 | MediaPlayerEntityFeature.VOLUME_STEP
459 if ZoneFeature.MUTE
in zone.features:
460 supported_features |= MediaPlayerEntityFeature.VOLUME_MUTE
463 supported_features |= MediaPlayerEntityFeature.PREVIOUS_TRACK
464 supported_features |= MediaPlayerEntityFeature.NEXT_TRACK
467 supported_features |= MediaPlayerEntityFeature.PAUSE
468 supported_features |= MediaPlayerEntityFeature.PLAY
469 supported_features |= MediaPlayerEntityFeature.STOP
472 supported_features |= MediaPlayerEntityFeature.BROWSE_MEDIA
474 return supported_features
477 """Send previous track command."""
479 await self.coordinator.musiccast.netusb_previous_track()
481 await self.coordinator.musiccast.tuner_previous_station()
484 "Service previous track is not supported for non NetUSB or Tuner"
489 """Send next track command."""
491 await self.coordinator.musiccast.netusb_next_track()
493 await self.coordinator.musiccast.tuner_next_station()
496 "Service next track is not supported for non NetUSB or Tuner sources."
500 """Enable/disable repeat mode."""
502 await self.coordinator.musiccast.netusb_repeat(
503 HA_REPEAT_MODE_TO_MC_MAPPING.get(repeat,
"off")
507 "Service set repeat is not supported for non NetUSB sources."
511 """Select input source."""
512 await self.coordinator.musiccast.select_source(
518 """ID of the current input source."""
523 """Name of the current input source."""
528 """List of available input sources."""
533 """Duration of current playing media in seconds."""
535 return self.coordinator.data.netusb_total_time
541 """Position of current playing media in seconds."""
543 return self.coordinator.data.netusb_play_time
549 """When was the position of the current playing media valid.
551 Returns value from homeassistant.util.dt.utcnow().
554 return self.coordinator.data.netusb_play_time_updated
562 """Return only true if the current entity is a network server and not a main zone with an attached zone2."""
564 self.coordinator.data.group_role ==
"server"
565 and self.coordinator.data.group_id != NULL_GROUP
566 and self.
_zone_id_zone_id_zone_id == self.coordinator.data.group_server_zone
571 """Return media player entities of the other zones of this device."""
574 for entity
in self.coordinator.entities
575 if entity != self
and isinstance(entity, MusicCastMediaPlayer)
580 """Return whether the media player is the server/host of the group.
582 If the media player is not part of a group, False is returned.
590 if entity.source == ATTR_MAIN_SYNC
598 """Return True if the current entity is a network client and not just a main syncing entity."""
600 self.coordinator.data.group_role ==
"client"
601 and self.coordinator.data.group_id != NULL_GROUP
607 """Return whether the media player is the client of a group.
609 If the media player is not part of a group, False is returned.
614 """Return all media player entities of the musiccast system."""
616 for coordinator
in self.
hasshasshass.data[DOMAIN].values():
619 for entity
in coordinator.entities
620 if isinstance(entity, MusicCastMediaPlayer)
625 """Return all media player entities in the musiccast system, which are in server mode."""
627 return [entity
for entity
in entities
if entity.is_server]
630 """Return the distribution_num (number of clients in the whole musiccast system)."""
632 len(server.coordinator.data.group_client_list)
637 """Return True if the given server is the server of self's group."""
638 return group_server != self
and (
641 and self.coordinator.data.group_id
642 == group_server.coordinator.data.group_id
654 """Return the server of the own group if present, self else."""
662 """Return a list of entity_ids, which belong to the group of self."""
663 return [entity.entity_id
for entity
in self.
musiccast_groupmusiccast_group]
667 """Return all media players of the current group, if the media player is server."""
671 return server.musiccast_group
677 clients = [entity
for entity
in entities
if entity.is_part_of_group(self)]
678 return [self, *clients]
682 """Return the entity of the zone, which is using MusicCast at the moment, if there is one, self else.
684 It is possible that multiple zones use MusicCast as client at the same time. In this case the first one is
688 if entity.is_network_server
or entity.is_network_client:
694 """Update the whole musiccast system when group data change."""
697 if check_clients
or self.coordinator.musiccast.group_reduce_by_source:
698 await entity.async_check_client_list()
700 entity.async_write_ha_state()
703 if not entity.is_server:
704 entity.async_write_ha_state()
709 """Add all clients given in entities to the group of the server.
711 Creates a new group if necessary. Used for join service.
714 "%s wants to add the following entities %s",
722 if entity.entity_id
in group_members
736 self.coordinator.data.group_id
738 else uuid.random_uuid_hex().upper()
743 for client
in entities:
746 network_join = await client.async_client_join(group, self)
747 except MusicCastGroupException:
750 "%s is struggling to update its group data. Will retry"
751 " perform the update"
755 network_join = await client.async_client_join(group, self)
758 ip_addresses.add(client.ip_address)
761 await self.coordinator.musiccast.mc_server_group_extend(
768 "%s added the following entities %s", self.
entity_identity_id,
str(entities)
771 "%s has now the following musiccast group %s",
781 Stops the distribution if device is server. Used for unjoin service.
783 _LOGGER.debug(
"%s called service unjoin", self.
entity_identity_id)
795 """Let the client join a group.
797 If this client is a server, the server will stop distributing.
798 If the client is part of a different group,
799 it will leave that group first. Returns True, if the server has to
800 add the client on his side.
804 _LOGGER.debug(
"%s called service client join", self.
entity_identity_id)
808 if server.zone == DEFAULT_ZONE:
810 server.async_write_ha_state()
815 "Can not join a zone other than main of the same device."
822 "%s is a server of a group and has to stop distribution "
823 "to use MusicCast for %s"
832 _LOGGER.warning(
"%s is already part of the group", self.
entity_identity_id)
836 "%s is client in a different group, will unjoin first",
843 and self.coordinator.data.group_id == server.coordinator.data.group_id
844 and self.coordinator.data.group_role ==
"client"
851 _LOGGER.debug(
"%s will now join as a client", self.
entity_identity_id)
852 await self.coordinator.musiccast.mc_client_join(
858 """Make self leave the group.
860 Should only be called for clients.
862 _LOGGER.debug(
"%s client leave called", self.
entity_identity_id)
868 if entity.source_id == ATTR_MC_LINK
876 if server.coordinator.data.group_id == self.coordinator.data.group_id
878 await self.coordinator.musiccast.mc_client_unjoin()
880 await servers[0].coordinator.musiccast.mc_server_group_reduce(
887 """Close group of self.
889 Should only be called for servers.
891 _LOGGER.debug(
"%s closes his group", self.
entity_identity_id)
894 await client.async_client_leave_group()
895 await self.coordinator.musiccast.mc_server_group_close()
898 """Let the server check if all its clients are still part of his group."""
899 if not self.
is_serveris_server
or self.coordinator.data.group_update_lock.locked():
902 _LOGGER.debug(
"%s updates his group members", self.
entity_identity_id)
903 client_ips_for_removal = [
905 for expected_client_ip
in self.coordinator.data.group_client_list
907 if expected_client_ip
908 not in [entity.ip_address
for entity
in self.
musiccast_groupmusiccast_group]
911 if client_ips_for_removal:
913 "%s says good bye to the following members %s",
915 str(client_ips_for_removal),
917 await self.coordinator.musiccast.mc_server_group_reduce(
928 """Schedule async_check_client_list."""
None async_write_ha_state(self)
None async_on_remove(self, CALLBACK_TYPE func)
Callable[[], None] async_add_listener(self, CALLBACK_TYPE update_callback, Any context=None)
web.Response get(self, web.Request request, str config_key)