Use non-persistent connection for MPD (#94507)

* Do not use a persistent connection to MPD

In other words, don't rely on the connection management provided by
"python-mpd2". Instead of keeping the connection to MPD open, we
explicitly connect before and disconnect after each command.

There is probably a bit of overhead to this, but as the integration
uses a local-polling approach to begin with, no functionality is lost or
degraded.

This change greatly hardens the MPD integration against both network
issues and problems with the daemon itself. All connection-related
failure modes have effectively been removed.

* Update state retrieval methods

Only "async_get_media_image" attempts to connect, all others are either
called from there, or from the main "async_update" method (see previous
commit) which also attempts to connect.

So, this commit mainly revolves around gracefully handling situations
where no connection is available when trying to retrieve MPD state.

Finally, note the removal of "self._commands". This property is only
used at the start of "_async_get_file_image_response" and was thus
changed into a local variable.

* Update media-player control methods

These all need to explicitly connect to MPD as part of their flow.

* Correct ruff failure (auto-fixed)

* Use "async_timeout.timeout" context manager

* Minor changes

* Replace "async_timeout" with "asyncio.timeout"

* Initialise "self._status" to empty dictionary

Used to be initialised as None, which caused "NoneType is not iterable"
type of issues in case of an unexpected disconnect (at which point
status gets set to None again). Instead of guarding against None
everywhere, using an empty dictionary seemed more prudent...

Furthermore, more cautiously access its members to prevent potential
KeyError-s in similar cases.

* Fix livelock in "async_mute_volume()"

This method doesn't need a connection; it calls into two other methods
that actually connect to MPD – attempting to connect from here resulted
in a livelock.
This commit is contained in:
Thijs Putman 2023-11-27 18:35:46 +01:00 committed by GitHub
parent 2a4ab3d53d
commit 360ef894a7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

View File

@ -1,11 +1,13 @@
"""Support to interact with a Music Player Daemon."""
from __future__ import annotations
from contextlib import suppress
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
@ -92,11 +94,11 @@ class MpdDevice(MediaPlayerEntity):
self._name = name
self.password = password
self._status = None
self._status = {}
self._currentsong = None
self._playlists = None
self._currentplaylist = None
self._is_connected = False
self._is_available = None
self._muted = False
self._muted_volume = None
self._media_position_updated_at = None
@ -104,67 +106,88 @@ class MpdDevice(MediaPlayerEntity):
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
self._commands = None
# set up MPD client
self._client = MPDClient()
self._client.timeout = 30
self._client.idletimeout = None
self._client.idletimeout = 10
self._client_lock = asyncio.Lock()
async def _connect(self):
"""Connect to MPD."""
try:
await self._client.connect(self.server, self.port)
if self.password is not None:
await self._client.password(self.password)
except mpd.ConnectionError:
return
self._is_connected = True
def _disconnect(self):
"""Disconnect from MPD."""
with suppress(mpd.ConnectionError):
self._client.disconnect()
self._is_connected = False
self._status = None
async def _fetch_status(self):
"""Fetch status from MPD."""
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._media_position != position:
self._media_position_updated_at = dt_util.utcnow()
self._media_position = int(float(position))
await self._update_playlists()
@property
def available(self):
"""Return true if MPD is available and connected."""
return self._is_connected
# 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 asyncio.TimeoutError as error:
# TimeoutError has no message (which hinders logging further
# down the line), so provide one.
raise asyncio.TimeoutError(
"Connection attempt timed out"
) from error
if self.password is not None:
await self._client.password(self.password)
self._is_available = True
yield
except (
asyncio.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._is_available is not False:
log_level = logging.WARNING
_LOGGER.log(
log_level, "Error connecting to '%s': %s", self.server, error
)
self._is_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 and update the state."""
try:
if not self._is_connected:
await self._connect()
self._commands = list(await self._client.commands())
"""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()
await self._fetch_status()
except (mpd.ConnectionError, OSError, ValueError) as error:
# Cleanly disconnect in case connection is not in valid state
_LOGGER.debug("Error updating status: %s", error)
self._disconnect()
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._media_position != position:
self._media_position_updated_at = dt_util.utcnow()
self._media_position = int(float(position))
await self._update_playlists()
except (mpd.ConnectionError, ValueError) as error:
_LOGGER.debug("Error updating status: %s", error)
@property
def available(self) -> bool:
"""Return true if MPD is available and connected."""
return self._is_available is True
@property
def name(self):
@ -174,13 +197,13 @@ class MpdDevice(MediaPlayerEntity):
@property
def state(self) -> MediaPlayerState:
"""Return the media state."""
if self._status is None:
if not self._status:
return MediaPlayerState.OFF
if self._status["state"] == "play":
if self._status.get("state") == "play":
return MediaPlayerState.PLAYING
if self._status["state"] == "pause":
if self._status.get("state") == "pause":
return MediaPlayerState.PAUSED
if self._status["state"] == "stop":
if self._status.get("state") == "stop":
return MediaPlayerState.OFF
return MediaPlayerState.OFF
@ -259,20 +282,26 @@ class MpdDevice(MediaPlayerEntity):
async def async_get_media_image(self) -> tuple[bytes | None, str | None]:
"""Fetch media image of current playing track."""
if not (file := self._currentsong.get("file")):
return None, None
response = await self._async_get_file_image_response(file)
if response is None:
return None, None
async with self.connection():
if self._currentsong is None or not (file := self._currentsong.get("file")):
return None, None
image = bytes(response["binary"])
mime = response.get(
"type", "image/png"
) # readpicture has type, albumart does not
return (image, mime)
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:
@ -295,16 +324,21 @@ class MpdDevice(MediaPlayerEntity):
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
can_albumart = "albumart" in self._commands
can_readpicture = "readpicture" in self._commands
# 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:
response = await self._client.readpicture(file)
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(
@ -315,7 +349,8 @@ class MpdDevice(MediaPlayerEntity):
# read artwork contained in the media directory (cover.{jpg,png,tiff,bmp}) if none is embedded
if can_albumart and not response:
try:
response = await self._client.albumart(file)
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(
@ -339,7 +374,7 @@ class MpdDevice(MediaPlayerEntity):
@property
def supported_features(self) -> MediaPlayerEntityFeature:
"""Flag media player features that are supported."""
if self._status is None:
if not self._status:
return MediaPlayerEntityFeature(0)
supported = SUPPORT_MPD
@ -373,55 +408,64 @@ class MpdDevice(MediaPlayerEntity):
"""Update available MPD playlists."""
try:
self._playlists = []
for playlist_data in await self._client.listplaylists():
self._playlists.append(playlist_data["playlist"])
with suppress(mpd.ConnectionError):
for playlist_data in await self._client.listplaylists():
self._playlists.append(playlist_data["playlist"])
except mpd.CommandError as error:
self._playlists = 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."""
if "volume" in self._status:
await self._client.setvol(int(volume * 100))
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."""
if "volume" in self._status:
current_volume = int(self._status["volume"])
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)
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."""
if "volume" in self._status:
current_volume = int(self._status["volume"])
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)
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."""
if self._status["state"] == "pause":
await self._client.pause(0)
else:
await self._client.play()
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."""
await self._client.pause(1)
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."""
await self._client.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."""
await self._client.next()
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."""
await self._client.previous()
async with self.connection():
await self._client.previous()
async def async_mute_volume(self, mute: bool) -> None:
"""Mute. Emulated with set_volume_level."""
@ -437,75 +481,82 @@ class MpdDevice(MediaPlayerEntity):
self, media_type: MediaType | str, media_id: str, **kwargs: Any
) -> None:
"""Send the media player the command for playing a playlist."""
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)
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 media_id in self._playlists:
self._currentplaylist = media_id
if media_type == MediaType.PLAYLIST:
_LOGGER.debug("Playing playlist: %s", media_id)
if media_id in self._playlists:
self._currentplaylist = media_id
else:
self._currentplaylist = 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._currentplaylist = 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._currentplaylist = None
await self._client.add(media_id)
await self._client.play()
await self._client.add(media_id)
await self._client.play()
@property
def repeat(self) -> RepeatMode:
"""Return current repeat mode."""
if self._status["repeat"] == "1":
if self._status["single"] == "1":
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."""
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:
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["random"]))
return bool(int(self._status.get("random")))
async def async_set_shuffle(self, shuffle: bool) -> None:
"""Enable/disable shuffle mode."""
await self._client.random(int(shuffle))
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."""
await self._client.stop()
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."""
await self._client.play()
await self._update_playlists(no_throttle=True)
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."""
await self._client.clear()
async with self.connection():
await self._client.clear()
async def async_media_seek(self, position: float) -> None:
"""Send seek command."""
await self._client.seekcur(position)
async with self.connection():
await self._client.seekcur(position)
async def async_browse_media(
self,
@ -513,8 +564,11 @@ class MpdDevice(MediaPlayerEntity):
media_content_id: str | None = None,
) -> BrowseMedia:
"""Implement the websocket media browsing helper."""
return await media_source.async_browse_media(
self.hass,
media_content_id,
content_filter=lambda item: item.media_content_type.startswith("audio/"),
)
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/"
),
)