Source code for libqtile.widget.mpd2widget

"""
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 socket import error as socket_error

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}]'


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}]' idle_message: text to display instead of song information when MPD is idle. (i.e. no song in queue) Default:: "MPD IDLE" 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/ """ orientations = base.ORIENTATION_HORIZONTAL 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.'), ('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__(None, **config) self.add_defaults(Mpd2.defaults) self.client = MPDClient() self.client.timeout = self.timeout self.client.idletimeout = self.idletimeout if self.color_progress: self.color_progress = utils.hex(self.color_progress) @property def connected(self): """Attempt connection to mpd server.""" try: self.client.ping() # pylint: disable=E1101 except(socket_error, ConnectionError): try: self.client.connect(self.host, self.port) if self.password: self.client.password(self.password) # pylint: disable=E1101 except(socket_error, 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(err + " {}".format(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.""" default = 'Undefined' song_info = defaultdict(lambda: default) 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'] == default: 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'] != default else 0.0 elapsed = float(song_info['elapsed'])\ if song_info['elapsed'] != default else 0.0 song_info['remaining'] = "{:.2f}".format(float(total - elapsed)) # 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 = '<span color="{0}">{1}</span>{2}'.format( self.color_progress, formatted[:progress], 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