Home Assistant Unofficial Reference 2024.12.1
media_player.py
Go to the documentation of this file.
1 """Support to interact with a Music Player Daemon."""
2 
3 from __future__ import annotations
4 
5 import asyncio
6 from contextlib import asynccontextmanager, suppress
7 from datetime import timedelta
8 import hashlib
9 import logging
10 import os
11 from socket import gaierror
12 from typing import Any
13 
14 import mpd
15 from mpd.asyncio import MPDClient
16 import voluptuous as vol
17 
18 from homeassistant.components import media_source
20  PLATFORM_SCHEMA as MEDIA_PLAYER_PLATFORM_SCHEMA,
21  BrowseMedia,
22  MediaPlayerEntity,
23  MediaPlayerEntityFeature,
24  MediaPlayerState,
25  MediaType,
26  RepeatMode,
27  async_process_play_media_url,
28 )
29 from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
30 from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_PORT
31 from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant
32 from homeassistant.data_entry_flow import FlowResultType
34 from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
35 from homeassistant.helpers.entity_platform import AddEntitiesCallback
36 from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
37 from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
38 from homeassistant.util import Throttle
39 import homeassistant.util.dt as dt_util
40 
41 from .const import DOMAIN, LOGGER
42 
43 DEFAULT_NAME = "MPD"
44 DEFAULT_PORT = 6600
45 
46 PLAYLIST_UPDATE_INTERVAL = timedelta(seconds=120)
47 
48 SUPPORT_MPD = (
49  MediaPlayerEntityFeature.PAUSE
50  | MediaPlayerEntityFeature.PREVIOUS_TRACK
51  | MediaPlayerEntityFeature.NEXT_TRACK
52  | MediaPlayerEntityFeature.PLAY_MEDIA
53  | MediaPlayerEntityFeature.PLAY
54  | MediaPlayerEntityFeature.CLEAR_PLAYLIST
55  | MediaPlayerEntityFeature.REPEAT_SET
56  | MediaPlayerEntityFeature.SHUFFLE_SET
57  | MediaPlayerEntityFeature.SEEK
58  | MediaPlayerEntityFeature.STOP
59  | MediaPlayerEntityFeature.TURN_OFF
60  | MediaPlayerEntityFeature.TURN_ON
61  | MediaPlayerEntityFeature.BROWSE_MEDIA
62 )
63 
64 PLATFORM_SCHEMA = MEDIA_PLAYER_PLATFORM_SCHEMA.extend(
65  {
66  vol.Required(CONF_HOST): cv.string,
67  vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
68  vol.Optional(CONF_PASSWORD): cv.string,
69  vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
70  }
71 )
72 
73 
75  hass: HomeAssistant,
76  config: ConfigType,
77  async_add_entities: AddEntitiesCallback,
78  discovery_info: DiscoveryInfoType | None = None,
79 ) -> None:
80  """Set up the MPD platform."""
81 
82  result = await hass.config_entries.flow.async_init(
83  DOMAIN,
84  context={"source": SOURCE_IMPORT},
85  data=config,
86  )
87  if (
88  result["type"] is FlowResultType.CREATE_ENTRY
89  or result["reason"] == "already_configured"
90  ):
92  hass,
93  HOMEASSISTANT_DOMAIN,
94  f"deprecated_yaml_{DOMAIN}",
95  breaks_in_ha_version="2025.1.0",
96  is_fixable=False,
97  issue_domain=DOMAIN,
98  severity=IssueSeverity.WARNING,
99  translation_key="deprecated_yaml",
100  translation_placeholders={
101  "domain": DOMAIN,
102  "integration_title": "Music Player Daemon",
103  },
104  )
105  return
107  hass,
108  DOMAIN,
109  f"deprecated_yaml_import_issue_{result['reason']}",
110  breaks_in_ha_version="2025.1.0",
111  is_fixable=False,
112  issue_domain=DOMAIN,
113  severity=IssueSeverity.WARNING,
114  translation_key=f"deprecated_yaml_import_issue_{result['reason']}",
115  translation_placeholders={
116  "domain": DOMAIN,
117  "integration_title": "Music Player Daemon",
118  },
119  )
120 
121 
123  hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
124 ) -> None:
125  """Set up media player from config_entry."""
126 
128  [
129  MpdDevice(
130  entry.data[CONF_HOST],
131  entry.data[CONF_PORT],
132  entry.data.get(CONF_PASSWORD),
133  entry.entry_id,
134  )
135  ],
136  True,
137  )
138 
139 
141  """Representation of a MPD server."""
142 
143  _attr_media_content_type = MediaType.MUSIC
144  _attr_has_entity_name = True
145  _attr_name = None
146 
147  def __init__(
148  self, server: str, port: int, password: str | None, unique_id: str
149  ) -> None:
150  """Initialize the MPD device."""
151  self.serverserver = server
152  self.portport = port
153  self._attr_unique_id_attr_unique_id = unique_id
154  self._attr_device_info_attr_device_info = DeviceInfo(
155  identifiers={(DOMAIN, unique_id)},
156  entry_type=DeviceEntryType.SERVICE,
157  )
158  self.passwordpassword = password
159 
160  self._status_status: dict[str, Any] = {}
161  self._currentsong_currentsong = None
162  self._current_playlist_current_playlist: str | None = None
163  self._muted_volume_muted_volume = None
164  self._media_image_hash_media_image_hash = None
165  # Track if the song changed so image doesn't have to be loaded every update.
166  self._media_image_file_media_image_file = None
167 
168  # set up MPD client
169  self._client_client = MPDClient()
170  self._client_client.timeout = 30
171  self._client_client.idletimeout = 10
172  self._client_lock_client_lock = asyncio.Lock()
173 
174  # Instead of relying on python-mpd2 to maintain a (persistent) connection to
175  # MPD, the below explicitly sets up a *non*-persistent connection. This is
176  # done to workaround the issue as described in:
177  # <https://github.com/Mic92/python-mpd2/issues/31>
178  @asynccontextmanager
179  async def connection(self):
180  """Handle MPD connect and disconnect."""
181  async with self._client_lock_client_lock:
182  try:
183  # MPDClient.connect() doesn't always respect its timeout. To
184  # prevent a deadlock, enforce an additional (slightly longer)
185  # timeout on the coroutine itself.
186  try:
187  async with asyncio.timeout(self._client_client.timeout + 5):
188  await self._client_client.connect(self.serverserver, self.portport)
189  except TimeoutError as error:
190  # TimeoutError has no message (which hinders logging further
191  # down the line), so provide one.
192  raise TimeoutError("Connection attempt timed out") from error
193  if self.passwordpassword is not None:
194  await self._client_client.password(self.passwordpassword)
195  self._attr_available_attr_available = True
196  yield
197  except (
198  TimeoutError,
199  gaierror,
200  mpd.ConnectionError,
201  OSError,
202  ) as error:
203  # Log a warning during startup or when previously connected; for
204  # subsequent errors a debug message is sufficient.
205  log_level = logging.DEBUG
206  if self._attr_available_attr_available is not False:
207  log_level = logging.WARNING
208  LOGGER.log(
209  log_level, "Error connecting to '%s': %s", self.serverserver, error
210  )
211  self._attr_available_attr_available = False
212  self._status_status = {}
213  # Also yield on failure. Handling mpd.ConnectionErrors caused by
214  # attempting to control a disconnected client is the
215  # responsibility of the caller.
216  yield
217  finally:
218  with suppress(mpd.ConnectionError):
219  self._client_client.disconnect()
220 
221  async def async_update(self) -> None:
222  """Get the latest data from MPD and update the state."""
223  async with self.connectionconnection():
224  try:
225  self._status_status = await self._client_client.status()
226  self._currentsong_currentsong = await self._client_client.currentsong()
227  await self._async_update_media_image_hash_async_update_media_image_hash()
228 
229  if (position := self._status_status.get("elapsed")) is None:
230  position = self._status_status.get("time")
231 
232  if isinstance(position, str) and ":" in position:
233  position = position.split(":")[0]
234 
235  if position is not None and self._attr_media_position_attr_media_position != position:
236  self._attr_media_position_updated_at_attr_media_position_updated_at = dt_util.utcnow()
237  self._attr_media_position_attr_media_position = int(float(position))
238 
239  await self._update_playlists_update_playlists()
240  except (mpd.ConnectionError, ValueError) as error:
241  LOGGER.debug("Error updating status: %s", error)
242 
243  @property
244  def state(self) -> MediaPlayerState:
245  """Return the media state."""
246  if not self._status_status:
247  return MediaPlayerState.OFF
248  if self._status_status.get("state") == "play":
249  return MediaPlayerState.PLAYING
250  if self._status_status.get("state") == "pause":
251  return MediaPlayerState.PAUSED
252  if self._status_status.get("state") == "stop":
253  return MediaPlayerState.OFF
254 
255  return MediaPlayerState.OFF
256 
257  @property
258  def media_content_id(self):
259  """Return the content ID of current playing media."""
260  return self._currentsong_currentsong.get("file")
261 
262  @property
263  def media_duration(self):
264  """Return the duration of current playing media in seconds."""
265  if currentsong_time := self._currentsong_currentsong.get("time"):
266  return currentsong_time
267 
268  time_from_status = self._status_status.get("time")
269  if isinstance(time_from_status, str) and ":" in time_from_status:
270  return time_from_status.split(":")[1]
271 
272  return None
273 
274  @property
275  def media_title(self):
276  """Return the title of current playing media."""
277  name = self._currentsong_currentsong.get("name", None)
278  title = self._currentsong_currentsong.get("title", None)
279  file_name = self._currentsong_currentsong.get("file", None)
280 
281  if name is None and title is None:
282  if file_name is None:
283  return "None"
284  return os.path.basename(file_name)
285  if name is None:
286  return title
287  if title is None:
288  return name
289 
290  return f"{name}: {title}"
291 
292  @property
293  def media_artist(self):
294  """Return the artist of current playing media (Music track only)."""
295  artists = self._currentsong_currentsong.get("artist")
296  if isinstance(artists, list):
297  return ", ".join(artists)
298  return artists
299 
300  @property
301  def media_album_name(self):
302  """Return the album of current playing media (Music track only)."""
303  return self._currentsong_currentsong.get("album")
304 
305  @property
306  def media_image_hash(self):
307  """Hash value for media image."""
308  return self._media_image_hash_media_image_hash
309 
310  async def async_get_media_image(self) -> tuple[bytes | None, str | None]:
311  """Fetch media image of current playing track."""
312  async with self.connectionconnection():
313  if self._currentsong_currentsong is None or not (file := self._currentsong_currentsong.get("file")):
314  return None, None
315 
316  with suppress(mpd.ConnectionError):
317  response = await self._async_get_file_image_response_async_get_file_image_response(file)
318  if response is None:
319  return None, None
320 
321  image = bytes(response["binary"])
322  mime = response.get(
323  "type", "image/png"
324  ) # readpicture has type, albumart does not
325  return (image, mime)
326 
328  """Update the hash value for the media image."""
329  if self._currentsong_currentsong is None:
330  return
331 
332  file = self._currentsong_currentsong.get("file")
333 
334  if file == self._media_image_file_media_image_file:
335  return
336 
337  if (
338  file is not None
339  and (response := await self._async_get_file_image_response_async_get_file_image_response(file))
340  is not None
341  ):
342  self._media_image_hash_media_image_hash = hashlib.sha256(
343  bytes(response["binary"])
344  ).hexdigest()[:16]
345  else:
346  # If there is no image, this hash has to be None, else the media player component
347  # assumes there is an image and returns an error trying to load it and the
348  # frontend media control card breaks.
349  self._media_image_hash_media_image_hash = None
350 
351  self._media_image_file_media_image_file = file
352 
353  async def _async_get_file_image_response(self, file):
354  # not all MPD implementations and versions support the `albumart` and
355  # `fetchpicture` commands.
356  commands = []
357  with suppress(mpd.ConnectionError):
358  commands = list(await self._client_client.commands())
359  can_albumart = "albumart" in commands
360  can_readpicture = "readpicture" in commands
361 
362  response = None
363 
364  # read artwork embedded into the media file
365  if can_readpicture:
366  try:
367  with suppress(mpd.ConnectionError):
368  response = await self._client_client.readpicture(file)
369  except mpd.CommandError as error:
370  if error.errno is not mpd.FailureResponseCode.NO_EXIST:
371  LOGGER.warning(
372  "Retrieving artwork through `readpicture` command failed: %s",
373  error,
374  )
375 
376  # read artwork contained in the media directory (cover.{jpg,png,tiff,bmp}) if none is embedded
377  if can_albumart and not response:
378  try:
379  with suppress(mpd.ConnectionError):
380  response = await self._client_client.albumart(file)
381  except mpd.CommandError as error:
382  if error.errno is not mpd.FailureResponseCode.NO_EXIST:
383  LOGGER.warning(
384  "Retrieving artwork through `albumart` command failed: %s",
385  error,
386  )
387 
388  # response can be an empty object if there is no image
389  if not response:
390  return None
391 
392  return response
393 
394  @property
395  def volume_level(self):
396  """Return the volume level."""
397  if "volume" in self._status_status:
398  return int(self._status_status["volume"]) / 100
399  return None
400 
401  @property
402  def supported_features(self) -> MediaPlayerEntityFeature:
403  """Flag media player features that are supported."""
404  if not self._status_status:
405  return MediaPlayerEntityFeature(0)
406 
407  supported = SUPPORT_MPD
408  if "volume" in self._status_status:
409  supported |= (
410  MediaPlayerEntityFeature.VOLUME_SET
411  | MediaPlayerEntityFeature.VOLUME_STEP
412  | MediaPlayerEntityFeature.VOLUME_MUTE
413  )
414  if self._attr_source_list_attr_source_list is not None:
415  supported |= MediaPlayerEntityFeature.SELECT_SOURCE
416 
417  return supported
418 
419  @property
420  def source(self):
421  """Name of the current input source."""
422  return self._current_playlist_current_playlist
423 
424  async def async_select_source(self, source: str) -> None:
425  """Choose a different available playlist and play it."""
426  await self.async_play_mediaasync_play_mediaasync_play_media(MediaType.PLAYLIST, source)
427 
428  @Throttle(PLAYLIST_UPDATE_INTERVAL)
429  async def _update_playlists(self, **kwargs: Any) -> None:
430  """Update available MPD playlists."""
431  try:
432  self._attr_source_list_attr_source_list = []
433  with suppress(mpd.ConnectionError):
434  for playlist_data in await self._client_client.listplaylists():
435  self._attr_source_list_attr_source_list.append(playlist_data["playlist"])
436  except mpd.CommandError as error:
437  self._attr_source_list_attr_source_list = None
438  LOGGER.warning("Playlists could not be updated: %s:", error)
439 
440  async def async_set_volume_level(self, volume: float) -> None:
441  """Set volume of media player."""
442  async with self.connectionconnection():
443  if "volume" in self._status_status:
444  await self._client_client.setvol(int(volume * 100))
445 
446  async def async_volume_up(self) -> None:
447  """Service to send the MPD the command for volume up."""
448  async with self.connectionconnection():
449  if "volume" in self._status_status:
450  current_volume = int(self._status_status["volume"])
451 
452  if current_volume <= 100:
453  self._client_client.setvol(current_volume + 5)
454 
455  async def async_volume_down(self) -> None:
456  """Service to send the MPD the command for volume down."""
457  async with self.connectionconnection():
458  if "volume" in self._status_status:
459  current_volume = int(self._status_status["volume"])
460 
461  if current_volume >= 0:
462  await self._client_client.setvol(current_volume - 5)
463 
464  async def async_media_play(self) -> None:
465  """Service to send the MPD the command for play/pause."""
466  async with self.connectionconnection():
467  if self._status_status.get("state") == "pause":
468  await self._client_client.pause(0)
469  else:
470  await self._client_client.play()
471 
472  async def async_media_pause(self) -> None:
473  """Service to send the MPD the command for play/pause."""
474  async with self.connectionconnection():
475  await self._client_client.pause(1)
476 
477  async def async_media_stop(self) -> None:
478  """Service to send the MPD the command for stop."""
479  async with self.connectionconnection():
480  await self._client_client.stop()
481 
482  async def async_media_next_track(self) -> None:
483  """Service to send the MPD the command for next track."""
484  async with self.connectionconnection():
485  await self._client_client.next()
486 
487  async def async_media_previous_track(self) -> None:
488  """Service to send the MPD the command for previous track."""
489  async with self.connectionconnection():
490  await self._client_client.previous()
491 
492  async def async_mute_volume(self, mute: bool) -> None:
493  """Mute. Emulated with set_volume_level."""
494  if "volume" in self._status_status:
495  if mute:
496  self._muted_volume_muted_volume = self.volume_levelvolume_levelvolume_level
497  await self.async_set_volume_levelasync_set_volume_levelasync_set_volume_level(0)
498  elif self._muted_volume_muted_volume is not None:
499  await self.async_set_volume_levelasync_set_volume_levelasync_set_volume_level(self._muted_volume_muted_volume)
500  self._attr_is_volume_muted_attr_is_volume_muted = mute
501 
502  async def async_play_media(
503  self, media_type: MediaType | str, media_id: str, **kwargs: Any
504  ) -> None:
505  """Send the media player the command for playing a playlist."""
506  async with self.connectionconnection():
507  if media_source.is_media_source_id(media_id):
508  media_type = MediaType.MUSIC
509  play_item = await media_source.async_resolve_media(
510  self.hasshass, media_id, self.entity_identity_id
511  )
512  media_id = async_process_play_media_url(self.hasshass, play_item.url)
513 
514  if media_type == MediaType.PLAYLIST:
515  LOGGER.debug("Playing playlist: %s", media_id)
516  if self._attr_source_list_attr_source_list and media_id in self._attr_source_list_attr_source_list:
517  self._current_playlist_current_playlist = media_id
518  else:
519  self._current_playlist_current_playlist = None
520  LOGGER.warning("Unknown playlist name %s", media_id)
521  await self._client_client.clear()
522  await self._client_client.load(media_id)
523  await self._client_client.play()
524  else:
525  await self._client_client.clear()
526  self._current_playlist_current_playlist = None
527  await self._client_client.add(media_id)
528  await self._client_client.play()
529 
530  @property
531  def repeat(self) -> RepeatMode:
532  """Return current repeat mode."""
533  if self._status_status.get("repeat") == "1":
534  if self._status_status.get("single") == "1":
535  return RepeatMode.ONE
536  return RepeatMode.ALL
537  return RepeatMode.OFF
538 
539  async def async_set_repeat(self, repeat: RepeatMode) -> None:
540  """Set repeat mode."""
541  async with self.connectionconnection():
542  if repeat == RepeatMode.OFF:
543  await self._client_client.repeat(0)
544  await self._client_client.single(0)
545  else:
546  await self._client_client.repeat(1)
547  if repeat == RepeatMode.ONE:
548  await self._client_client.single(1)
549  else:
550  await self._client_client.single(0)
551 
552  @property
553  def shuffle(self):
554  """Boolean if shuffle is enabled."""
555  return bool(int(self._status_status.get("random")))
556 
557  async def async_set_shuffle(self, shuffle: bool) -> None:
558  """Enable/disable shuffle mode."""
559  async with self.connectionconnection():
560  await self._client_client.random(int(shuffle))
561 
562  async def async_turn_off(self) -> None:
563  """Service to send the MPD the command to stop playing."""
564  async with self.connectionconnection():
565  await self._client_client.stop()
566 
567  async def async_turn_on(self) -> None:
568  """Service to send the MPD the command to start playing."""
569  async with self.connectionconnection():
570  await self._client_client.play()
571  await self._update_playlists_update_playlists(no_throttle=True)
572 
573  async def async_clear_playlist(self) -> None:
574  """Clear players playlist."""
575  async with self.connectionconnection():
576  await self._client_client.clear()
577 
578  async def async_media_seek(self, position: float) -> None:
579  """Send seek command."""
580  async with self.connectionconnection():
581  await self._client_client.seekcur(position)
582 
584  self,
585  media_content_type: MediaType | str | None = None,
586  media_content_id: str | None = None,
587  ) -> BrowseMedia:
588  """Implement the websocket media browsing helper."""
589  async with self.connectionconnection():
590  return await media_source.async_browse_media(
591  self.hasshass,
592  media_content_id,
593  content_filter=lambda item: item.media_content_type.startswith(
594  "audio/"
595  ),
596  )
None async_play_media(self, MediaType|str media_type, str media_id, **Any kwargs)
Definition: __init__.py:877
None async_play_media(self, MediaType|str media_type, str media_id, **Any kwargs)
MediaPlayerEntityFeature supported_features(self)
None async_set_repeat(self, RepeatMode repeat)
BrowseMedia async_browse_media(self, MediaType|str|None media_content_type=None, str|None media_content_id=None)
None __init__(self, str server, int port, str|None password, str unique_id)
tuple[bytes|None, str|None] async_get_media_image(self)
bool add(self, _T matcher)
Definition: match.py:185
web.Response get(self, web.Request request, str config_key)
Definition: view.py:88
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
None async_setup_entry(HomeAssistant hass, ConfigEntry entry, AddEntitiesCallback async_add_entities)
None async_setup_platform(HomeAssistant hass, ConfigType config, AddEntitiesCallback async_add_entities, DiscoveryInfoType|None discovery_info=None)
Definition: media_player.py:79
None async_create_issue(HomeAssistant hass, str entry_id)
Definition: repairs.py:69