mirror of https://github.com/home-assistant/core
597 lines
21 KiB
Python
597 lines
21 KiB
Python
"""Support to interact with a Music Player Daemon."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
from contextlib import asynccontextmanager, suppress
|
|
from datetime import timedelta
|
|
import hashlib
|
|
import logging
|
|
import os
|
|
from socket import gaierror
|
|
from typing import Any
|
|
|
|
import mpd
|
|
from mpd.asyncio import MPDClient
|
|
import voluptuous as vol
|
|
|
|
from homeassistant.components import media_source
|
|
from homeassistant.components.media_player import (
|
|
PLATFORM_SCHEMA as MEDIA_PLAYER_PLATFORM_SCHEMA,
|
|
BrowseMedia,
|
|
MediaPlayerEntity,
|
|
MediaPlayerEntityFeature,
|
|
MediaPlayerState,
|
|
MediaType,
|
|
RepeatMode,
|
|
async_process_play_media_url,
|
|
)
|
|
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
|
|
from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_PORT
|
|
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant
|
|
from homeassistant.data_entry_flow import FlowResultType
|
|
import homeassistant.helpers.config_validation as cv
|
|
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
|
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
|
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
|
|
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
|
from homeassistant.util import Throttle
|
|
import homeassistant.util.dt as dt_util
|
|
|
|
from .const import DOMAIN, LOGGER
|
|
|
|
DEFAULT_NAME = "MPD"
|
|
DEFAULT_PORT = 6600
|
|
|
|
PLAYLIST_UPDATE_INTERVAL = timedelta(seconds=120)
|
|
|
|
SUPPORT_MPD = (
|
|
MediaPlayerEntityFeature.PAUSE
|
|
| MediaPlayerEntityFeature.PREVIOUS_TRACK
|
|
| MediaPlayerEntityFeature.NEXT_TRACK
|
|
| MediaPlayerEntityFeature.PLAY_MEDIA
|
|
| MediaPlayerEntityFeature.PLAY
|
|
| MediaPlayerEntityFeature.CLEAR_PLAYLIST
|
|
| MediaPlayerEntityFeature.REPEAT_SET
|
|
| MediaPlayerEntityFeature.SHUFFLE_SET
|
|
| MediaPlayerEntityFeature.SEEK
|
|
| MediaPlayerEntityFeature.STOP
|
|
| MediaPlayerEntityFeature.TURN_OFF
|
|
| MediaPlayerEntityFeature.TURN_ON
|
|
| MediaPlayerEntityFeature.BROWSE_MEDIA
|
|
)
|
|
|
|
PLATFORM_SCHEMA = MEDIA_PLAYER_PLATFORM_SCHEMA.extend(
|
|
{
|
|
vol.Required(CONF_HOST): cv.string,
|
|
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
|
|
vol.Optional(CONF_PASSWORD): cv.string,
|
|
vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
|
|
}
|
|
)
|
|
|
|
|
|
async def async_setup_platform(
|
|
hass: HomeAssistant,
|
|
config: ConfigType,
|
|
async_add_entities: AddEntitiesCallback,
|
|
discovery_info: DiscoveryInfoType | None = None,
|
|
) -> None:
|
|
"""Set up the MPD platform."""
|
|
|
|
result = await hass.config_entries.flow.async_init(
|
|
DOMAIN,
|
|
context={"source": SOURCE_IMPORT},
|
|
data=config,
|
|
)
|
|
if (
|
|
result["type"] is FlowResultType.CREATE_ENTRY
|
|
or result["reason"] == "already_configured"
|
|
):
|
|
async_create_issue(
|
|
hass,
|
|
HOMEASSISTANT_DOMAIN,
|
|
f"deprecated_yaml_{DOMAIN}",
|
|
breaks_in_ha_version="2025.1.0",
|
|
is_fixable=False,
|
|
issue_domain=DOMAIN,
|
|
severity=IssueSeverity.WARNING,
|
|
translation_key="deprecated_yaml",
|
|
translation_placeholders={
|
|
"domain": DOMAIN,
|
|
"integration_title": "Music Player Daemon",
|
|
},
|
|
)
|
|
return
|
|
async_create_issue(
|
|
hass,
|
|
DOMAIN,
|
|
f"deprecated_yaml_import_issue_{result['reason']}",
|
|
breaks_in_ha_version="2025.1.0",
|
|
is_fixable=False,
|
|
issue_domain=DOMAIN,
|
|
severity=IssueSeverity.WARNING,
|
|
translation_key=f"deprecated_yaml_import_issue_{result['reason']}",
|
|
translation_placeholders={
|
|
"domain": DOMAIN,
|
|
"integration_title": "Music Player Daemon",
|
|
},
|
|
)
|
|
|
|
|
|
async def async_setup_entry(
|
|
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
|
|
) -> None:
|
|
"""Set up media player from config_entry."""
|
|
|
|
async_add_entities(
|
|
[
|
|
MpdDevice(
|
|
entry.data[CONF_HOST],
|
|
entry.data[CONF_PORT],
|
|
entry.data.get(CONF_PASSWORD),
|
|
entry.entry_id,
|
|
)
|
|
],
|
|
True,
|
|
)
|
|
|
|
|
|
class MpdDevice(MediaPlayerEntity):
|
|
"""Representation of a MPD server."""
|
|
|
|
_attr_media_content_type = MediaType.MUSIC
|
|
_attr_has_entity_name = True
|
|
_attr_name = None
|
|
|
|
def __init__(
|
|
self, server: str, port: int, password: str | None, unique_id: str
|
|
) -> None:
|
|
"""Initialize the MPD device."""
|
|
self.server = server
|
|
self.port = port
|
|
self._attr_unique_id = unique_id
|
|
self._attr_device_info = DeviceInfo(
|
|
identifiers={(DOMAIN, unique_id)},
|
|
entry_type=DeviceEntryType.SERVICE,
|
|
)
|
|
self.password = password
|
|
|
|
self._status: dict[str, Any] = {}
|
|
self._currentsong = None
|
|
self._current_playlist: str | None = None
|
|
self._muted_volume = None
|
|
self._media_image_hash = None
|
|
# Track if the song changed so image doesn't have to be loaded every update.
|
|
self._media_image_file = None
|
|
|
|
# set up MPD client
|
|
self._client = MPDClient()
|
|
self._client.timeout = 30
|
|
self._client.idletimeout = 10
|
|
self._client_lock = asyncio.Lock()
|
|
|
|
# Instead of relying on python-mpd2 to maintain a (persistent) connection to
|
|
# MPD, the below explicitly sets up a *non*-persistent connection. This is
|
|
# done to workaround the issue as described in:
|
|
# <https://github.com/Mic92/python-mpd2/issues/31>
|
|
@asynccontextmanager
|
|
async def connection(self):
|
|
"""Handle MPD connect and disconnect."""
|
|
async with self._client_lock:
|
|
try:
|
|
# MPDClient.connect() doesn't always respect its timeout. To
|
|
# prevent a deadlock, enforce an additional (slightly longer)
|
|
# timeout on the coroutine itself.
|
|
try:
|
|
async with asyncio.timeout(self._client.timeout + 5):
|
|
await self._client.connect(self.server, self.port)
|
|
except TimeoutError as error:
|
|
# TimeoutError has no message (which hinders logging further
|
|
# down the line), so provide one.
|
|
raise TimeoutError("Connection attempt timed out") from error
|
|
if self.password is not None:
|
|
await self._client.password(self.password)
|
|
self._attr_available = True
|
|
yield
|
|
except (
|
|
TimeoutError,
|
|
gaierror,
|
|
mpd.ConnectionError,
|
|
OSError,
|
|
) as error:
|
|
# Log a warning during startup or when previously connected; for
|
|
# subsequent errors a debug message is sufficient.
|
|
log_level = logging.DEBUG
|
|
if self._attr_available is not False:
|
|
log_level = logging.WARNING
|
|
LOGGER.log(
|
|
log_level, "Error connecting to '%s': %s", self.server, error
|
|
)
|
|
self._attr_available = False
|
|
self._status = {}
|
|
# Also yield on failure. Handling mpd.ConnectionErrors caused by
|
|
# attempting to control a disconnected client is the
|
|
# responsibility of the caller.
|
|
yield
|
|
finally:
|
|
with suppress(mpd.ConnectionError):
|
|
self._client.disconnect()
|
|
|
|
async def async_update(self) -> None:
|
|
"""Get the latest data from MPD and update the state."""
|
|
async with self.connection():
|
|
try:
|
|
self._status = await self._client.status()
|
|
self._currentsong = await self._client.currentsong()
|
|
await self._async_update_media_image_hash()
|
|
|
|
if (position := self._status.get("elapsed")) is None:
|
|
position = self._status.get("time")
|
|
|
|
if isinstance(position, str) and ":" in position:
|
|
position = position.split(":")[0]
|
|
|
|
if position is not None and self._attr_media_position != position:
|
|
self._attr_media_position_updated_at = dt_util.utcnow()
|
|
self._attr_media_position = int(float(position))
|
|
|
|
await self._update_playlists()
|
|
except (mpd.ConnectionError, ValueError) as error:
|
|
LOGGER.debug("Error updating status: %s", error)
|
|
|
|
@property
|
|
def state(self) -> MediaPlayerState:
|
|
"""Return the media state."""
|
|
if not self._status:
|
|
return MediaPlayerState.OFF
|
|
if self._status.get("state") == "play":
|
|
return MediaPlayerState.PLAYING
|
|
if self._status.get("state") == "pause":
|
|
return MediaPlayerState.PAUSED
|
|
if self._status.get("state") == "stop":
|
|
return MediaPlayerState.OFF
|
|
|
|
return MediaPlayerState.OFF
|
|
|
|
@property
|
|
def media_content_id(self):
|
|
"""Return the content ID of current playing media."""
|
|
return self._currentsong.get("file")
|
|
|
|
@property
|
|
def media_duration(self):
|
|
"""Return the duration of current playing media in seconds."""
|
|
if currentsong_time := self._currentsong.get("time"):
|
|
return currentsong_time
|
|
|
|
time_from_status = self._status.get("time")
|
|
if isinstance(time_from_status, str) and ":" in time_from_status:
|
|
return time_from_status.split(":")[1]
|
|
|
|
return None
|
|
|
|
@property
|
|
def media_title(self):
|
|
"""Return the title of current playing media."""
|
|
name = self._currentsong.get("name", None)
|
|
title = self._currentsong.get("title", None)
|
|
file_name = self._currentsong.get("file", None)
|
|
|
|
if name is None and title is None:
|
|
if file_name is None:
|
|
return "None"
|
|
return os.path.basename(file_name)
|
|
if name is None:
|
|
return title
|
|
if title is None:
|
|
return name
|
|
|
|
return f"{name}: {title}"
|
|
|
|
@property
|
|
def media_artist(self):
|
|
"""Return the artist of current playing media (Music track only)."""
|
|
artists = self._currentsong.get("artist")
|
|
if isinstance(artists, list):
|
|
return ", ".join(artists)
|
|
return artists
|
|
|
|
@property
|
|
def media_album_name(self):
|
|
"""Return the album of current playing media (Music track only)."""
|
|
return self._currentsong.get("album")
|
|
|
|
@property
|
|
def media_image_hash(self):
|
|
"""Hash value for media image."""
|
|
return self._media_image_hash
|
|
|
|
async def async_get_media_image(self) -> tuple[bytes | None, str | None]:
|
|
"""Fetch media image of current playing track."""
|
|
async with self.connection():
|
|
if self._currentsong is None or not (file := self._currentsong.get("file")):
|
|
return None, None
|
|
|
|
with suppress(mpd.ConnectionError):
|
|
response = await self._async_get_file_image_response(file)
|
|
if response is None:
|
|
return None, None
|
|
|
|
image = bytes(response["binary"])
|
|
mime = response.get(
|
|
"type", "image/png"
|
|
) # readpicture has type, albumart does not
|
|
return (image, mime)
|
|
|
|
async def _async_update_media_image_hash(self):
|
|
"""Update the hash value for the media image."""
|
|
if self._currentsong is None:
|
|
return
|
|
|
|
file = self._currentsong.get("file")
|
|
|
|
if file == self._media_image_file:
|
|
return
|
|
|
|
if (
|
|
file is not None
|
|
and (response := await self._async_get_file_image_response(file))
|
|
is not None
|
|
):
|
|
self._media_image_hash = hashlib.sha256(
|
|
bytes(response["binary"])
|
|
).hexdigest()[:16]
|
|
else:
|
|
# If there is no image, this hash has to be None, else the media player component
|
|
# assumes there is an image and returns an error trying to load it and the
|
|
# frontend media control card breaks.
|
|
self._media_image_hash = None
|
|
|
|
self._media_image_file = file
|
|
|
|
async def _async_get_file_image_response(self, file):
|
|
# not all MPD implementations and versions support the `albumart` and
|
|
# `fetchpicture` commands.
|
|
commands = []
|
|
with suppress(mpd.ConnectionError):
|
|
commands = list(await self._client.commands())
|
|
can_albumart = "albumart" in commands
|
|
can_readpicture = "readpicture" in commands
|
|
|
|
response = None
|
|
|
|
# read artwork embedded into the media file
|
|
if can_readpicture:
|
|
try:
|
|
with suppress(mpd.ConnectionError):
|
|
response = await self._client.readpicture(file)
|
|
except mpd.CommandError as error:
|
|
if error.errno is not mpd.FailureResponseCode.NO_EXIST:
|
|
LOGGER.warning(
|
|
"Retrieving artwork through `readpicture` command failed: %s",
|
|
error,
|
|
)
|
|
|
|
# read artwork contained in the media directory (cover.{jpg,png,tiff,bmp}) if none is embedded
|
|
if can_albumart and not response:
|
|
try:
|
|
with suppress(mpd.ConnectionError):
|
|
response = await self._client.albumart(file)
|
|
except mpd.CommandError as error:
|
|
if error.errno is not mpd.FailureResponseCode.NO_EXIST:
|
|
LOGGER.warning(
|
|
"Retrieving artwork through `albumart` command failed: %s",
|
|
error,
|
|
)
|
|
|
|
# response can be an empty object if there is no image
|
|
if not response:
|
|
return None
|
|
|
|
return response
|
|
|
|
@property
|
|
def volume_level(self):
|
|
"""Return the volume level."""
|
|
if "volume" in self._status:
|
|
return int(self._status["volume"]) / 100
|
|
return None
|
|
|
|
@property
|
|
def supported_features(self) -> MediaPlayerEntityFeature:
|
|
"""Flag media player features that are supported."""
|
|
if not self._status:
|
|
return MediaPlayerEntityFeature(0)
|
|
|
|
supported = SUPPORT_MPD
|
|
if "volume" in self._status:
|
|
supported |= (
|
|
MediaPlayerEntityFeature.VOLUME_SET
|
|
| MediaPlayerEntityFeature.VOLUME_STEP
|
|
| MediaPlayerEntityFeature.VOLUME_MUTE
|
|
)
|
|
if self._attr_source_list is not None:
|
|
supported |= MediaPlayerEntityFeature.SELECT_SOURCE
|
|
|
|
return supported
|
|
|
|
@property
|
|
def source(self):
|
|
"""Name of the current input source."""
|
|
return self._current_playlist
|
|
|
|
async def async_select_source(self, source: str) -> None:
|
|
"""Choose a different available playlist and play it."""
|
|
await self.async_play_media(MediaType.PLAYLIST, source)
|
|
|
|
@Throttle(PLAYLIST_UPDATE_INTERVAL)
|
|
async def _update_playlists(self, **kwargs: Any) -> None:
|
|
"""Update available MPD playlists."""
|
|
try:
|
|
self._attr_source_list = []
|
|
with suppress(mpd.ConnectionError):
|
|
for playlist_data in await self._client.listplaylists():
|
|
self._attr_source_list.append(playlist_data["playlist"])
|
|
except mpd.CommandError as error:
|
|
self._attr_source_list = None
|
|
LOGGER.warning("Playlists could not be updated: %s:", error)
|
|
|
|
async def async_set_volume_level(self, volume: float) -> None:
|
|
"""Set volume of media player."""
|
|
async with self.connection():
|
|
if "volume" in self._status:
|
|
await self._client.setvol(int(volume * 100))
|
|
|
|
async def async_volume_up(self) -> None:
|
|
"""Service to send the MPD the command for volume up."""
|
|
async with self.connection():
|
|
if "volume" in self._status:
|
|
current_volume = int(self._status["volume"])
|
|
|
|
if current_volume <= 100:
|
|
self._client.setvol(current_volume + 5)
|
|
|
|
async def async_volume_down(self) -> None:
|
|
"""Service to send the MPD the command for volume down."""
|
|
async with self.connection():
|
|
if "volume" in self._status:
|
|
current_volume = int(self._status["volume"])
|
|
|
|
if current_volume >= 0:
|
|
await self._client.setvol(current_volume - 5)
|
|
|
|
async def async_media_play(self) -> None:
|
|
"""Service to send the MPD the command for play/pause."""
|
|
async with self.connection():
|
|
if self._status.get("state") == "pause":
|
|
await self._client.pause(0)
|
|
else:
|
|
await self._client.play()
|
|
|
|
async def async_media_pause(self) -> None:
|
|
"""Service to send the MPD the command for play/pause."""
|
|
async with self.connection():
|
|
await self._client.pause(1)
|
|
|
|
async def async_media_stop(self) -> None:
|
|
"""Service to send the MPD the command for stop."""
|
|
async with self.connection():
|
|
await self._client.stop()
|
|
|
|
async def async_media_next_track(self) -> None:
|
|
"""Service to send the MPD the command for next track."""
|
|
async with self.connection():
|
|
await self._client.next()
|
|
|
|
async def async_media_previous_track(self) -> None:
|
|
"""Service to send the MPD the command for previous track."""
|
|
async with self.connection():
|
|
await self._client.previous()
|
|
|
|
async def async_mute_volume(self, mute: bool) -> None:
|
|
"""Mute. Emulated with set_volume_level."""
|
|
if "volume" in self._status:
|
|
if mute:
|
|
self._muted_volume = self.volume_level
|
|
await self.async_set_volume_level(0)
|
|
elif self._muted_volume is not None:
|
|
await self.async_set_volume_level(self._muted_volume)
|
|
self._attr_is_volume_muted = mute
|
|
|
|
async def async_play_media(
|
|
self, media_type: MediaType | str, media_id: str, **kwargs: Any
|
|
) -> None:
|
|
"""Send the media player the command for playing a playlist."""
|
|
async with self.connection():
|
|
if media_source.is_media_source_id(media_id):
|
|
media_type = MediaType.MUSIC
|
|
play_item = await media_source.async_resolve_media(
|
|
self.hass, media_id, self.entity_id
|
|
)
|
|
media_id = async_process_play_media_url(self.hass, play_item.url)
|
|
|
|
if media_type == MediaType.PLAYLIST:
|
|
LOGGER.debug("Playing playlist: %s", media_id)
|
|
if self._attr_source_list and media_id in self._attr_source_list:
|
|
self._current_playlist = media_id
|
|
else:
|
|
self._current_playlist = None
|
|
LOGGER.warning("Unknown playlist name %s", media_id)
|
|
await self._client.clear()
|
|
await self._client.load(media_id)
|
|
await self._client.play()
|
|
else:
|
|
await self._client.clear()
|
|
self._current_playlist = None
|
|
await self._client.add(media_id)
|
|
await self._client.play()
|
|
|
|
@property
|
|
def repeat(self) -> RepeatMode:
|
|
"""Return current repeat mode."""
|
|
if self._status.get("repeat") == "1":
|
|
if self._status.get("single") == "1":
|
|
return RepeatMode.ONE
|
|
return RepeatMode.ALL
|
|
return RepeatMode.OFF
|
|
|
|
async def async_set_repeat(self, repeat: RepeatMode) -> None:
|
|
"""Set repeat mode."""
|
|
async with self.connection():
|
|
if repeat == RepeatMode.OFF:
|
|
await self._client.repeat(0)
|
|
await self._client.single(0)
|
|
else:
|
|
await self._client.repeat(1)
|
|
if repeat == RepeatMode.ONE:
|
|
await self._client.single(1)
|
|
else:
|
|
await self._client.single(0)
|
|
|
|
@property
|
|
def shuffle(self):
|
|
"""Boolean if shuffle is enabled."""
|
|
return bool(int(self._status.get("random")))
|
|
|
|
async def async_set_shuffle(self, shuffle: bool) -> None:
|
|
"""Enable/disable shuffle mode."""
|
|
async with self.connection():
|
|
await self._client.random(int(shuffle))
|
|
|
|
async def async_turn_off(self) -> None:
|
|
"""Service to send the MPD the command to stop playing."""
|
|
async with self.connection():
|
|
await self._client.stop()
|
|
|
|
async def async_turn_on(self) -> None:
|
|
"""Service to send the MPD the command to start playing."""
|
|
async with self.connection():
|
|
await self._client.play()
|
|
await self._update_playlists(no_throttle=True)
|
|
|
|
async def async_clear_playlist(self) -> None:
|
|
"""Clear players playlist."""
|
|
async with self.connection():
|
|
await self._client.clear()
|
|
|
|
async def async_media_seek(self, position: float) -> None:
|
|
"""Send seek command."""
|
|
async with self.connection():
|
|
await self._client.seekcur(position)
|
|
|
|
async def async_browse_media(
|
|
self,
|
|
media_content_type: MediaType | str | None = None,
|
|
media_content_id: str | None = None,
|
|
) -> BrowseMedia:
|
|
"""Implement the websocket media browsing helper."""
|
|
async with self.connection():
|
|
return await media_source.async_browse_media(
|
|
self.hass,
|
|
media_content_id,
|
|
content_filter=lambda item: item.media_content_type.startswith(
|
|
"audio/"
|
|
),
|
|
)
|