1 # -*- coding: utf-8 -*-
2 # SPDX-FileCopyrightText: 2012-2024 kaliko <kaliko@azylum.org>
3 # SPDX-FileCopyrightText: 2021 Wonko der Verständige <wonko@hanstool.org>
4 # SPDX-FileCopyrightText: 2019 Naglis Jonaitis <naglis@mailbox.org>
5 # SPDX-FileCopyrightText: 2019 Bart Van Loon <bbb@bbbart.be>
6 # SPDX-FileCopyrightText: 2008-2010 J. Alexander Treuman <jat@spatialrift.net>
7 # SPDX-License-Identifier: LGPL-3.0-or-later
8 """Python Music Player Daemon client library"""
15 from functools import wraps
17 HELLO_PREFIX = "OK MPD "
23 #: Seconds before a connection attempt times out
24 #: (overriden by :envvar:`MPD_TIMEOUT` env. var.)
25 CONNECTION_TIMEOUT = 30
26 #: Socket timeout in second > 0 (Default is :py:obj:`None` for no timeout)
29 log = logging.getLogger(__name__)
32 def iterator_wrapper(func):
33 """Decorator handling iterate option"""
35 def decorated_function(instance, *args, **kwargs):
36 generator = func(instance, *args, **kwargs)
37 if not instance.iterate:
38 return list(generator)
39 instance._iterating = True
46 instance._iterating = False
47 return iterator(generator)
48 return decorated_function
51 class MPDError(Exception):
52 """Main musicpd Exception"""
55 class ConnectionError(MPDError):
56 """Fatal Connection Error, cannot recover from it."""
59 class ProtocolError(MPDError):
60 """Fatal Protocol Error, cannot recover from it"""
63 class CommandError(MPDError):
64 """Malformed command, socket should be fine, can reuse it"""
67 class CommandListError(MPDError):
71 class PendingCommandError(MPDError):
75 class IteratingError(MPDError):
81 def __init__(self, tpl):
88 return f'{self.lower}:{self.upper}'
91 return f'Range({self.tpl})'
93 def _check_element(self, item):
94 if item is None or item == '':
98 except (TypeError, ValueError) as err:
99 raise CommandError(f'Not an integer: "{item}"') from err
103 if not isinstance(self.tpl, tuple):
104 raise CommandError('Wrong type, provide a tuple')
105 if len(self.tpl) == 0:
107 if len(self.tpl) == 1:
108 self.lower = self._check_element(self.tpl[0])
110 if len(self.tpl) != 2:
111 raise CommandError('Range wrong size (0, 1 or 2 allowed)')
112 self.lower = self._check_element(self.tpl[0])
113 self.upper = self._check_element(self.tpl[1])
114 if self.lower == '' and self.upper != '':
115 raise CommandError(f'Integer expected to start the range: {self.tpl}')
116 if self.upper.isdigit() and self.lower.isdigit():
117 if int(self.lower) > int(self.upper):
118 raise CommandError(f'Wrong range: {self.lower} > {self.upper}')
123 def __getattr__(self, attr):
126 def _dummy(self, *args):
127 raise ConnectionError("Not connected")
131 """MPDClient instance will look for :envvar:`MPD_HOST`/:envvar:`MPD_PORT`/:envvar:`XDG_RUNTIME_DIR` environment
132 variables and set instance attribute :py:attr:`host`, :py:attr:`port` and :py:obj:`pwd`
135 Then :py:obj:`musicpd.MPDClient.connect` will use :py:obj:`host` and
136 :py:obj:`port` as defaults if not provided as args.
138 Regarding :envvar:`MPD_HOST` format to expose password refer this module
139 documentation or MPD client manual :manpage:`mpc (1)`.
141 >>> from os import environ
142 >>> environ['MPD_HOST'] = 'pass@mpdhost'
143 >>> cli = musicpd.MPDClient()
144 >>> cli.pwd == environ['MPD_HOST'].split('@')[0]
146 >>> cli.host == environ['MPD_HOST'].split('@')[1]
148 >>> cli.connect() # will use host/port as set in MPD_HOST/MPD_PORT
153 * use :envvar:`MPD_HOST` environment variable if set, extract password if present,
154 * else use :envvar:`XDG_RUNTIME_DIR` to looks for an existing file in ``${XDG_RUNTIME_DIR:-/run/}/mpd/socket``
155 * else set host to ``localhost``
158 * use :envvar:`MPD_PORT` environment variable is set
161 .. warning:: **Instance attribute host/port/pwd**
163 While :py:attr:`musicpd.MPDClient.host` and
164 :py:attr:`musicpd.MPDClient.port` keep track of current connection
165 host and port, :py:attr:`musicpd.MPDClient.pwd` is set once with
166 password extracted from environment variable.
167 Calling MPS's password method with a new password
168 won't update :py:attr:`musicpd.MPDClient.pwd` value.
170 Moreover, :py:attr:`musicpd.MPDClient.pwd` is only an helper attribute
171 exposing password extracted from :envvar:`MPD_HOST` environment variable, it
172 will not be used as default value for the MPD's password command.
177 #: Socket timeout value in seconds
178 self._socket_timeout = SOCKET_TIMEOUT
179 #: Current connection timeout value, defaults to
180 #: :py:obj:`CONNECTION_TIMEOUT` or env. var. ``MPD_TIMEOUT`` if provided
181 self.mpd_timeout = None
182 self.mpd_version = ''
183 """Protocol version as exposed by the server as a :py:obj:`str`
185 .. note:: This is the version of the protocol spoken, not the real version of the daemon."""
189 "clearerror": self._fetch_nothing,
190 "currentsong": self._fetch_object,
191 "idle": self._fetch_list,
193 "status": self._fetch_object,
194 "stats": self._fetch_object,
195 # Playback Option Commands
196 "consume": self._fetch_nothing,
197 "crossfade": self._fetch_nothing,
198 "mixrampdb": self._fetch_nothing,
199 "mixrampdelay": self._fetch_nothing,
200 "random": self._fetch_nothing,
201 "repeat": self._fetch_nothing,
202 "setvol": self._fetch_nothing,
203 "getvol": self._fetch_object,
204 "single": self._fetch_nothing,
205 "replay_gain_mode": self._fetch_nothing,
206 "replay_gain_status": self._fetch_item,
207 "volume": self._fetch_nothing,
208 # Playback Control Commands
209 "next": self._fetch_nothing,
210 "pause": self._fetch_nothing,
211 "play": self._fetch_nothing,
212 "playid": self._fetch_nothing,
213 "previous": self._fetch_nothing,
214 "seek": self._fetch_nothing,
215 "seekid": self._fetch_nothing,
216 "seekcur": self._fetch_nothing,
217 "stop": self._fetch_nothing,
219 "add": self._fetch_nothing,
220 "addid": self._fetch_item,
221 "clear": self._fetch_nothing,
222 "delete": self._fetch_nothing,
223 "deleteid": self._fetch_nothing,
224 "move": self._fetch_nothing,
225 "moveid": self._fetch_nothing,
226 "playlist": self._fetch_playlist,
227 "playlistfind": self._fetch_songs,
228 "playlistid": self._fetch_songs,
229 "playlistinfo": self._fetch_songs,
230 "playlistsearch": self._fetch_songs,
231 "plchanges": self._fetch_songs,
232 "plchangesposid": self._fetch_changes,
233 "prio": self._fetch_nothing,
234 "prioid": self._fetch_nothing,
235 "rangeid": self._fetch_nothing,
236 "shuffle": self._fetch_nothing,
237 "swap": self._fetch_nothing,
238 "swapid": self._fetch_nothing,
239 "addtagid": self._fetch_nothing,
240 "cleartagid": self._fetch_nothing,
241 # Stored Playlist Commands
242 "listplaylist": self._fetch_list,
243 "listplaylistinfo": self._fetch_songs,
244 "listplaylists": self._fetch_playlists,
245 "load": self._fetch_nothing,
246 "playlistadd": self._fetch_nothing,
247 "playlistclear": self._fetch_nothing,
248 "playlistdelete": self._fetch_nothing,
249 "playlistmove": self._fetch_nothing,
250 "rename": self._fetch_nothing,
251 "rm": self._fetch_nothing,
252 "save": self._fetch_nothing,
254 "albumart": self._fetch_composite,
255 "count": self._fetch_object,
256 "getfingerprint": self._fetch_object,
257 "find": self._fetch_songs,
258 "findadd": self._fetch_nothing,
259 "list": self._fetch_list,
260 "listall": self._fetch_database,
261 "listallinfo": self._fetch_database,
262 "listfiles": self._fetch_database,
263 "lsinfo": self._fetch_database,
264 "readcomments": self._fetch_object,
265 "readpicture": self._fetch_composite,
266 "search": self._fetch_songs,
267 "searchadd": self._fetch_nothing,
268 "searchaddpl": self._fetch_nothing,
269 "update": self._fetch_item,
270 "rescan": self._fetch_item,
271 # Mounts and neighbors
272 "mount": self._fetch_nothing,
273 "unmount": self._fetch_nothing,
274 "listmounts": self._fetch_mounts,
275 "listneighbors": self._fetch_neighbors,
277 "sticker get": self._fetch_item,
278 "sticker set": self._fetch_nothing,
279 "sticker delete": self._fetch_nothing,
280 "sticker list": self._fetch_list,
281 "sticker find": self._fetch_songs,
282 # Connection Commands
285 "password": self._fetch_nothing,
286 "ping": self._fetch_nothing,
287 "binarylimit": self._fetch_nothing,
288 "tagtypes": self._fetch_list,
289 "tagtypes disable": self._fetch_nothing,
290 "tagtypes enable": self._fetch_nothing,
291 "tagtypes clear": self._fetch_nothing,
292 "tagtypes all": self._fetch_nothing,
294 "partition": self._fetch_nothing,
295 "listpartitions": self._fetch_list,
296 "newpartition": self._fetch_nothing,
297 "delpartition": self._fetch_nothing,
298 "moveoutput": self._fetch_nothing,
299 # Audio Output Commands
300 "disableoutput": self._fetch_nothing,
301 "enableoutput": self._fetch_nothing,
302 "toggleoutput": self._fetch_nothing,
303 "outputs": self._fetch_outputs,
304 "outputset": self._fetch_nothing,
305 # Reflection Commands
306 "config": self._fetch_object,
307 "commands": self._fetch_list,
308 "notcommands": self._fetch_list,
309 "urlhandlers": self._fetch_list,
310 "decoders": self._fetch_plugins,
312 "subscribe": self._fetch_nothing,
313 "unsubscribe": self._fetch_nothing,
314 "channels": self._fetch_list,
315 "readmessages": self._fetch_messages,
316 "sendmessage": self._fetch_nothing,
318 #: host used with the current connection (:py:obj:`str`)
320 #: password detected in :envvar:`MPD_HOST` environment variable (:py:obj:`str`)
322 #: port used with the current connection (:py:obj:`int`, :py:obj:`str`)
326 def _get_envvars(self):
328 Retrieve MPD env. var. to overrides default "localhost:6600"
331 self.host = 'localhost'
332 self.port = os.getenv('MPD_PORT', '6600')
333 _host = os.getenv('MPD_HOST', '')
335 # If password is set: MPD_HOST=pass@host
337 mpd_host_env = _host.split('@', 1)
339 # A password is actually set
340 log.debug('password detected in MPD_HOST, set client pwd attribute')
341 self.pwd = mpd_host_env[0]
343 self.host = mpd_host_env[1]
344 log.debug('host detected in MPD_HOST: %s', self.host)
345 elif mpd_host_env[1]:
346 # No password set but leading @ is an abstract socket
347 self.host = '@'+mpd_host_env[1]
348 log.debug('host detected in MPD_HOST: %s (abstract socket)', self.host)
350 # MPD_HOST is a plain host
352 log.debug('host detected in MPD_HOST: %s', self.host)
355 xdg_runtime_dir = os.getenv('XDG_RUNTIME_DIR', '/run')
356 rundir = os.path.join(xdg_runtime_dir, 'mpd/socket')
357 if os.path.exists(rundir):
359 log.debug('host detected in ${XDG_RUNTIME_DIR}/run: %s (unix socket)', self.host)
360 _mpd_timeout = os.getenv('MPD_TIMEOUT', '')
361 if _mpd_timeout.isdigit():
362 self.mpd_timeout = int(_mpd_timeout)
363 log.debug('timeout detected in MPD_TIMEOUT: %d', self.mpd_timeout)
364 else: # Use CONNECTION_TIMEOUT as default even if MPD_TIMEOUT carries gargage
365 self.mpd_timeout = CONNECTION_TIMEOUT
367 def __getattr__(self, attr):
368 if attr == 'send_noidle': # have send_noidle to cancel idle as well as noidle
370 if attr.startswith("send_"):
371 command = attr.replace("send_", "", 1)
373 elif attr.startswith("fetch_"):
374 command = attr.replace("fetch_", "", 1)
375 wrapper = self._fetch
378 wrapper = self._execute
379 if command not in self._commands:
380 command = command.replace("_", " ")
381 if command not in self._commands:
382 cls = self.__class__.__name__
383 raise AttributeError(f"'{cls}' object has no attribute '{attr}'")
384 return lambda *args: wrapper(command, args)
386 def _send(self, command, args):
387 if self._command_list is not None:
388 raise CommandListError("Cannot use send_%s in a command list" %
389 command.replace(" ", "_"))
390 self._write_command(command, args)
391 retval = self._commands[command]
392 if retval is not None:
393 self._pending.append(command)
395 def _fetch(self, command, args=None): # pylint: disable=unused-argument
396 cmd_fmt = command.replace(" ", "_")
397 if self._command_list is not None:
398 raise CommandListError(f"Cannot use fetch_{cmd_fmt} in a command list")
400 raise IteratingError(f"Cannot use fetch_{cmd_fmt} while iterating")
401 if not self._pending:
402 raise PendingCommandError("No pending commands to fetch")
403 if self._pending[0] != command:
404 raise PendingCommandError(f"'{command}' is not the currently pending command")
406 retval = self._commands[command]
411 def _execute(self, command, args): # pylint: disable=unused-argument
413 raise IteratingError(f"Cannot execute '{command}' while iterating")
415 raise PendingCommandError(f"Cannot execute '{command}' with pending commands")
416 retval = self._commands[command]
417 if self._command_list is not None:
418 if not callable(retval):
419 raise CommandListError(f"'{command}' not allowed in command list")
420 self._write_command(command, args)
421 self._command_list.append(retval)
423 self._write_command(command, args)
429 def _write_line(self, line):
430 self._wfile.write(f"{line!s}\n")
433 def _write_command(self, command, args=None):
438 if isinstance(arg, tuple):
439 parts.append(f'{Range(arg)!s}')
441 parts.append(f'"{escape(str(arg))}"')
442 if '\n' in ' '.join(parts):
443 raise CommandError('new line found in the command!')
444 self._write_line(" ".join(parts))
446 def _read_binary(self, amount):
449 result = self._rbfile.read(amount)
452 raise ConnectionError("Connection lost while reading binary content")
454 amount -= len(result)
457 def _read_line(self, binary=False):
459 line = self._rbfile.readline().decode('utf-8')
461 line = self._rfile.readline()
462 if not line.endswith("\n"):
464 raise ConnectionError("Connection lost while reading line")
465 line = line.rstrip("\n")
466 if line.startswith(ERROR_PREFIX):
467 error = line[len(ERROR_PREFIX):].strip()
468 raise CommandError(error)
469 if self._command_list is not None:
473 raise ProtocolError(f"Got unexpected '{SUCCESS}'")
474 elif line == SUCCESS:
478 def _read_pair(self, separator, binary=False):
479 line = self._read_line(binary=binary)
482 pair = line.split(separator, 1)
484 raise ProtocolError(f"Could not parse pair: '{line}'")
487 def _read_pairs(self, separator=": ", binary=False):
488 pair = self._read_pair(separator, binary=binary)
491 pair = self._read_pair(separator, binary=binary)
493 def _read_list(self):
495 for key, value in self._read_pairs():
498 raise ProtocolError(f"Expected key '{seen}', got '{key}'")
502 def _read_playlist(self):
503 for _, value in self._read_pairs(":"):
506 def _read_objects(self, delimiters=None):
508 if delimiters is None:
510 for key, value in self._read_pairs():
513 if key in delimiters:
517 if not isinstance(obj[key], list):
518 obj[key] = [obj[key], value]
520 obj[key].append(value)
526 def _read_command_list(self):
528 for retval in self._command_list:
531 self._command_list = None
532 self._fetch_nothing()
534 def _fetch_nothing(self):
535 line = self._read_line()
537 raise ProtocolError(f"Got unexpected return value: '{line}'")
539 def _fetch_item(self):
540 pairs = list(self._read_pairs())
546 def _fetch_list(self):
547 return self._read_list()
550 def _fetch_playlist(self):
551 return self._read_playlist()
553 def _fetch_object(self):
554 objs = list(self._read_objects())
560 def _fetch_objects(self, delimiters):
561 return self._read_objects(delimiters)
563 def _fetch_changes(self):
564 return self._fetch_objects(["cpos"])
566 def _fetch_songs(self):
567 return self._fetch_objects(["file"])
569 def _fetch_playlists(self):
570 return self._fetch_objects(["playlist"])
572 def _fetch_database(self):
573 return self._fetch_objects(["file", "directory", "playlist"])
575 def _fetch_outputs(self):
576 return self._fetch_objects(["outputid"])
578 def _fetch_plugins(self):
579 return self._fetch_objects(["plugin"])
581 def _fetch_messages(self):
582 return self._fetch_objects(["channel"])
584 def _fetch_mounts(self):
585 return self._fetch_objects(["mount"])
587 def _fetch_neighbors(self):
588 return self._fetch_objects(["neighbor"])
590 def _fetch_composite(self):
592 for key, value in self._read_pairs(binary=True):
598 # If the song file was recognized, but there is no picture, the
599 # response is successful, but is otherwise empty.
601 amount = int(obj['binary'])
603 obj['data'] = self._read_binary(amount)
604 except IOError as err:
605 raise ConnectionError(f'Error reading binary content: {err}') from err
606 data_bytes = len(obj['data'])
607 if data_bytes != amount: # can we ever get there?
608 raise ConnectionError('Error reading binary content: '
609 f'Expects {amount}B, got {data_bytes}')
610 # Fetches trailing new line
611 self._read_line(binary=True)
612 # Fetches SUCCESS code
613 self._read_line(binary=True)
617 def _fetch_command_list(self):
618 return self._read_command_list()
621 line = self._rfile.readline()
622 if not line.endswith("\n"):
623 raise ConnectionError("Connection lost while reading MPD hello")
624 line = line.rstrip("\n")
625 if not line.startswith(HELLO_PREFIX):
626 raise ProtocolError(f"Got invalid MPD hello: '{line}'")
627 self.mpd_version = line[len(HELLO_PREFIX):].strip()
630 self.mpd_version = ''
631 self._iterating = False
633 self._command_list = None
635 self._rfile = _NotConnected()
636 self._rbfile = _NotConnected()
637 self._wfile = _NotConnected()
639 def _connect_unix(self, path):
640 if not hasattr(socket, "AF_UNIX"):
641 raise ConnectionError("Unix domain sockets not supported on this platform")
643 if path.startswith('@'):
646 sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
647 sock.settimeout(self.mpd_timeout)
649 sock.settimeout(self.socket_timeout)
650 except socket.error as socket_err:
651 raise ConnectionError(socket_err) from socket_err
654 def _connect_tcp(self, host, port):
656 flags = socket.AI_ADDRCONFIG
657 except AttributeError:
661 gai = socket.getaddrinfo(host, port, socket.AF_UNSPEC,
662 socket.SOCK_STREAM, socket.IPPROTO_TCP,
664 except socket.error as gaierr:
665 raise ConnectionError(gaierr) from gaierr
667 af, socktype, proto, _, sa = res
670 log.debug('opening socket %s', sa)
671 sock = socket.socket(af, socktype, proto)
672 sock.settimeout(self.mpd_timeout)
674 sock.settimeout(self.socket_timeout)
676 except socket.error as socket_err:
677 log.debug('opening socket %s failed: %s', sa, socket_err)
682 raise ConnectionError(err)
683 raise ConnectionError("getaddrinfo returns an empty list")
686 # noidle's special case
687 if not self._pending or self._pending[0] != 'idle':
688 raise CommandError('cannot send noidle if send_idle was not called')
690 self._write_command("noidle")
691 return self._fetch_list()
693 def connect(self, host=None, port=None):
694 """Connects the MPD server
696 :param str host: hostname, IP or FQDN (defaults to *localhost* or socket)
697 :param port: port number (defaults to *6600*)
698 :type port: str or int
700 If host/port are :py:obj:`None` the socket uses :py:attr:`host`/:py:attr:`port`
701 attributes as defaults. Cf. :py:obj:`MPDClient` for the logic behind default host/port.
703 The underlying socket also honors :envvar:`MPD_TIMEOUT` environment variable
704 and defaults to :py:obj:`musicpd.CONNECTION_TIMEOUT` (connect command only).
706 If you want to have a timeout for each command once you got connected,
707 set its value in :py:obj:`MPDClient.socket_timeout` (in second) or at
708 module level in :py:obj:`musicpd.SOCKET_TIMEOUT`.
718 if self._sock is not None:
719 raise ConnectionError("Already connected")
720 if host[0] in ['/', '@']:
721 log.debug('Connecting unix socket %s', host)
722 self._sock = self._connect_unix(host)
724 log.debug('Connecting tcp socket %s:%s (timeout: %ss)', host, port, self.mpd_timeout)
725 self._sock = self._connect_tcp(host, port)
726 self._rfile = self._sock.makefile("r", encoding='utf-8', errors='surrogateescape')
727 self._rbfile = self._sock.makefile("rb")
728 self._wfile = self._sock.makefile("w", encoding='utf-8')
734 log.debug('Connected')
737 def socket_timeout(self):
738 """Socket timeout in second (defaults to :py:obj:`SOCKET_TIMEOUT`).
739 Use :py:obj:`None` to disable socket timout.
741 :setter: Set the socket timeout (integer > 0)
744 return self._socket_timeout
746 @socket_timeout.setter
747 def socket_timeout(self, timeout):
748 if timeout is not None:
749 if int(timeout) <= 0:
750 raise ValueError('socket_timeout expects a non zero positive integer')
751 self._socket_timeout = int(timeout)
753 self._socket_timeout = timeout
754 if getattr(self._sock, 'settimeout', False):
755 self._sock.settimeout(self._socket_timeout)
758 def disconnect(self):
759 """Closes the MPD connection.
760 The client closes the actual socket, it does not use the
761 'close' request from MPD protocol (as suggested in documentation).
763 if hasattr(self._rfile, 'close'):
765 if hasattr(self._rbfile, 'close'):
767 if hasattr(self._wfile, 'close'):
769 if hasattr(self._sock, 'close'):
777 def __exit__(self, exception_type, exception_value, exception_traceback):
781 """Return the socket’s file descriptor (a small integer).
782 This is useful with :py:obj:`select.select`.
784 if self._sock is None:
785 raise ConnectionError("Not connected")
786 return self._sock.fileno()
788 def command_list_ok_begin(self):
789 if self._command_list is not None:
790 raise CommandListError("Already in command list")
792 raise IteratingError("Cannot begin command list while iterating")
794 raise PendingCommandError("Cannot begin command list with pending commands")
795 self._write_command("command_list_ok_begin")
796 self._command_list = []
798 def command_list_end(self):
799 if self._command_list is None:
800 raise CommandListError("Not in command list")
802 raise IteratingError("Already iterating over a command list")
803 self._write_command("command_list_end")
804 return self._fetch_command_list()
808 return text.replace("\\", "\\\\").replace('"', '\\"')
810 # vim: set expandtab shiftwidth=4 softtabstop=4 textwidth=79: