X-Git-Url: http://git.kaliko.me/?p=python-musicpd.git;a=blobdiff_plain;f=musicpd.py;h=d96e76a54387fcfb7d6ac8db9be83540252edf83;hp=fe14987dc54af0355a399b90bd8f3c294b35c28b;hb=9148a096c9939a89ecca79578e5fc2590f2dfd6f;hpb=e8daa719b31e6728f697fdc2812d969038ebe159 diff --git a/musicpd.py b/musicpd.py index fe14987..d96e76a 100644 --- a/musicpd.py +++ b/musicpd.py @@ -1,6 +1,8 @@ # python-musicpd: Python MPD client library +# Copyright (C) 2012-2021 kaliko +# Copyright (C) 2019 Naglis Jonaitis +# Copyright (C) 2019 Bart Van Loon # Copyright (C) 2008-2010 J. Alexander Treuman -# Copyright (C) 2012-2013 Kaliko Jack # # python-musicpd is free software: you can redistribute it and/or modify # it under the terms of the GNU Lesser General Public License as published by @@ -15,39 +17,70 @@ # You should have received a copy of the GNU Lesser General Public License # along with python-musicpd. If not, see . -# pylint: disable=C0111 - import socket +import os +from functools import wraps HELLO_PREFIX = "OK MPD " ERROR_PREFIX = "ACK " SUCCESS = "OK" NEXT = "list_OK" -VERSION = '0.4.1' +VERSION = '0.8.0b0' +#: Seconds before a connection attempt times out +#: (overriden by MPD_TIMEOUT env. var.) +CONNECTION_TIMEOUT = 30 +#: Socket timeout in second (Default is None for no timeout) +SOCKET_TIMEOUT = None + + +def iterator_wrapper(func): + """Decorator handling iterate option""" + @wraps(func) + def decorated_function(instance, *args, **kwargs): + generator = func(instance, *args, **kwargs) + if not instance.iterate: + return list(generator) + instance._iterating = True + + def iterator(gen): + try: + for item in gen: + yield item + finally: + instance._iterating = False + return iterator(generator) + return decorated_function class MPDError(Exception): pass + class ConnectionError(MPDError): pass + class ProtocolError(MPDError): pass + class CommandError(MPDError): pass + class CommandListError(MPDError): pass + class PendingCommandError(MPDError): pass + class IteratingError(MPDError): pass + class Range: def __init__(self, tpl): @@ -55,6 +88,8 @@ class Range: self._check() def __str__(self): + if len(self.tpl) == 0: + return ':' if len(self.tpl) == 1: return '{0}:'.format(self.tpl[0]) return '{0[0]}:{0[1]}'.format(self.tpl) @@ -65,25 +100,68 @@ class Range: def _check(self): if not isinstance(self.tpl, tuple): raise CommandError('Wrong type, provide a tuple') - if len(self.tpl) not in [1, 2]: - raise CommandError('length not in [1, 2]') + if len(self.tpl) not in [0, 1, 2]: + raise CommandError('length not in [0, 1, 2]') for index in self.tpl: try: index = int(index) - except (TypeError, ValueError): - raise CommandError('Not a tuple of int') + except (TypeError, ValueError) as err: + raise CommandError('Not a tuple of int') from err class _NotConnected: + def __getattr__(self, attr): return self._dummy - def _dummy(*args): + def _dummy(self, *args): raise ConnectionError("Not connected") + class MPDClient: + """MPDClient instance will look for ``MPD_HOST``/``MPD_PORT``/``XDG_RUNTIME_DIR`` environment + variables and set instance attribute ``host``, ``port`` and ``pwd`` + accordingly. Regarding ``MPD_HOST`` format to expose password refer + MPD client manual :manpage:`mpc (1)`. + + Then :py:obj:`musicpd.MPDClient.connect` will use ``host`` and ``port`` as defaults if not provided as args. + + Cf. :py:obj:`musicpd.MPDClient.connect` for details. + + >>> from os import environ + >>> environ['MPD_HOST'] = 'pass@mpdhost' + >>> cli = musicpd.MPDClient() + >>> cli.pwd == environ['MPD_HOST'].split('@')[0] + True + >>> cli.host == environ['MPD_HOST'].split('@')[1] + True + >>> cli.connect() # will use host/port as set in MPD_HOST/MPD_PORT + + :ivar str host: host used with the current connection + :ivar str,int port: port used with the current connection + :ivar str pwd: password detected in ``MPD_HOST`` environment variable + + .. warning:: Instance attribute host/port/pwd + + While :py:attr:`musicpd.MPDClient().host` and + :py:attr:`musicpd.MPDClient().port` keep track of current connection + host and port, :py:attr:`musicpd.MPDClient().pwd` is set once with + password extracted from environment variable. + Calling :py:meth:`musicpd.MPDClient().password()` with a new password + won't update :py:attr:`musicpd.MPDClient().pwd` value. + + Moreover, :py:attr:`musicpd.MPDClient().pwd` is only an helper attribute + exposing password extracted from ``MPD_HOST`` environment variable, it + will not be used as default value for the :py:meth:`password` method + """ + def __init__(self): self.iterate = False + #: Socket timeout value in seconds + self._socket_timeout = SOCKET_TIMEOUT + #: Current connection timeout value, defaults to + #: :py:obj:`CONNECTION_TIMEOUT` or env. var. ``MPD_TIMEOUT`` if provided + self.mpd_timeout = None self._reset() self._commands = { # Status Commands @@ -101,6 +179,7 @@ class MPDClient: "random": self._fetch_nothing, "repeat": self._fetch_nothing, "setvol": self._fetch_nothing, + "getvol": self._fetch_object, "single": self._fetch_nothing, "replay_gain_mode": self._fetch_nothing, "replay_gain_status": self._fetch_item, @@ -115,7 +194,7 @@ class MPDClient: "seekid": self._fetch_nothing, "seekcur": self._fetch_nothing, "stop": self._fetch_nothing, - # Playlist Commands + # Queue Commands "add": self._fetch_nothing, "addid": self._fetch_item, "clear": self._fetch_nothing, @@ -130,9 +209,14 @@ class MPDClient: "playlistsearch": self._fetch_songs, "plchanges": self._fetch_songs, "plchangesposid": self._fetch_changes, + "prio": self._fetch_nothing, + "prioid": self._fetch_nothing, + "rangeid": self._fetch_nothing, "shuffle": self._fetch_nothing, "swap": self._fetch_nothing, "swapid": self._fetch_nothing, + "addtagid": self._fetch_nothing, + "cleartagid": self._fetch_nothing, # Stored Playlist Commands "listplaylist": self._fetch_list, "listplaylistinfo": self._fetch_songs, @@ -146,19 +230,28 @@ class MPDClient: "rm": self._fetch_nothing, "save": self._fetch_nothing, # Database Commands + "albumart": self._fetch_composite, "count": self._fetch_object, + "getfingerprint": self._fetch_object, "find": self._fetch_songs, "findadd": self._fetch_nothing, "list": self._fetch_list, "listall": self._fetch_database, "listallinfo": self._fetch_database, + "listfiles": self._fetch_database, "lsinfo": self._fetch_database, + "readcomments": self._fetch_object, + "readpicture": self._fetch_composite, "search": self._fetch_songs, "searchadd": self._fetch_nothing, "searchaddpl": self._fetch_nothing, "update": self._fetch_item, "rescan": self._fetch_item, - "readcomments": self._fetch_object, + # Mounts and neighbors + "mount": self._fetch_nothing, + "unmount": self._fetch_nothing, + "listmounts": self._fetch_mounts, + "listneighbors": self._fetch_neighbors, # Sticker Commands "sticker get": self._fetch_item, "sticker set": self._fetch_nothing, @@ -170,15 +263,28 @@ class MPDClient: "kill": None, "password": self._fetch_nothing, "ping": self._fetch_nothing, + "binarylimit": self._fetch_nothing, + "tagtypes": self._fetch_list, + "tagtypes disable": self._fetch_nothing, + "tagtypes enable": self._fetch_nothing, + "tagtypes clear": self._fetch_nothing, + "tagtypes all": self._fetch_nothing, + # Partition Commands + "partition": self._fetch_nothing, + "listpartitions": self._fetch_list, + "newpartition": self._fetch_nothing, + "delpartition": self._fetch_nothing, + "moveoutput": self._fetch_nothing, # Audio Output Commands "disableoutput": self._fetch_nothing, "enableoutput": self._fetch_nothing, "toggleoutput": self._fetch_nothing, "outputs": self._fetch_outputs, + "outputset": self._fetch_nothing, # Reflection Commands + "config": self._fetch_object, "commands": self._fetch_list, "notcommands": self._fetch_list, - "tagtypes": self._fetch_list, "urlhandlers": self._fetch_list, "decoders": self._fetch_plugins, # Client to Client @@ -188,6 +294,43 @@ class MPDClient: "readmessages": self._fetch_messages, "sendmessage": self._fetch_nothing, } + self._get_envvars() + + def _get_envvars(self): + """ + Retrieve MPD env. var. to overrides "localhost:6600" + Use MPD_HOST/MPD_PORT if set + else use MPD_HOST=${XDG_RUNTIME_DIR:-/run/}/mpd/socket if file exists + """ + self.host = 'localhost' + self.pwd = None + self.port = os.getenv('MPD_PORT', '6600') + if os.getenv('MPD_HOST'): + # If password is set: MPD_HOST=pass@host + if '@' in os.getenv('MPD_HOST'): + mpd_host_env = os.getenv('MPD_HOST').split('@', 1) + if mpd_host_env[0]: + # A password is actually set + self.pwd = mpd_host_env[0] + if mpd_host_env[1]: + self.host = mpd_host_env[1] + elif mpd_host_env[1]: + # No password set but leading @ is an abstract socket + self.host = '@'+mpd_host_env[1] + else: + # MPD_HOST is a plain host + self.host = os.getenv('MPD_HOST') + else: + # Is socket there + xdg_runtime_dir = os.getenv('XDG_RUNTIME_DIR', '/run') + rundir = os.path.join(xdg_runtime_dir, 'mpd/socket') + if os.path.exists(rundir): + self.host = rundir + self.mpd_timeout = os.getenv('MPD_TIMEOUT') + if self.mpd_timeout and self.mpd_timeout.isdigit(): + self.mpd_timeout = int(self.mpd_timeout) + else: # Use CONNECTION_TIMEOUT as default even if MPD_TIMEOUT carries gargage + self.mpd_timeout = CONNECTION_TIMEOUT def __getattr__(self, attr): if attr == 'send_noidle': # have send_noidle to cancel idle as well as noidle @@ -204,8 +347,8 @@ class MPDClient: if command not in self._commands: command = command.replace("_", " ") if command not in self._commands: - raise AttributeError("'%s' object has no attribute '%s'" % - (self.__class__.__name__, attr)) + cls = self.__class__.__name__ + raise AttributeError(f"'{cls}' object has no attribute '{attr}'") return lambda *args: wrapper(command, args) def _send(self, command, args): @@ -217,36 +360,31 @@ class MPDClient: if retval is not None: self._pending.append(command) - def _fetch(self, command, args=None): + def _fetch(self, command, args=None): # pylint: disable=unused-argument + cmd_fmt = command.replace(" ", "_") if self._command_list is not None: - raise CommandListError("Cannot use fetch_%s in a command list" % - command.replace(" ", "_")) + raise CommandListError(f"Cannot use fetch_{cmd_fmt} in a command list") if self._iterating: - raise IteratingError("Cannot use fetch_%s while iterating" % - command.replace(" ", "_")) + raise IteratingError(f"Cannot use fetch_{cmd_fmt} while iterating") if not self._pending: raise PendingCommandError("No pending commands to fetch") if self._pending[0] != command: - raise PendingCommandError("'%s' is not the currently " - "pending command" % command) + raise PendingCommandError(f"'{command}' is not the currently pending command") del self._pending[0] retval = self._commands[command] if callable(retval): return retval() return retval - def _execute(self, command, args): + def _execute(self, command, args): # pylint: disable=unused-argument if self._iterating: - raise IteratingError("Cannot execute '%s' while iterating" % - command) + raise IteratingError(f"Cannot execute '{command}' while iterating") if self._pending: - raise PendingCommandError("Cannot execute '%s' with " - "pending commands" % command) + raise PendingCommandError(f"Cannot execute '{command}' with pending commands") retval = self._commands[command] if self._command_list is not None: if not callable(retval): - raise CommandListError("'%s' not allowed in command list" % - command) + raise CommandListError(f"'{command}' not allowed in command list") self._write_command(command, args) self._command_list.append(retval) else: @@ -254,9 +392,10 @@ class MPDClient: if callable(retval): return retval() return retval + return None def _write_line(self, line): - self._wfile.write("%s\n" % line) + self._wfile.write(f"{line!s}\n") self._wfile.flush() def _write_command(self, command, args=None): @@ -268,10 +407,26 @@ class MPDClient: parts.append('{0!s}'.format(Range(arg))) else: parts.append('"%s"' % escape(str(arg))) + if '\n' in ' '.join(parts): + raise CommandError('new line found in the command!') self._write_line(" ".join(parts)) - def _read_line(self): - line = self._rfile.readline() + def _read_binary(self, amount): + chunk = bytearray() + while amount > 0: + result = self._rbfile.read(amount) + if len(result) == 0: + self.disconnect() + raise ConnectionError("Connection lost while reading binary content") + chunk.extend(result) + amount -= len(result) + return bytes(chunk) + + def _read_line(self, binary=False): + if binary: + line = self._rbfile.readline().decode('utf-8') + else: + line = self._rfile.readline() if not line.endswith("\n"): self.disconnect() raise ConnectionError("Connection lost while reading line") @@ -281,35 +436,34 @@ class MPDClient: raise CommandError(error) if self._command_list is not None: if line == NEXT: - return + return None if line == SUCCESS: - raise ProtocolError("Got unexpected '%s'" % SUCCESS) + raise ProtocolError(f"Got unexpected '{SUCCESS}'") elif line == SUCCESS: - return + return None return line - def _read_pair(self, separator): - line = self._read_line() + def _read_pair(self, separator, binary=False): + line = self._read_line(binary=binary) if line is None: - return + return None pair = line.split(separator, 1) if len(pair) < 2: - raise ProtocolError("Could not parse pair: '%s'" % line) + raise ProtocolError(f"Could not parse pair: '{line}'") return pair - def _read_pairs(self, separator=": "): - pair = self._read_pair(separator) + def _read_pairs(self, separator=": ", binary=False): + pair = self._read_pair(separator, binary=binary) while pair: yield pair - pair = self._read_pair(separator) + pair = self._read_pair(separator, binary=binary) def _read_list(self): seen = None for key, value in self._read_pairs(): if key != seen: if seen is not None: - raise ProtocolError("Expected key '%s', got '%s'" % - (seen, key)) + raise ProtocolError(f"Expected key '{seen}', got '{key}'") seen = key yield value @@ -345,35 +499,24 @@ class MPDClient: self._command_list = None self._fetch_nothing() - def _iterator_wrapper(self, iterator): - try: - for item in iterator: - yield item - finally: - self._iterating = False - - def _wrap_iterator(self, iterator): - if not self.iterate: - return list(iterator) - self._iterating = True - return self._iterator_wrapper(iterator) - def _fetch_nothing(self): line = self._read_line() if line is not None: - raise ProtocolError("Got unexpected return value: '%s'" % line) + raise ProtocolError(f"Got unexpected return value: '{line}'") def _fetch_item(self): pairs = list(self._read_pairs()) if len(pairs) != 1: - return + return None return pairs[0][1] + @iterator_wrapper def _fetch_list(self): - return self._wrap_iterator(self._read_list()) + return self._read_list() + @iterator_wrapper def _fetch_playlist(self): - return self._wrap_iterator(self._read_playlist()) + return self._read_playlist() def _fetch_object(self): objs = list(self._read_objects()) @@ -381,8 +524,9 @@ class MPDClient: return {} return objs[0] + @iterator_wrapper def _fetch_objects(self, delimiters): - return self._wrap_iterator(self._read_objects(delimiters)) + return self._read_objects(delimiters) def _fetch_changes(self): return self._fetch_objects(["cpos"]) @@ -405,8 +549,41 @@ class MPDClient: def _fetch_messages(self): return self._fetch_objects(["channel"]) + def _fetch_mounts(self): + return self._fetch_objects(["mount"]) + + def _fetch_neighbors(self): + return self._fetch_objects(["neighbor"]) + + def _fetch_composite(self): + obj = {} + for key, value in self._read_pairs(binary=True): + key = key.lower() + obj[key] = value + if key == 'binary': + break + if not obj: + # If the song file was recognized, but there is no picture, the + # response is successful, but is otherwise empty. + return obj + amount = int(obj['binary']) + try: + obj['data'] = self._read_binary(amount) + except IOError as err: + raise ConnectionError(f'Error reading binary content: {err}') from err + data_bytes = len(obj['data']) + if data_bytes != amount: # can we ever get there? + raise ConnectionError('Error reading binary content: ' + f'Expects {amount}B, got {data_bytes}') + # Fetches trailing new line + self._read_line(binary=True) + # Fetches SUCCESS code + self._read_line(binary=True) + return obj + + @iterator_wrapper def _fetch_command_list(self): - return self._wrap_iterator(self._read_command_list()) + return self._read_command_list() def _hello(self): line = self._rfile.readline() @@ -414,25 +591,29 @@ class MPDClient: raise ConnectionError("Connection lost while reading MPD hello") line = line.rstrip("\n") if not line.startswith(HELLO_PREFIX): - raise ProtocolError("Got invalid MPD hello: '%s'" % line) + raise ProtocolError(f"Got invalid MPD hello: '{line}'") self.mpd_version = line[len(HELLO_PREFIX):].strip() def _reset(self): - # pylint: disable=w0201 self.mpd_version = None self._iterating = False self._pending = [] self._command_list = None self._sock = None self._rfile = _NotConnected() + self._rbfile = _NotConnected() self._wfile = _NotConnected() def _connect_unix(self, path): if not hasattr(socket, "AF_UNIX"): - raise ConnectionError("Unix domain sockets not supported " - "on this platform") + raise ConnectionError("Unix domain sockets not supported on this platform") + # abstract socket + if path.startswith('@'): + path = '\0'+path[1:] sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + sock.settimeout(self.mpd_timeout) sock.connect(path) + sock.settimeout(self.socket_timeout) return sock def _connect_tcp(self, host, port): @@ -448,7 +629,9 @@ class MPDClient: sock = None try: sock = socket.socket(af, socktype, proto) + sock.settimeout(self.mpd_timeout) sock.connect(sa) + sock.settimeout(self.socket_timeout) return sock except socket.error as socket_err: err = socket_err @@ -456,8 +639,7 @@ class MPDClient: sock.close() if err is not None: raise ConnectionError(str(err)) - else: - raise ConnectionError("getaddrinfo returns an empty list") + raise ConnectionError("getaddrinfo returns an empty list") def noidle(self): # noidle's special case @@ -467,14 +649,49 @@ class MPDClient: self._write_command("noidle") return self._fetch_list() - def connect(self, host, port): + def connect(self, host=None, port=None): + """Connects the MPD server + + :param str host: hostname, IP or FQDN (defaults to `localhost` or socket, see below for details) + :param port: port number (defaults to 6600) + :type port: str or int + + The connect method honors MPD_HOST/MPD_PORT environment variables. + + The underlying socket also honors MPD_TIMEOUT environment variable + and defaults to :py:obj:`musicpd.CONNECTION_TIMEOUT` (connect command only). + + If you want to have a timeout for each command once you got connected, + set its value in :py:obj:`MPDClient.socket_timeout` (in second) or at + module level in :py:obj:`musicpd.SOCKET_TIMEOUT`. + + .. note:: Default host/port + + If host evaluate to :py:obj:`False` + * use ``MPD_HOST`` environment variable if set, extract password if present, + * else looks for an existing file in ``${XDG_RUNTIME_DIR:-/run/}/mpd/socket`` + * else set host to ``localhost`` + + If port evaluate to :py:obj:`False` + * if ``MPD_PORT`` environment variable is set, use it for port + * else use ``6600`` + """ + if not host: + host = self.host + else: + self.host = host + if not port: + port = self.port + else: + self.port = port if self._sock is not None: raise ConnectionError("Already connected") - if host.startswith("/"): + if host[0] in ['/', '@']: self._sock = self._connect_unix(host) else: self._sock = self._connect_tcp(host, port) - self._rfile = self._sock.makefile("r", encoding='utf-8') + self._rfile = self._sock.makefile("r", encoding='utf-8', errors='surrogateescape') + self._rbfile = self._sock.makefile("rb") self._wfile = self._sock.makefile("w", encoding='utf-8') try: self._hello() @@ -482,9 +699,27 @@ class MPDClient: self.disconnect() raise + @property + def socket_timeout(self): + """Socket timeout in second (defaults to :py:obj:`SOCKET_TIMEOUT`). + Use None to disable socket timout.""" + return self._socket_timeout + + @socket_timeout.setter + def socket_timeout(self, timeout): + self._socket_timeout = timeout + if getattr(self._sock, 'settimeout', False): + self._sock.settimeout(self._socket_timeout) + def disconnect(self): + """Closes the MPD connection. + The client closes the actual socket, it does not use the + 'close' request from MPD protocol (as suggested in documentation). + """ if hasattr(self._rfile, 'close'): self._rfile.close() + if hasattr(self._rbfile, 'close'): + self._rbfile.close() if hasattr(self._wfile, 'close'): self._wfile.close() if hasattr(self._sock, 'close'): @@ -492,6 +727,9 @@ class MPDClient: self._reset() def fileno(self): + """Return the socket’s file descriptor (a small integer). + This is useful with :py:obj:`select.select`. + """ if self._sock is None: raise ConnectionError("Not connected") return self._sock.fileno() @@ -502,8 +740,7 @@ class MPDClient: if self._iterating: raise IteratingError("Cannot begin command list while iterating") if self._pending: - raise PendingCommandError("Cannot begin command list " - "with pending commands") + raise PendingCommandError("Cannot begin command list with pending commands") self._write_command("command_list_ok_begin") self._command_list = [] @@ -519,5 +756,4 @@ class MPDClient: def escape(text): return text.replace("\\", "\\\\").replace('"', '\\"') - # vim: set expandtab shiftwidth=4 softtabstop=4 textwidth=79: