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