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