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