Home Assistant Unofficial Reference 2024.12.1
media_player.py
Go to the documentation of this file.
1 """Support for LinkPlay media players."""
2 
3 from __future__ import annotations
4 
5 import logging
6 from typing import Any
7 
8 from linkplay.bridge import LinkPlayBridge
9 from linkplay.consts import EqualizerMode, LoopMode, PlayingMode, PlayingStatus
10 from linkplay.controller import LinkPlayController, LinkPlayMultiroom
11 from linkplay.exceptions import LinkPlayRequestException
12 import voluptuous as vol
13 
14 from homeassistant.components import media_source
16  BrowseMedia,
17  MediaPlayerDeviceClass,
18  MediaPlayerEntity,
19  MediaPlayerEntityFeature,
20  MediaPlayerState,
21  MediaType,
22  RepeatMode,
23  async_process_play_media_url,
24 )
25 from homeassistant.const import Platform
26 from homeassistant.core import HomeAssistant
27 from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
28 from homeassistant.helpers import (
29  config_validation as cv,
30  entity_platform,
31  entity_registry as er,
32 )
33 from homeassistant.helpers.entity_platform import AddEntitiesCallback
34 from homeassistant.util.dt import utcnow
35 
36 from . import LinkPlayConfigEntry, LinkPlayData
37 from .const import CONTROLLER_KEY, DOMAIN
38 from .entity import LinkPlayBaseEntity, exception_wrap
39 
40 _LOGGER = logging.getLogger(__name__)
41 STATE_MAP: dict[PlayingStatus, MediaPlayerState] = {
42  PlayingStatus.STOPPED: MediaPlayerState.IDLE,
43  PlayingStatus.PAUSED: MediaPlayerState.PAUSED,
44  PlayingStatus.PLAYING: MediaPlayerState.PLAYING,
45  PlayingStatus.LOADING: MediaPlayerState.BUFFERING,
46 }
47 
48 SOURCE_MAP: dict[PlayingMode, str] = {
49  PlayingMode.NETWORK: "Wifi",
50  PlayingMode.LINE_IN: "Line In",
51  PlayingMode.BLUETOOTH: "Bluetooth",
52  PlayingMode.OPTICAL: "Optical",
53  PlayingMode.LINE_IN_2: "Line In 2",
54  PlayingMode.USB_DAC: "USB DAC",
55  PlayingMode.COAXIAL: "Coaxial",
56  PlayingMode.XLR: "XLR",
57  PlayingMode.HDMI: "HDMI",
58  PlayingMode.OPTICAL_2: "Optical 2",
59  PlayingMode.EXTERN_BLUETOOTH: "External Bluetooth",
60  PlayingMode.PHONO: "Phono",
61  PlayingMode.ARC: "ARC",
62  PlayingMode.COAXIAL_2: "Coaxial 2",
63  PlayingMode.TF_CARD_1: "SD Card 1",
64  PlayingMode.TF_CARD_2: "SD Card 2",
65  PlayingMode.CD: "CD",
66  PlayingMode.DAB: "DAB Radio",
67  PlayingMode.FM: "FM Radio",
68  PlayingMode.RCA: "RCA",
69  PlayingMode.UDISK: "USB",
70  PlayingMode.SPOTIFY: "Spotify",
71  PlayingMode.TIDAL: "Tidal",
72  PlayingMode.FOLLOWER: "Follower",
73 }
74 
75 SOURCE_MAP_INV: dict[str, PlayingMode] = {v: k for k, v in SOURCE_MAP.items()}
76 
77 REPEAT_MAP: dict[LoopMode, RepeatMode] = {
78  LoopMode.CONTINOUS_PLAY_ONE_SONG: RepeatMode.ONE,
79  LoopMode.PLAY_IN_ORDER: RepeatMode.OFF,
80  LoopMode.CONTINUOUS_PLAYBACK: RepeatMode.ALL,
81  LoopMode.RANDOM_PLAYBACK: RepeatMode.ALL,
82  LoopMode.LIST_CYCLE: RepeatMode.ALL,
83  LoopMode.SHUFF_DISABLED_REPEAT_DISABLED: RepeatMode.OFF,
84  LoopMode.SHUFF_ENABLED_REPEAT_ENABLED_LOOP_ONCE: RepeatMode.ALL,
85 }
86 
87 REPEAT_MAP_INV: dict[RepeatMode, LoopMode] = {v: k for k, v in REPEAT_MAP.items()}
88 
89 EQUALIZER_MAP: dict[EqualizerMode, str] = {
90  EqualizerMode.NONE: "None",
91  EqualizerMode.CLASSIC: "Classic",
92  EqualizerMode.POP: "Pop",
93  EqualizerMode.JAZZ: "Jazz",
94  EqualizerMode.VOCAL: "Vocal",
95 }
96 
97 EQUALIZER_MAP_INV: dict[str, EqualizerMode] = {v: k for k, v in EQUALIZER_MAP.items()}
98 
99 DEFAULT_FEATURES: MediaPlayerEntityFeature = (
100  MediaPlayerEntityFeature.PLAY
101  | MediaPlayerEntityFeature.PLAY_MEDIA
102  | MediaPlayerEntityFeature.BROWSE_MEDIA
103  | MediaPlayerEntityFeature.PAUSE
104  | MediaPlayerEntityFeature.STOP
105  | MediaPlayerEntityFeature.VOLUME_MUTE
106  | MediaPlayerEntityFeature.VOLUME_SET
107  | MediaPlayerEntityFeature.SELECT_SOURCE
108  | MediaPlayerEntityFeature.SELECT_SOUND_MODE
109  | MediaPlayerEntityFeature.GROUPING
110 )
111 
112 SEEKABLE_FEATURES: MediaPlayerEntityFeature = (
113  MediaPlayerEntityFeature.PREVIOUS_TRACK
114  | MediaPlayerEntityFeature.NEXT_TRACK
115  | MediaPlayerEntityFeature.REPEAT_SET
116  | MediaPlayerEntityFeature.SEEK
117 )
118 
119 SERVICE_PLAY_PRESET = "play_preset"
120 ATTR_PRESET_NUMBER = "preset_number"
121 
122 SERVICE_PLAY_PRESET_SCHEMA = cv.make_entity_service_schema(
123  {
124  vol.Required(ATTR_PRESET_NUMBER): cv.positive_int,
125  }
126 )
127 
128 
130  hass: HomeAssistant,
131  entry: LinkPlayConfigEntry,
132  async_add_entities: AddEntitiesCallback,
133 ) -> None:
134  """Set up a media player from a config entry."""
135 
136  # register services
137  platform = entity_platform.async_get_current_platform()
138  platform.async_register_entity_service(
139  SERVICE_PLAY_PRESET, SERVICE_PLAY_PRESET_SCHEMA, "async_play_preset"
140  )
141 
142  # add entities
143  async_add_entities([LinkPlayMediaPlayerEntity(entry.runtime_data.bridge)])
144 
145 
147  """Representation of a LinkPlay media player."""
148 
149  _attr_sound_mode_list = list(EQUALIZER_MAP.values())
150  _attr_device_class = MediaPlayerDeviceClass.RECEIVER
151  _attr_media_content_type = MediaType.MUSIC
152  _attr_name = None
153 
154  def __init__(self, bridge: LinkPlayBridge) -> None:
155  """Initialize the LinkPlay media player."""
156 
157  super().__init__(bridge)
158  self._attr_unique_id_attr_unique_id = bridge.device.uuid
159 
160  self._attr_source_list_attr_source_list = [
161  SOURCE_MAP[playing_mode] for playing_mode in bridge.device.playmode_support
162  ]
163 
164  @exception_wrap
165  async def async_update(self) -> None:
166  """Update the state of the media player."""
167  try:
168  await self._bridge_bridge.player.update_status()
169  self._update_properties_update_properties()
170  except LinkPlayRequestException:
171  self._attr_available_attr_available = False
172 
173  @exception_wrap
174  async def async_select_source(self, source: str) -> None:
175  """Select input source."""
176  await self._bridge_bridge.player.set_play_mode(SOURCE_MAP_INV[source])
177 
178  @exception_wrap
179  async def async_select_sound_mode(self, sound_mode: str) -> None:
180  """Select sound mode."""
181  await self._bridge_bridge.player.set_equalizer_mode(EQUALIZER_MAP_INV[sound_mode])
182 
183  @exception_wrap
184  async def async_mute_volume(self, mute: bool) -> None:
185  """Mute the volume."""
186  if mute:
187  await self._bridge_bridge.player.mute()
188  else:
189  await self._bridge_bridge.player.unmute()
190 
191  @exception_wrap
192  async def async_set_volume_level(self, volume: float) -> None:
193  """Set volume level, range 0..1."""
194  await self._bridge_bridge.player.set_volume(int(volume * 100))
195 
196  @exception_wrap
197  async def async_media_pause(self) -> None:
198  """Send pause command."""
199  await self._bridge_bridge.player.pause()
200 
201  @exception_wrap
202  async def async_media_play(self) -> None:
203  """Send play command."""
204  await self._bridge_bridge.player.resume()
205 
206  @exception_wrap
207  async def async_media_stop(self) -> None:
208  """Send stop command."""
209  await self._bridge_bridge.player.stop()
210 
211  @exception_wrap
212  async def async_media_next_track(self) -> None:
213  """Send next command."""
214  await self._bridge_bridge.player.next()
215 
216  @exception_wrap
217  async def async_media_previous_track(self) -> None:
218  """Send previous command."""
219  await self._bridge_bridge.player.previous()
220 
221  @exception_wrap
222  async def async_set_repeat(self, repeat: RepeatMode) -> None:
223  """Set repeat mode."""
224  await self._bridge_bridge.player.set_loop_mode(REPEAT_MAP_INV[repeat])
225 
227  self,
228  media_content_type: MediaType | str | None = None,
229  media_content_id: str | None = None,
230  ) -> BrowseMedia:
231  """Return a BrowseMedia instance.
232 
233  The BrowseMedia instance will be used by the
234  "media_player/browse_media" websocket command.
235  """
236  return await media_source.async_browse_media(
237  self.hasshass,
238  media_content_id,
239  # This allows filtering content. In this case it will only show audio sources.
240  content_filter=lambda item: item.media_content_type.startswith("audio/"),
241  )
242 
243  @exception_wrap
244  async def async_play_media(
245  self, media_type: MediaType | str, media_id: str, **kwargs: Any
246  ) -> None:
247  """Play a piece of media."""
248  if media_source.is_media_source_id(media_id):
249  play_item = await media_source.async_resolve_media(
250  self.hasshass, media_id, self.entity_identity_id
251  )
252  media_id = play_item.url
253 
254  url = async_process_play_media_url(self.hasshass, media_id)
255  await self._bridge_bridge.player.play(url)
256 
257  @exception_wrap
258  async def async_play_preset(self, preset_number: int) -> None:
259  """Play preset number."""
260  try:
261  await self._bridge_bridge.player.play_preset(preset_number)
262  except ValueError as err:
263  raise HomeAssistantError(err) from err
264 
265  @exception_wrap
266  async def async_media_seek(self, position: float) -> None:
267  """Seek to a position."""
268  await self._bridge_bridge.player.seek(round(position))
269 
270  @exception_wrap
271  async def async_join_players(self, group_members: list[str]) -> None:
272  """Join `group_members` as a player group with the current player."""
273 
274  controller: LinkPlayController = self.hasshass.data[DOMAIN][CONTROLLER_KEY]
275  multiroom = self._bridge_bridge.multiroom
276  if multiroom is None:
277  multiroom = LinkPlayMultiroom(self._bridge_bridge)
278 
279  for group_member in group_members:
280  bridge = self._get_linkplay_bridge_get_linkplay_bridge(group_member)
281  if bridge:
282  await multiroom.add_follower(bridge)
283 
284  await controller.discover_multirooms()
285 
286  def _get_linkplay_bridge(self, entity_id: str) -> LinkPlayBridge:
287  """Get linkplay bridge from entity_id."""
288 
289  entity_registry = er.async_get(self.hasshass)
290 
291  # Check for valid linkplay media_player entity
292  entity_entry = entity_registry.async_get(entity_id)
293 
294  if (
295  entity_entry is None
296  or entity_entry.domain != Platform.MEDIA_PLAYER
297  or entity_entry.platform != DOMAIN
298  or entity_entry.config_entry_id is None
299  ):
301  translation_domain=DOMAIN,
302  translation_key="invalid_grouping_entity",
303  translation_placeholders={"entity_id": entity_id},
304  )
305 
306  config_entry = self.hasshass.config_entries.async_get_entry(
307  entity_entry.config_entry_id
308  )
309  assert config_entry
310 
311  # Return bridge
312  data: LinkPlayData = config_entry.runtime_data
313  return data.bridge
314 
315  @property
316  def group_members(self) -> list[str]:
317  """List of players which are grouped together."""
318  multiroom = self._bridge_bridge.multiroom
319  if multiroom is not None:
320  return [multiroom.leader.device.uuid] + [
321  follower.device.uuid for follower in multiroom.followers
322  ]
323 
324  return []
325 
326  @exception_wrap
327  async def async_unjoin_player(self) -> None:
328  """Remove this player from any group."""
329  controller: LinkPlayController = self.hasshass.data[DOMAIN][CONTROLLER_KEY]
330 
331  multiroom = self._bridge_bridge.multiroom
332  if multiroom is not None:
333  await multiroom.remove_follower(self._bridge_bridge)
334 
335  await controller.discover_multirooms()
336 
337  def _update_properties(self) -> None:
338  """Update the properties of the media player."""
339  self._attr_available_attr_available = True
340  self._attr_state_attr_state = STATE_MAP[self._bridge_bridge.player.status]
341  self._attr_volume_level_attr_volume_level = self._bridge_bridge.player.volume / 100
342  self._attr_is_volume_muted_attr_is_volume_muted = self._bridge_bridge.player.muted
343  self._attr_repeat_attr_repeat = REPEAT_MAP[self._bridge_bridge.player.loop_mode]
344  self._attr_shuffle_attr_shuffle = self._bridge_bridge.player.loop_mode == LoopMode.RANDOM_PLAYBACK
345  self._attr_sound_mode_attr_sound_mode = EQUALIZER_MAP[self._bridge_bridge.player.equalizer_mode]
346  self._attr_supported_features_attr_supported_features = DEFAULT_FEATURES
347 
348  if self._bridge_bridge.player.status == PlayingStatus.PLAYING:
349  if self._bridge_bridge.player.total_length != 0:
350  self._attr_supported_features_attr_supported_features = (
351  self._attr_supported_features_attr_supported_features | SEEKABLE_FEATURES
352  )
353 
354  self._attr_source_attr_source = SOURCE_MAP.get(self._bridge_bridge.player.play_mode, "other")
355  self._attr_media_position_attr_media_position = self._bridge_bridge.player.current_position_in_seconds
356  self._attr_media_position_updated_at_attr_media_position_updated_at = utcnow()
357  self._attr_media_duration_attr_media_duration = self._bridge_bridge.player.total_length_in_seconds
358  self._attr_media_artist_attr_media_artist = self._bridge_bridge.player.artist
359  self._attr_media_title_attr_media_title = self._bridge_bridge.player.title
360  self._attr_media_album_name_attr_media_album_name = self._bridge_bridge.player.album
361  elif self._bridge_bridge.player.status == PlayingStatus.STOPPED:
362  self._attr_media_position_attr_media_position = None
363  self._attr_media_position_updated_at_attr_media_position_updated_at = None
364  self._attr_media_artist_attr_media_artist = None
365  self._attr_media_title_attr_media_title = None
366  self._attr_media_album_name_attr_media_album_name = None
BrowseMedia async_browse_media(self, MediaType|str|None media_content_type=None, str|None media_content_id=None)
None async_play_media(self, MediaType|str media_type, str media_id, **Any kwargs)
None async_setup_entry(HomeAssistant hass, LinkPlayConfigEntry entry, AddEntitiesCallback async_add_entities)
str async_process_play_media_url(HomeAssistant hass, str media_content_id, *bool allow_relative_url=False, bool for_supervisor_network=False)
Definition: browse_media.py:36