"""
A widget for Music Player Daemon (MPD) based on python-mpd2.
This widget exists since python-mpd library is no longer supported.
"""
from collections import defaultdict
from html import escape
from mpd import CommandError, ConnectionError, MPDClient
from libqtile import utils
from libqtile.log_utils import logger
from libqtile.widget import base
# Mouse Interaction
# TODO: Volume inc/dec support
keys = {
# Left mouse button
1: "toggle",
# Right mouse button
3: "stop",
# Scroll up
4: "previous",
# Scroll down
5: "next",
}
# To display mpd state
play_states = {"play": "\u25b6", "pause": "\u23f8", "stop": "\u25a0"}
def option(char):
"""
old status mapping method.
Deprecated.
"""
def _convert(elements, key, space):
if key in elements and elements[key] != "0":
elements[key] = char
else:
elements[key] = space
return _convert
# Changes to formatter will still use this dicitionary as a fallback
prepare_status = {
"repeat": option("r"),
"random": option("z"),
"single": option("1"),
"consume": option("c"),
"updating_db": option("U"),
}
# dictionary for new formatting method. This is now default.
status_dict = {"repeat": "r", "random": "z", "single": "1", "consume": "c", "updating_db": "U"}
default_idle_message = "MPD IDLE"
default_idle_format = (
"{play_status} {idle_message}" + "[{repeat}{random}{single}{consume}{updating_db}]"
)
default_format = (
"{play_status} {artist}/{title} " + "[{repeat}{random}{single}{consume}{updating_db}]"
)
default_undefined_status_value = "Undefined"
def default_cmd():
return None
format_fns = {
"all": escape,
}
[docs]class Mpd2(base.ThreadPoolText):
r"""Mpd2 Object.
Parameters
==========
status_format:
format string to display status
For a full list of values, see:
MPDClient.status() and MPDClient.currentsong()
https://musicpd.org/doc/protocol/command_reference.html#command_status
https://musicpd.org/doc/protocol/tags.html
Default::
'{play_status} {artist}/{title} \
[{repeat}{random}{single}{consume}{updating_db}]'
``play_status`` is a string from ``play_states`` dict
Note that the ``time`` property of the song renamed to ``fulltime``
to prevent conflicts with status information during formating.
idle_format:
format string to display status when no song is in queue.
Default::
'{play_status} {idle_message} \
[{repeat}{random}{single}{consume}{updating_db}]'
Note that the ``artist`` key fallbacks to similar keys in specific order.
(``artist`` -> ``albumartist`` -> ``performer`` ->
-> ``composer`` -> ``conductor`` -> ``ensemble``)
idle_message:
text to display instead of song information when MPD is idle.
(i.e. no song in queue)
Default:: "MPD IDLE"
undefined_value:
text to display when status key is undefined
Default:: "Undefined"
prepare_status:
dict of functions to replace values in status with custom characters.
``f(status, key, space_element) => str``
New functionality allows use of a dictionary of plain strings.
Default::
status_dict = {
'repeat': 'r',
'random': 'z',
'single': '1',
'consume': 'c',
'updating_db': 'U'
}
format_fns:
A dict of functions to format the various elements.
'Tag': f(str) => str
Default:: { 'all': lambda s: cgi.escape(s) }
N.B. if 'all' is present, it is processed on every element of song_info
before any other formatting is done.
mouse_buttons:
A dict of mouse button numbers to actions
Widget requirements: python-mpd2_.
.. _python-mpd2: https://pypi.org/project/python-mpd2/
"""
defaults = [
("update_interval", 1, "Interval of update widget"),
("host", "localhost", "Host of mpd server"),
("port", 6600, "Port of mpd server"),
("password", None, "Password for auth on mpd server"),
("mouse_buttons", keys, "b_num -> action."),
("play_states", play_states, "Play state mapping"),
("format_fns", format_fns, "Dictionary of format methods"),
("command", default_cmd, "command to be executed by mapped mouse button."),
("prepare_status", status_dict, "characters to show the status of MPD"),
("status_format", default_format, "format for displayed song info."),
("idle_format", default_idle_format, "format for status when mpd has no playlist."),
("idle_message", default_idle_message, "text to display when mpd is idle."),
(
"undefined_value",
default_undefined_status_value,
"text to display when status key is undefined.",
),
("timeout", 30, "MPDClient timeout"),
("idletimeout", 5, "MPDClient idle command timeout"),
("no_connection", "No connection", "Text when mpd is disconnected"),
("color_progress", None, "Text color to indicate track progress."),
("space", "-", "Space keeper"),
]
def __init__(self, **config):
"""Constructor."""
super().__init__("", **config)
self.add_defaults(Mpd2.defaults)
if self.color_progress:
self.color_progress = utils.hex(self.color_progress)
def _configure(self, qtile, bar):
super()._configure(qtile, bar)
self.client = MPDClient()
self.client.timeout = self.timeout
self.client.idletimeout = self.idletimeout
@property
def connected(self):
"""Attempt connection to mpd server."""
try:
self.client.ping() # pylint: disable=E1101
except (OSError, ConnectionError):
try:
self.client.connect(self.host, self.port)
if self.password:
self.client.password(self.password) # pylint: disable=E1101
except (OSError, ConnectionError, CommandError):
return False
return True
def poll(self):
"""
Called by qtile manager.
poll the mpd server and update widget.
"""
if self.connected:
return self.update_status()
else:
return self.no_connection
def update_status(self):
"""get updated info from mpd server and call format."""
self.client.command_list_ok_begin()
self.client.status() # pylint: disable=E1101
self.client.currentsong() # pylint: disable=E1101
status, current_song = self.client.command_list_end()
return self.formatter(status, current_song)
def button_press(self, x, y, button):
"""handle click event on widget."""
base.ThreadPoolText.button_press(self, x, y, button)
m_name = self.mouse_buttons[button]
if self.connected:
if hasattr(self, m_name):
self.__try_call(m_name)
elif hasattr(self.client, m_name):
self.__try_call(m_name, self.client)
def __try_call(self, attr_name, obj=None):
err1 = "Class {Class} has no attribute {attr}."
err2 = 'attribute "{Class}.{attr}" is not callable.'
context = obj or self
try:
getattr(context, attr_name)()
except (AttributeError, TypeError) as e:
if isinstance(e, AttributeError):
err = err1.format(Class=type(context).__name__, attr=attr_name)
else:
err = err2.format(Class=type(context).__name__, attr=attr_name)
logger.exception("%s %s", err, e.args[0])
def toggle(self):
"""toggle play/pause."""
status = self.client.status() # pylint: disable=E1101
play_status = status["state"]
if play_status == "play":
self.client.pause() # pylint: disable=E1101
else:
self.client.play() # pylint: disable=E1101
def formatter(self, status, current_song):
"""format song info."""
song_info = defaultdict(lambda: self.undefined_value)
song_info["play_status"] = self.play_states[status["state"]]
if status["state"] == "stop" and current_song == {}:
song_info["idle_message"] = self.idle_message
fmt = self.idle_format
else:
fmt = self.status_format
for k in current_song:
song_info[k] = current_song[k]
song_info["fulltime"] = song_info["time"]
del song_info["time"]
song_info.update(status)
if song_info["updating_db"] == self.undefined_value:
song_info["updating_db"] = "0"
if not callable(self.prepare_status["repeat"]):
for k in self.prepare_status:
if k in status and status[k] != "0":
# Much more direct.
song_info[k] = self.prepare_status[k]
else:
song_info[k] = self.space
else:
self.prepare_formatting(song_info)
# 'remaining' isn't actually in the information provided by mpd
# so we construct it from 'fulltime' and 'elapsed'.
# 'elapsed' is always less than or equal to 'fulltime', if it exists.
# Remaining should default to '00:00' if either or both are missing.
# These values are also used for coloring text by progress, if wanted.
if "remaining" in self.status_format or self.color_progress:
total = (
float(song_info["fulltime"])
if song_info["fulltime"] != self.undefined_value
else 0.0
)
elapsed = (
float(song_info["elapsed"])
if song_info["elapsed"] != self.undefined_value
else 0.0
)
song_info["remaining"] = f"{float(total - elapsed):.2f}"
if "song" in self.status_format and song_info["song"] != self.undefined_value:
song_info["currentsong"] = str(int(song_info["song"]) + 1)
if "artist" in self.status_format and song_info["artist"] == self.undefined_value:
artist_keys = ("albumartist", "performer", "composer", "conductor", "ensemble")
for key in artist_keys:
if song_info[key] != self.undefined_value:
song_info["artist"] = song_info[key]
break
# mpd serializes tags containing commas as lists.
for key in song_info:
if isinstance(song_info[key], list):
song_info[key] = ", ".join(song_info[key])
# Now we apply the user formatting to selected elements in song_info.
# if 'all' is defined, it is applied first.
# the reason for this is that, if the format functions do pango markup.
# we don't want to do anything that would mess it up, e.g. `escape`ing.
if "all" in self.format_fns:
for key in song_info:
song_info[key] = self.format_fns["all"](song_info[key])
for fmt_fn in self.format_fns:
if fmt_fn in song_info and fmt_fn != "all":
song_info[fmt_fn] = self.format_fns[fmt_fn](song_info[fmt_fn])
# fmt = self.status_format
if not isinstance(fmt, str):
fmt = str(fmt)
formatted = fmt.format_map(song_info)
if self.color_progress and status["state"] != "stop":
try:
progress = int(len(formatted) * elapsed / total)
formatted = f'<span color="{self.color_progress}">{formatted[:progress]}</span>{formatted[progress:]}'
except (ZeroDivisionError, ValueError):
pass
return formatted
def prepare_formatting(self, status):
"""old way of preparing status formatting."""
for key in self.prepare_status:
self.prepare_status[key](status, key, self.space)
def finalize(self):
"""finalize."""
super().finalize()
try:
self.client.close() # pylint: disable=E1101
self.client.disconnect()
except ConnectionError:
pass