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