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