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