]> kaliko git repositories - python-musicpd.git/blob - musicpd.py
Bumped version
[python-musicpd.git] / musicpd.py
1 # python-musicpd: Python MPD client library
2 # Copyright (C) 2012-2020  kaliko <kaliko@azylum.org>
3 # Copyright (C) 2019       Naglis Jonaitis <naglis@mailbox.org>
4 # Copyright (C) 2019       Bart Van Loon <bbb@bbbart.be>
5 # Copyright (C) 2008-2010  J. Alexander Treuman <jat@spatialrift.net>
6 #
7 # python-musicpd is free software: you can redistribute it and/or modify
8 # it under the terms of the GNU Lesser General Public License as published by
9 # the Free Software Foundation, either version 3 of the License, or
10 # (at your option) any later version.
11 #
12 # python-musicpd is distributed in the hope that it will be useful,
13 # but WITHOUT ANY WARRANTY; without even the implied warranty of
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
15 # GNU Lesser General Public License for more details.
16 #
17 # You should have received a copy of the GNU Lesser General Public License
18 # along with python-musicpd.  If not, see <http://www.gnu.org/licenses/>.
19
20 # pylint: disable=missing-docstring
21
22 import socket
23 import os
24
25 from functools import wraps
26
27 HELLO_PREFIX = "OK MPD "
28 ERROR_PREFIX = "ACK "
29 SUCCESS = "OK"
30 NEXT = "list_OK"
31 VERSION = '0.6.0'
32 #: seconds before a tcp connection attempt times out
33 CONNECTION_TIMEOUT = 5
34
35
36 def iterator_wrapper(func):
37     """Decorator handling iterate option"""
38     @wraps(func)
39     def decorated_function(instance, *args, **kwargs):
40         generator = func(instance, *args, **kwargs)
41         if not instance.iterate:
42             return list(generator)
43         instance._iterating = True
44
45         def iterator(gen):
46             try:
47                 for item in gen:
48                     yield item
49             finally:
50                 instance._iterating = False
51         return iterator(generator)
52     return decorated_function
53
54
55 class MPDError(Exception):
56     pass
57
58
59 class ConnectionError(MPDError):
60     pass
61
62
63 class ProtocolError(MPDError):
64     pass
65
66
67 class CommandError(MPDError):
68     pass
69
70
71 class CommandListError(MPDError):
72     pass
73
74
75 class PendingCommandError(MPDError):
76     pass
77
78
79 class IteratingError(MPDError):
80     pass
81
82
83 class Range:
84
85     def __init__(self, tpl):
86         self.tpl = tpl
87         self._check()
88
89     def __str__(self):
90         if len(self.tpl) == 0:
91             return ':'
92         if len(self.tpl) == 1:
93             return '{0}:'.format(self.tpl[0])
94         return '{0[0]}:{0[1]}'.format(self.tpl)
95
96     def __repr__(self):
97         return 'Range({0})'.format(self.tpl)
98
99     def _check(self):
100         if not isinstance(self.tpl, tuple):
101             raise CommandError('Wrong type, provide a tuple')
102         if len(self.tpl) not in [0, 1, 2]:
103             raise CommandError('length not in [0, 1, 2]')
104         for index in self.tpl:
105             try:
106                 index = int(index)
107             except (TypeError, ValueError):
108                 raise CommandError('Not a tuple of int')
109
110
111 class _NotConnected:
112
113     def __getattr__(self, attr):
114         return self._dummy
115
116     def _dummy(*args):
117         raise ConnectionError("Not connected")
118
119
120 class MPDClient:
121     """MPDClient instance will look for ``MPD_HOST``/``MPD_PORT``/``XDG_RUNTIME_DIR`` environment
122     variables and set instance attribute ``host``, ``port`` and ``pwd``
123     accordingly. Regarding ``MPD_HOST`` format to expose password refer
124     MPD client manual :manpage:`mpc (1)`.
125
126     Then :py:obj:`musicpd.MPDClient.connect` will use ``host`` and ``port`` as defaults if not provided as args.
127
128     Cf. :py:obj:`musicpd.MPDClient.connect` for details.
129
130     >>> from os import environ
131     >>> environ['MPD_HOST'] = 'pass@mpdhost'
132     >>> cli = musicpd.MPDClient()
133     >>> cli.pwd == environ['MPD_HOST'].split('@')[0]
134     True
135     >>> cli.host == environ['MPD_HOST'].split('@')[1]
136     True
137     >>> cli.connect() # will use host/port as set in MPD_HOST/MPD_PORT
138
139     :ivar str host: host used with the current connection
140     :ivar str,int port: port used with the current connection
141     :ivar str pwd: password detected in ``MPD_HOST`` environment variable
142
143     .. warning:: Instance attribute host/port/pwd
144
145       While :py:attr:`musicpd.MPDClient().host` and
146       :py:attr:`musicpd.MPDClient().port` keep track of current connection
147       host and port, :py:attr:`musicpd.MPDClient().pwd` is set once with
148       password extracted from environment variable.
149       Calling :py:meth:`musicpd.MPDClient().password()` with a new password
150       won't update :py:attr:`musicpd.MPDClient().pwd` value.
151
152       Moreover, :py:attr:`musicpd.MPDClient().pwd` is only an helper attribute
153       exposing password extracted from ``MPD_HOST`` environment variable, it
154       will not be used as default value for the :py:meth:`password` method
155     """
156
157     def __init__(self):
158         self.iterate = False
159         self._reset()
160         self._commands = {
161             # Status Commands
162             "clearerror":         self._fetch_nothing,
163             "currentsong":        self._fetch_object,
164             "idle":               self._fetch_list,
165             #"noidle":             None,
166             "status":             self._fetch_object,
167             "stats":              self._fetch_object,
168             # Playback Option Commands
169             "consume":            self._fetch_nothing,
170             "crossfade":          self._fetch_nothing,
171             "mixrampdb":          self._fetch_nothing,
172             "mixrampdelay":       self._fetch_nothing,
173             "random":             self._fetch_nothing,
174             "repeat":             self._fetch_nothing,
175             "setvol":             self._fetch_nothing,
176             "single":             self._fetch_nothing,
177             "replay_gain_mode":   self._fetch_nothing,
178             "replay_gain_status": self._fetch_item,
179             "volume":             self._fetch_nothing,
180             # Playback Control Commands
181             "next":               self._fetch_nothing,
182             "pause":              self._fetch_nothing,
183             "play":               self._fetch_nothing,
184             "playid":             self._fetch_nothing,
185             "previous":           self._fetch_nothing,
186             "seek":               self._fetch_nothing,
187             "seekid":             self._fetch_nothing,
188             "seekcur":            self._fetch_nothing,
189             "stop":               self._fetch_nothing,
190             # Queue Commands
191             "add":                self._fetch_nothing,
192             "addid":              self._fetch_item,
193             "clear":              self._fetch_nothing,
194             "delete":             self._fetch_nothing,
195             "deleteid":           self._fetch_nothing,
196             "move":               self._fetch_nothing,
197             "moveid":             self._fetch_nothing,
198             "playlist":           self._fetch_playlist,
199             "playlistfind":       self._fetch_songs,
200             "playlistid":         self._fetch_songs,
201             "playlistinfo":       self._fetch_songs,
202             "playlistsearch":     self._fetch_songs,
203             "plchanges":          self._fetch_songs,
204             "plchangesposid":     self._fetch_changes,
205             "prio":               self._fetch_nothing,
206             "prioid":             self._fetch_nothing,
207             "rangeid":            self._fetch_nothing,
208             "shuffle":            self._fetch_nothing,
209             "swap":               self._fetch_nothing,
210             "swapid":             self._fetch_nothing,
211             "addtagid":           self._fetch_nothing,
212             "cleartagid":         self._fetch_nothing,
213             # Stored Playlist Commands
214             "listplaylist":       self._fetch_list,
215             "listplaylistinfo":   self._fetch_songs,
216             "listplaylists":      self._fetch_playlists,
217             "load":               self._fetch_nothing,
218             "playlistadd":        self._fetch_nothing,
219             "playlistclear":      self._fetch_nothing,
220             "playlistdelete":     self._fetch_nothing,
221             "playlistmove":       self._fetch_nothing,
222             "rename":             self._fetch_nothing,
223             "rm":                 self._fetch_nothing,
224             "save":               self._fetch_nothing,
225             # Database Commands
226             "albumart":           self._fetch_composite,
227             "count":              self._fetch_object,
228             "getfingerprint":     self._fetch_object,
229             "find":               self._fetch_songs,
230             "findadd":            self._fetch_nothing,
231             "list":               self._fetch_list,
232             "listall":            self._fetch_database,
233             "listallinfo":        self._fetch_database,
234             "listfiles":          self._fetch_database,
235             "lsinfo":             self._fetch_database,
236             "readcomments":       self._fetch_object,
237             "readpicture":        self._fetch_composite,
238             "search":             self._fetch_songs,
239             "searchadd":          self._fetch_nothing,
240             "searchaddpl":        self._fetch_nothing,
241             "update":             self._fetch_item,
242             "rescan":             self._fetch_item,
243             # Mounts and neighbors
244             "mount":              self._fetch_nothing,
245             "unmount":            self._fetch_nothing,
246             "listmounts":         self._fetch_mounts,
247             "listneighbors":      self._fetch_neighbors,
248             # Sticker Commands
249             "sticker get":        self._fetch_item,
250             "sticker set":        self._fetch_nothing,
251             "sticker delete":     self._fetch_nothing,
252             "sticker list":       self._fetch_list,
253             "sticker find":       self._fetch_songs,
254             # Connection Commands
255             "close":              None,
256             "kill":               None,
257             "password":           self._fetch_nothing,
258             "ping":               self._fetch_nothing,
259             "tagtypes":           self._fetch_list,
260             "tagtypes disable":   self._fetch_nothing,
261             "tagtypes enable":    self._fetch_nothing,
262             "tagtypes clear":     self._fetch_nothing,
263             "tagtypes all":       self._fetch_nothing,
264             # Partition Commands
265             "partition":          self._fetch_nothing,
266             "listpartitions":     self._fetch_list,
267             "newpartition":       self._fetch_nothing,
268             "delpartition":       self._fetch_nothing,
269             "moveoutput":         self._fetch_nothing,
270             # Audio Output Commands
271             "disableoutput":      self._fetch_nothing,
272             "enableoutput":       self._fetch_nothing,
273             "toggleoutput":       self._fetch_nothing,
274             "outputs":            self._fetch_outputs,
275             "outputset":          self._fetch_nothing,
276             # Reflection Commands
277             "config":             self._fetch_object,
278             "commands":           self._fetch_list,
279             "notcommands":        self._fetch_list,
280             "urlhandlers":        self._fetch_list,
281             "decoders":           self._fetch_plugins,
282             # Client to Client
283             "subscribe":          self._fetch_nothing,
284             "unsubscribe":        self._fetch_nothing,
285             "channels":           self._fetch_list,
286             "readmessages":       self._fetch_messages,
287             "sendmessage":        self._fetch_nothing,
288         }
289         self._get_envvars()
290
291     def _get_envvars(self):
292         """
293         Retrieve MPD env. var. to overrides "localhost:6600"
294             Use MPD_HOST/MPD_PORT if set
295             else use MPD_HOST=${XDG_RUNTIME_DIR:-/run/}/mpd/socket if file exists
296         """
297         self.host = 'localhost'
298         self.pwd = None
299         self.port = os.environ.get('MPD_PORT', '6600')
300         mpd_host_env = os.environ.get('MPD_HOST')
301         if mpd_host_env:
302             # If password is set:
303             # mpd_host_env = ['pass', 'host'] because MPD_HOST=pass@host
304             mpd_host_env = mpd_host_env.split('@')
305             mpd_host_env.reverse()
306             self.host = mpd_host_env[0]
307             if len(mpd_host_env) > 1 and mpd_host_env[1]:
308                 self.pwd = mpd_host_env[1]
309         else:
310             # Is socket there
311             xdg_runtime_dir = os.environ.get('XDG_RUNTIME_DIR', '/run')
312             rundir = os.path.join(xdg_runtime_dir, 'mpd/socket')
313             if os.path.exists(rundir):
314                 self.host = rundir
315
316     def __getattr__(self, attr):
317         if attr == 'send_noidle':  # have send_noidle to cancel idle as well as noidle
318             return self.noidle()
319         if attr.startswith("send_"):
320             command = attr.replace("send_", "", 1)
321             wrapper = self._send
322         elif attr.startswith("fetch_"):
323             command = attr.replace("fetch_", "", 1)
324             wrapper = self._fetch
325         else:
326             command = attr
327             wrapper = self._execute
328         if command not in self._commands:
329             command = command.replace("_", " ")
330             if command not in self._commands:
331                 raise AttributeError("'%s' object has no attribute '%s'" %
332                                      (self.__class__.__name__, attr))
333         return lambda *args: wrapper(command, args)
334
335     def _send(self, command, args):
336         if self._command_list is not None:
337             raise CommandListError("Cannot use send_%s in a command list" %
338                                    command.replace(" ", "_"))
339         self._write_command(command, args)
340         retval = self._commands[command]
341         if retval is not None:
342             self._pending.append(command)
343
344     def _fetch(self, command, args=None):
345         if self._command_list is not None:
346             raise CommandListError("Cannot use fetch_%s in a command list" %
347                                    command.replace(" ", "_"))
348         if self._iterating:
349             raise IteratingError("Cannot use fetch_%s while iterating" %
350                                  command.replace(" ", "_"))
351         if not self._pending:
352             raise PendingCommandError("No pending commands to fetch")
353         if self._pending[0] != command:
354             raise PendingCommandError("'%s' is not the currently "
355                                       "pending command" % command)
356         del self._pending[0]
357         retval = self._commands[command]
358         if callable(retval):
359             return retval()
360         return retval
361
362     def _execute(self, command, args):
363         if self._iterating:
364             raise IteratingError("Cannot execute '%s' while iterating" %
365                                  command)
366         if self._pending:
367             raise PendingCommandError(
368                 "Cannot execute '%s' with pending commands" % command)
369         retval = self._commands[command]
370         if self._command_list is not None:
371             if not callable(retval):
372                 raise CommandListError(
373                     "'%s' not allowed in command list" % command)
374             self._write_command(command, args)
375             self._command_list.append(retval)
376         else:
377             self._write_command(command, args)
378             if callable(retval):
379                 return retval()
380             return retval
381
382     def _write_line(self, line):
383         self._wfile.write("%s\n" % line)
384         self._wfile.flush()
385
386     def _write_command(self, command, args=None):
387         if args is None:
388             args = []
389         parts = [command]
390         for arg in args:
391             if isinstance(arg, tuple):
392                 parts.append('{0!s}'.format(Range(arg)))
393             else:
394                 parts.append('"%s"' % escape(str(arg)))
395         self._write_line(" ".join(parts))
396
397     def _read_binary(self, amount):
398         chunk = bytearray()
399         while amount > 0:
400             result = self._rbfile.read(amount)
401             if len(result) == 0:
402                 self.disconnect()
403                 raise ConnectionError("Connection lost while reading binary content")
404             chunk.extend(result)
405             amount -= len(result)
406         return bytes(chunk)
407
408     def _read_line(self, binary=False):
409         if binary:
410             line = self._rbfile.readline().decode('utf-8')
411         else:
412             line = self._rfile.readline()
413         if not line.endswith("\n"):
414             self.disconnect()
415             raise ConnectionError("Connection lost while reading line")
416         line = line.rstrip("\n")
417         if line.startswith(ERROR_PREFIX):
418             error = line[len(ERROR_PREFIX):].strip()
419             raise CommandError(error)
420         if self._command_list is not None:
421             if line == NEXT:
422                 return
423             if line == SUCCESS:
424                 raise ProtocolError("Got unexpected '%s'" % SUCCESS)
425         elif line == SUCCESS:
426             return
427         return line
428
429     def _read_pair(self, separator, binary=False):
430         line = self._read_line(binary=binary)
431         if line is None:
432             return
433         pair = line.split(separator, 1)
434         if len(pair) < 2:
435             raise ProtocolError("Could not parse pair: '%s'" % line)
436         return pair
437
438     def _read_pairs(self, separator=": ", binary=False):
439         pair = self._read_pair(separator, binary=binary)
440         while pair:
441             yield pair
442             pair = self._read_pair(separator, binary=binary)
443
444     def _read_list(self):
445         seen = None
446         for key, value in self._read_pairs():
447             if key != seen:
448                 if seen is not None:
449                     raise ProtocolError("Expected key '%s', got '%s'" %
450                                         (seen, key))
451                 seen = key
452             yield value
453
454     def _read_playlist(self):
455         for _, value in self._read_pairs(":"):
456             yield value
457
458     def _read_objects(self, delimiters=None):
459         obj = {}
460         if delimiters is None:
461             delimiters = []
462         for key, value in self._read_pairs():
463             key = key.lower()
464             if obj:
465                 if key in delimiters:
466                     yield obj
467                     obj = {}
468                 elif key in obj:
469                     if not isinstance(obj[key], list):
470                         obj[key] = [obj[key], value]
471                     else:
472                         obj[key].append(value)
473                     continue
474             obj[key] = value
475         if obj:
476             yield obj
477
478     def _read_command_list(self):
479         try:
480             for retval in self._command_list:
481                 yield retval()
482         finally:
483             self._command_list = None
484         self._fetch_nothing()
485
486     def _fetch_nothing(self):
487         line = self._read_line()
488         if line is not None:
489             raise ProtocolError("Got unexpected return value: '%s'" % line)
490
491     def _fetch_item(self):
492         pairs = list(self._read_pairs())
493         if len(pairs) != 1:
494             return
495         return pairs[0][1]
496
497     @iterator_wrapper
498     def _fetch_list(self):
499         return self._read_list()
500
501     @iterator_wrapper
502     def _fetch_playlist(self):
503         return self._read_playlist()
504
505     def _fetch_object(self):
506         objs = list(self._read_objects())
507         if not objs:
508             return {}
509         return objs[0]
510
511     @iterator_wrapper
512     def _fetch_objects(self, delimiters):
513         return self._read_objects(delimiters)
514
515     def _fetch_changes(self):
516         return self._fetch_objects(["cpos"])
517
518     def _fetch_songs(self):
519         return self._fetch_objects(["file"])
520
521     def _fetch_playlists(self):
522         return self._fetch_objects(["playlist"])
523
524     def _fetch_database(self):
525         return self._fetch_objects(["file", "directory", "playlist"])
526
527     def _fetch_outputs(self):
528         return self._fetch_objects(["outputid"])
529
530     def _fetch_plugins(self):
531         return self._fetch_objects(["plugin"])
532
533     def _fetch_messages(self):
534         return self._fetch_objects(["channel"])
535
536     def _fetch_mounts(self):
537         return self._fetch_objects(["mount"])
538
539     def _fetch_neighbors(self):
540         return self._fetch_objects(["neighbor"])
541
542     def _fetch_composite(self):
543         obj = {}
544         for key, value in self._read_pairs(binary=True):
545             key = key.lower()
546             obj[key] = value
547             if key == 'binary':
548                 break
549         if not obj:
550             # If the song file was recognized, but there is no picture, the
551             # response is successful, but is otherwise empty.
552             return obj
553         amount = int(obj['binary'])
554         try:
555             obj['data'] = self._read_binary(amount)
556         except IOError as err:
557             raise ConnectionError('Error reading binary content: %s' % err)
558         if len(obj['data']) != amount:
559             raise ConnectionError('Error reading binary content: '
560                       'Expects %sB, got %s' % (amount, len(obj['data'])))
561         # Fetches trailing new line
562         self._read_line(binary=True)
563         # Fetches SUCCESS code
564         self._read_line(binary=True)
565         return obj
566
567     @iterator_wrapper
568     def _fetch_command_list(self):
569         return self._read_command_list()
570
571     def _hello(self):
572         line = self._rfile.readline()
573         if not line.endswith("\n"):
574             raise ConnectionError("Connection lost while reading MPD hello")
575         line = line.rstrip("\n")
576         if not line.startswith(HELLO_PREFIX):
577             raise ProtocolError("Got invalid MPD hello: '%s'" % line)
578         self.mpd_version = line[len(HELLO_PREFIX):].strip()
579
580     def _reset(self):
581         self.mpd_version = None
582         self._iterating = False
583         self._pending = []
584         self._command_list = None
585         self._sock = None
586         self._rfile = _NotConnected()
587         self._rbfile = _NotConnected()
588         self._wfile = _NotConnected()
589
590     def _connect_unix(self, path):
591         if not hasattr(socket, "AF_UNIX"):
592             raise ConnectionError(
593                 "Unix domain sockets not supported on this platform")
594         sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
595         sock.connect(path)
596         return sock
597
598     def _connect_tcp(self, host, port):
599         try:
600             flags = socket.AI_ADDRCONFIG
601         except AttributeError:
602             flags = 0
603         err = None
604         for res in socket.getaddrinfo(host, port, socket.AF_UNSPEC,
605                                       socket.SOCK_STREAM, socket.IPPROTO_TCP,
606                                       flags):
607             af, socktype, proto, _, sa = res
608             sock = None
609             try:
610                 sock = socket.socket(af, socktype, proto)
611                 sock.settimeout(CONNECTION_TIMEOUT)
612                 sock.connect(sa)
613                 sock.settimeout(None)
614                 return sock
615             except socket.error as socket_err:
616                 err = socket_err
617                 if sock is not None:
618                     sock.close()
619         if err is not None:
620             raise ConnectionError(str(err))
621         else:
622             raise ConnectionError("getaddrinfo returns an empty list")
623
624     def noidle(self):
625         # noidle's special case
626         if not self._pending or self._pending[0] != 'idle':
627             raise CommandError(
628                 'cannot send noidle if send_idle was not called')
629         del self._pending[0]
630         self._write_command("noidle")
631         return self._fetch_list()
632
633     def connect(self, host=None, port=None):
634         """Connects the MPD server
635
636         :param str host: hostname, IP or FQDN (defaults to `localhost` or socket, see below for details)
637         :param port: port number (defaults to 6600)
638         :type port: str or int
639
640         The connect method honors MPD_HOST/MPD_PORT environment variables.
641
642         .. note:: Default host/port
643
644           If host evaluate to :py:obj:`False`
645            * use ``MPD_HOST`` environment variable if set, extract password if present,
646            * else looks for a existing file in ``${XDG_RUNTIME_DIR:-/run/}/mpd/socket``
647            * else set host to ``localhost``
648
649           If port evaluate to :py:obj:`False`
650            * if ``MPD_PORT`` environment variable is set, use it for port
651            * else use ``6600``
652         """
653         if not host:
654             host = self.host
655         else:
656             self.host = host
657         if not port:
658             port = self.port
659         else:
660             self.port = port
661         if self._sock is not None:
662             raise ConnectionError("Already connected")
663         if host.startswith("/"):
664             self._sock = self._connect_unix(host)
665         else:
666             self._sock = self._connect_tcp(host, port)
667         self._rfile = self._sock.makefile("r", encoding='utf-8', errors='surrogateescape')
668         self._rbfile = self._sock.makefile("rb")
669         self._wfile = self._sock.makefile("w", encoding='utf-8')
670         try:
671             self._hello()
672         except:
673             self.disconnect()
674             raise
675
676     def disconnect(self):
677         """Closes the MPD connection.
678         The client closes the actual socket, it does not use the
679         'close' request from MPD protocol (as suggested in documentation).
680         """
681         if hasattr(self._rfile, 'close'):
682             self._rfile.close()
683         if hasattr(self._rbfile, 'close'):
684             self._rbfile.close()
685         if hasattr(self._wfile, 'close'):
686             self._wfile.close()
687         if hasattr(self._sock, 'close'):
688             self._sock.close()
689         self._reset()
690
691     def fileno(self):
692         if self._sock is None:
693             raise ConnectionError("Not connected")
694         return self._sock.fileno()
695
696     def command_list_ok_begin(self):
697         if self._command_list is not None:
698             raise CommandListError("Already in command list")
699         if self._iterating:
700             raise IteratingError("Cannot begin command list while iterating")
701         if self._pending:
702             raise PendingCommandError("Cannot begin command list "
703                                       "with pending commands")
704         self._write_command("command_list_ok_begin")
705         self._command_list = []
706
707     def command_list_end(self):
708         if self._command_list is None:
709             raise CommandListError("Not in command list")
710         if self._iterating:
711             raise IteratingError("Already iterating over a command list")
712         self._write_command("command_list_end")
713         return self._fetch_command_list()
714
715
716 def escape(text):
717     return text.replace("\\", "\\\\").replace('"', '\\"')
718
719 # vim: set expandtab shiftwidth=4 softtabstop=4 textwidth=79: