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