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