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