]> kaliko git repositories - python-musicpd.git/blob - musicpd.py
3425a6f7f84fd9d37f3ffdd1a749d24b19dacbc0
[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.4.5'
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             "find":               self._fetch_songs,
229             "findadd":            self._fetch_nothing,
230             "list":               self._fetch_list,
231             "listall":            self._fetch_database,
232             "listallinfo":        self._fetch_database,
233             "listfiles":          self._fetch_database,
234             "lsinfo":             self._fetch_database,
235             "readcomments":       self._fetch_object,
236             "search":             self._fetch_songs,
237             "searchadd":          self._fetch_nothing,
238             "searchaddpl":        self._fetch_nothing,
239             "update":             self._fetch_item,
240             "rescan":             self._fetch_item,
241             # Mounts and neighbors
242             "mount":              self._fetch_nothing,
243             "unmount":            self._fetch_nothing,
244             "listmounts":         self._fetch_mounts,
245             "listneighbors":      self._fetch_neighbors,
246             # Sticker Commands
247             "sticker get":        self._fetch_item,
248             "sticker set":        self._fetch_nothing,
249             "sticker delete":     self._fetch_nothing,
250             "sticker list":       self._fetch_list,
251             "sticker find":       self._fetch_songs,
252             # Connection Commands
253             "close":              None,
254             "kill":               None,
255             "password":           self._fetch_nothing,
256             "ping":               self._fetch_nothing,
257             "tagtypes":           self._fetch_list,
258             "tagtypes disable":   self._fetch_nothing,
259             "tagtypes enable":    self._fetch_nothing,
260             "tagtypes clear":     self._fetch_nothing,
261             "tagtypes all":       self._fetch_nothing,
262             # Partition Commands
263             "partition":          self._fetch_nothing,
264             "listpartitions":     self._fetch_list,
265             "newpartition":       self._fetch_nothing,
266             # Audio Output Commands
267             "disableoutput":      self._fetch_nothing,
268             "enableoutput":       self._fetch_nothing,
269             "toggleoutput":       self._fetch_nothing,
270             "outputs":            self._fetch_outputs,
271             "outputset":          self._fetch_nothing,
272             # Reflection Commands
273             "config":             self._fetch_object,
274             "commands":           self._fetch_list,
275             "notcommands":        self._fetch_list,
276             "urlhandlers":        self._fetch_list,
277             "decoders":           self._fetch_plugins,
278             # Client to Client
279             "subscribe":          self._fetch_nothing,
280             "unsubscribe":        self._fetch_nothing,
281             "channels":           self._fetch_list,
282             "readmessages":       self._fetch_messages,
283             "sendmessage":        self._fetch_nothing,
284         }
285         self._get_envvars()
286
287     def _get_envvars(self):
288         """
289         Retrieve MPD env. var. to overrides "localhost:6600"
290             Use MPD_HOST/MPD_PORT if set
291             else use MPD_HOST=${XDG_RUNTIME_DIR:-/run/}/mpd/socket if file exists
292         """
293         self.host = 'localhost'
294         self.pwd = None
295         self.port = os.environ.get('MPD_PORT', '6600')
296         mpd_host_env = os.environ.get('MPD_HOST')
297         if mpd_host_env:
298             # If password is set:
299             # mpd_host_env = ['pass', 'host'] because MPD_HOST=pass@host
300             mpd_host_env = mpd_host_env.split('@')
301             mpd_host_env.reverse()
302             self.host = mpd_host_env[0]
303             if len(mpd_host_env) > 1 and mpd_host_env[1]:
304                 self.pwd = mpd_host_env[1]
305         else:
306             # Is socket there
307             xdg_runtime_dir = os.environ.get('XDG_RUNTIME_DIR', '/run')
308             rundir = os.path.join(xdg_runtime_dir, 'mpd/socket')
309             if os.path.exists(rundir):
310                 self.host = rundir
311
312     def __getattr__(self, attr):
313         if attr == 'send_noidle':  # have send_noidle to cancel idle as well as noidle
314             return self.noidle()
315         if attr.startswith("send_"):
316             command = attr.replace("send_", "", 1)
317             wrapper = self._send
318         elif attr.startswith("fetch_"):
319             command = attr.replace("fetch_", "", 1)
320             wrapper = self._fetch
321         else:
322             command = attr
323             wrapper = self._execute
324         if command not in self._commands:
325             command = command.replace("_", " ")
326             if command not in self._commands:
327                 raise AttributeError("'%s' object has no attribute '%s'" %
328                                      (self.__class__.__name__, attr))
329         return lambda *args: wrapper(command, args)
330
331     def _send(self, command, args):
332         if self._command_list is not None:
333             raise CommandListError("Cannot use send_%s in a command list" %
334                                    command.replace(" ", "_"))
335         self._write_command(command, args)
336         retval = self._commands[command]
337         if retval is not None:
338             self._pending.append(command)
339
340     def _fetch(self, command, args=None):
341         if self._command_list is not None:
342             raise CommandListError("Cannot use fetch_%s in a command list" %
343                                    command.replace(" ", "_"))
344         if self._iterating:
345             raise IteratingError("Cannot use fetch_%s while iterating" %
346                                  command.replace(" ", "_"))
347         if not self._pending:
348             raise PendingCommandError("No pending commands to fetch")
349         if self._pending[0] != command:
350             raise PendingCommandError("'%s' is not the currently "
351                                       "pending command" % command)
352         del self._pending[0]
353         retval = self._commands[command]
354         if callable(retval):
355             return retval()
356         return retval
357
358     def _execute(self, command, args):
359         if self._iterating:
360             raise IteratingError("Cannot execute '%s' while iterating" %
361                                  command)
362         if self._pending:
363             raise PendingCommandError(
364                 "Cannot execute '%s' with pending commands" % command)
365         retval = self._commands[command]
366         if self._command_list is not None:
367             if not callable(retval):
368                 raise CommandListError(
369                     "'%s' not allowed in command list" % command)
370             self._write_command(command, args)
371             self._command_list.append(retval)
372         else:
373             self._write_command(command, args)
374             if callable(retval):
375                 return retval()
376             return retval
377
378     def _write_line(self, line):
379         self._wfile.write("%s\n" % line)
380         self._wfile.flush()
381
382     def _write_command(self, command, args=None):
383         if args is None:
384             args = []
385         parts = [command]
386         for arg in args:
387             if isinstance(arg, tuple):
388                 parts.append('{0!s}'.format(Range(arg)))
389             else:
390                 parts.append('"%s"' % escape(str(arg)))
391         self._write_line(" ".join(parts))
392
393     def _read_binary(self, amount):
394         chunk = bytearray()
395         while amount > 0:
396             result = self._rbfile.read(amount)
397             if len(result) == 0:
398                 self.disconnect()
399                 raise ConnectionError("Connection lost while reading binary content")
400             chunk.extend(result)
401             amount -= len(result)
402         return bytes(chunk)
403
404     def _read_line(self, binary=False):
405         if binary:
406             line = self._rbfile.readline().decode('utf-8')
407         else:
408             line = self._rfile.readline()
409         if not line.endswith("\n"):
410             self.disconnect()
411             raise ConnectionError("Connection lost while reading line")
412         line = line.rstrip("\n")
413         if line.startswith(ERROR_PREFIX):
414             error = line[len(ERROR_PREFIX):].strip()
415             raise CommandError(error)
416         if self._command_list is not None:
417             if line == NEXT:
418                 return
419             if line == SUCCESS:
420                 raise ProtocolError("Got unexpected '%s'" % SUCCESS)
421         elif line == SUCCESS:
422             return
423         return line
424
425     def _read_pair(self, separator, binary=False):
426         line = self._read_line(binary=binary)
427         if line is None:
428             return
429         pair = line.split(separator, 1)
430         if len(pair) < 2:
431             raise ProtocolError("Could not parse pair: '%s'" % line)
432         return pair
433
434     def _read_pairs(self, separator=": ", binary=False):
435         pair = self._read_pair(separator, binary=binary)
436         while pair:
437             yield pair
438             pair = self._read_pair(separator, binary=binary)
439
440     def _read_list(self):
441         seen = None
442         for key, value in self._read_pairs():
443             if key != seen:
444                 if seen is not None:
445                     raise ProtocolError("Expected key '%s', got '%s'" %
446                                         (seen, key))
447                 seen = key
448             yield value
449
450     def _read_playlist(self):
451         for _, value in self._read_pairs(":"):
452             yield value
453
454     def _read_objects(self, delimiters=None):
455         obj = {}
456         if delimiters is None:
457             delimiters = []
458         for key, value in self._read_pairs():
459             key = key.lower()
460             if obj:
461                 if key in delimiters:
462                     yield obj
463                     obj = {}
464                 elif key in obj:
465                     if not isinstance(obj[key], list):
466                         obj[key] = [obj[key], value]
467                     else:
468                         obj[key].append(value)
469                     continue
470             obj[key] = value
471         if obj:
472             yield obj
473
474     def _read_command_list(self):
475         try:
476             for retval in self._command_list:
477                 yield retval()
478         finally:
479             self._command_list = None
480         self._fetch_nothing()
481
482     def _fetch_nothing(self):
483         line = self._read_line()
484         if line is not None:
485             raise ProtocolError("Got unexpected return value: '%s'" % line)
486
487     def _fetch_item(self):
488         pairs = list(self._read_pairs())
489         if len(pairs) != 1:
490             return
491         return pairs[0][1]
492
493     @iterator_wrapper
494     def _fetch_list(self):
495         return self._read_list()
496
497     @iterator_wrapper
498     def _fetch_playlist(self):
499         return self._read_playlist()
500
501     def _fetch_object(self):
502         objs = list(self._read_objects())
503         if not objs:
504             return {}
505         return objs[0]
506
507     @iterator_wrapper
508     def _fetch_objects(self, delimiters):
509         return self._read_objects(delimiters)
510
511     def _fetch_changes(self):
512         return self._fetch_objects(["cpos"])
513
514     def _fetch_songs(self):
515         return self._fetch_objects(["file"])
516
517     def _fetch_playlists(self):
518         return self._fetch_objects(["playlist"])
519
520     def _fetch_database(self):
521         return self._fetch_objects(["file", "directory", "playlist"])
522
523     def _fetch_outputs(self):
524         return self._fetch_objects(["outputid"])
525
526     def _fetch_plugins(self):
527         return self._fetch_objects(["plugin"])
528
529     def _fetch_messages(self):
530         return self._fetch_objects(["channel"])
531
532     def _fetch_mounts(self):
533         return self._fetch_objects(["mount"])
534
535     def _fetch_neighbors(self):
536         return self._fetch_objects(["neighbor"])
537
538     def _fetch_composite(self):
539         obj = {}
540         for key, value in self._read_pairs(binary=True):
541             key = key.lower()
542             obj[key] = value
543             if key == 'binary':
544                 break
545         amount = int(obj['binary'])
546         try:
547             obj['data'] = self._read_binary(amount)
548         except IOError as err:
549             raise ConnectionError('Error reading binary content: %s' % err)
550         if len(obj['data']) != amount:
551             raise ConnectionError('Error reading binary content: '
552                       'Expects %sB, got %s' % (amount, len(obj['data'])))
553         # Fetches trailing new line
554         self._read_line(binary=True)
555         # Fetches SUCCESS code
556         self._read_line(binary=True)
557         return obj
558
559     @iterator_wrapper
560     def _fetch_command_list(self):
561         return self._read_command_list()
562
563     def _hello(self):
564         line = self._rfile.readline()
565         if not line.endswith("\n"):
566             raise ConnectionError("Connection lost while reading MPD hello")
567         line = line.rstrip("\n")
568         if not line.startswith(HELLO_PREFIX):
569             raise ProtocolError("Got invalid MPD hello: '%s'" % line)
570         self.mpd_version = line[len(HELLO_PREFIX):].strip()
571
572     def _reset(self):
573         self.mpd_version = None
574         self._iterating = False
575         self._pending = []
576         self._command_list = None
577         self._sock = None
578         self._rfile = _NotConnected()
579         self._rbfile = _NotConnected()
580         self._wfile = _NotConnected()
581
582     def _connect_unix(self, path):
583         if not hasattr(socket, "AF_UNIX"):
584             raise ConnectionError(
585                 "Unix domain sockets not supported on this platform")
586         sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
587         sock.connect(path)
588         return sock
589
590     def _connect_tcp(self, host, port):
591         try:
592             flags = socket.AI_ADDRCONFIG
593         except AttributeError:
594             flags = 0
595         err = None
596         for res in socket.getaddrinfo(host, port, socket.AF_UNSPEC,
597                                       socket.SOCK_STREAM, socket.IPPROTO_TCP,
598                                       flags):
599             af, socktype, proto, _, sa = res
600             sock = None
601             try:
602                 sock = socket.socket(af, socktype, proto)
603                 sock.settimeout(CONNECTION_TIMEOUT)
604                 sock.connect(sa)
605                 sock.settimeout(None)
606                 return sock
607             except socket.error as socket_err:
608                 err = socket_err
609                 if sock is not None:
610                     sock.close()
611         if err is not None:
612             raise ConnectionError(str(err))
613         else:
614             raise ConnectionError("getaddrinfo returns an empty list")
615
616     def noidle(self):
617         # noidle's special case
618         if not self._pending or self._pending[0] != 'idle':
619             raise CommandError(
620                 'cannot send noidle if send_idle was not called')
621         del self._pending[0]
622         self._write_command("noidle")
623         return self._fetch_list()
624
625     def connect(self, host=None, port=None):
626         """Connects the MPD server
627
628         :param str host: hostname, IP or FQDN (defaults to `localhost` or socket, see below for details)
629         :param port: port number (defaults to 6600)
630         :type port: str or int
631
632         The connect method honors MPD_HOST/MPD_PORT environment variables.
633
634         .. note:: Default host/port
635
636           If host evaluate to :py:obj:`False`
637            * use ``MPD_HOST`` environment variable if set, extract password if present,
638            * else looks for a existing file in ``${XDG_RUNTIME_DIR:-/run/}/mpd/socket``
639            * else set host to ``localhost``
640
641           If port evaluate to :py:obj:`False`
642            * if ``MPD_PORT`` environment variable is set, use it for port
643            * else use ``6600``
644         """
645         if not host:
646             host = self.host
647         else:
648             self.host = host
649         if not port:
650             port = self.port
651         else:
652             self.port = port
653         if self._sock is not None:
654             raise ConnectionError("Already connected")
655         if host.startswith("/"):
656             self._sock = self._connect_unix(host)
657         else:
658             self._sock = self._connect_tcp(host, port)
659         self._rfile = self._sock.makefile("r", encoding='utf-8', errors='surrogateescape')
660         self._rbfile = self._sock.makefile("rb")
661         self._wfile = self._sock.makefile("w", encoding='utf-8')
662         try:
663             self._hello()
664         except:
665             self.disconnect()
666             raise
667
668     def disconnect(self):
669         """Closes the MPD connection.
670         The client closes the actual socket, it does not use the
671         'close' request from MPD protocol (as suggested in documentation).
672         """
673         if hasattr(self._rfile, 'close'):
674             self._rfile.close()
675         if hasattr(self._rbfile, 'close'):
676             self._rbfile.close()
677         if hasattr(self._wfile, 'close'):
678             self._wfile.close()
679         if hasattr(self._sock, 'close'):
680             self._sock.close()
681         self._reset()
682
683     def fileno(self):
684         if self._sock is None:
685             raise ConnectionError("Not connected")
686         return self._sock.fileno()
687
688     def command_list_ok_begin(self):
689         if self._command_list is not None:
690             raise CommandListError("Already in command list")
691         if self._iterating:
692             raise IteratingError("Cannot begin command list while iterating")
693         if self._pending:
694             raise PendingCommandError("Cannot begin command list "
695                                       "with pending commands")
696         self._write_command("command_list_ok_begin")
697         self._command_list = []
698
699     def command_list_end(self):
700         if self._command_list is None:
701             raise CommandListError("Not in command list")
702         if self._iterating:
703             raise IteratingError("Already iterating over a command list")
704         self._write_command("command_list_end")
705         return self._fetch_command_list()
706
707
708 def escape(text):
709     return text.replace("\\", "\\\\").replace('"', '\\"')
710
711 # vim: set expandtab shiftwidth=4 softtabstop=4 textwidth=79: