]> kaliko git repositories - python-musicpd.git/blob - musicpd.py
Add readpicture command
[python-musicpd.git] / musicpd.py
1 # python-musicpd: Python MPD client library
2 # Copyright (C) 2012-2020  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.4.5'
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             "find":               self._fetch_songs,
229             "findadd":            self._fetch_nothing,
230             "list":               self._fetch_list,
231             "listall":            self._fetch_database,
232             "listallinfo":        self._fetch_database,
233             "listfiles":          self._fetch_database,
234             "lsinfo":             self._fetch_database,
235             "readcomments":       self._fetch_object,
236             "readpicture":        self._fetch_composite,
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_binary(self, amount):
395         chunk = bytearray()
396         while amount > 0:
397             result = self._rbfile.read(amount)
398             if len(result) == 0:
399                 self.disconnect()
400                 raise ConnectionError("Connection lost while reading binary content")
401             chunk.extend(result)
402             amount -= len(result)
403         return bytes(chunk)
404
405     def _read_line(self, binary=False):
406         if binary:
407             line = self._rbfile.readline().decode('utf-8')
408         else:
409             line = self._rfile.readline()
410         if not line.endswith("\n"):
411             self.disconnect()
412             raise ConnectionError("Connection lost while reading line")
413         line = line.rstrip("\n")
414         if line.startswith(ERROR_PREFIX):
415             error = line[len(ERROR_PREFIX):].strip()
416             raise CommandError(error)
417         if self._command_list is not None:
418             if line == NEXT:
419                 return
420             if line == SUCCESS:
421                 raise ProtocolError("Got unexpected '%s'" % SUCCESS)
422         elif line == SUCCESS:
423             return
424         return line
425
426     def _read_pair(self, separator, binary=False):
427         line = self._read_line(binary=binary)
428         if line is None:
429             return
430         pair = line.split(separator, 1)
431         if len(pair) < 2:
432             raise ProtocolError("Could not parse pair: '%s'" % line)
433         return pair
434
435     def _read_pairs(self, separator=": ", binary=False):
436         pair = self._read_pair(separator, binary=binary)
437         while pair:
438             yield pair
439             pair = self._read_pair(separator, binary=binary)
440
441     def _read_list(self):
442         seen = None
443         for key, value in self._read_pairs():
444             if key != seen:
445                 if seen is not None:
446                     raise ProtocolError("Expected key '%s', got '%s'" %
447                                         (seen, key))
448                 seen = key
449             yield value
450
451     def _read_playlist(self):
452         for _, value in self._read_pairs(":"):
453             yield value
454
455     def _read_objects(self, delimiters=None):
456         obj = {}
457         if delimiters is None:
458             delimiters = []
459         for key, value in self._read_pairs():
460             key = key.lower()
461             if obj:
462                 if key in delimiters:
463                     yield obj
464                     obj = {}
465                 elif key in obj:
466                     if not isinstance(obj[key], list):
467                         obj[key] = [obj[key], value]
468                     else:
469                         obj[key].append(value)
470                     continue
471             obj[key] = value
472         if obj:
473             yield obj
474
475     def _read_command_list(self):
476         try:
477             for retval in self._command_list:
478                 yield retval()
479         finally:
480             self._command_list = None
481         self._fetch_nothing()
482
483     def _fetch_nothing(self):
484         line = self._read_line()
485         if line is not None:
486             raise ProtocolError("Got unexpected return value: '%s'" % line)
487
488     def _fetch_item(self):
489         pairs = list(self._read_pairs())
490         if len(pairs) != 1:
491             return
492         return pairs[0][1]
493
494     @iterator_wrapper
495     def _fetch_list(self):
496         return self._read_list()
497
498     @iterator_wrapper
499     def _fetch_playlist(self):
500         return self._read_playlist()
501
502     def _fetch_object(self):
503         objs = list(self._read_objects())
504         if not objs:
505             return {}
506         return objs[0]
507
508     @iterator_wrapper
509     def _fetch_objects(self, delimiters):
510         return self._read_objects(delimiters)
511
512     def _fetch_changes(self):
513         return self._fetch_objects(["cpos"])
514
515     def _fetch_songs(self):
516         return self._fetch_objects(["file"])
517
518     def _fetch_playlists(self):
519         return self._fetch_objects(["playlist"])
520
521     def _fetch_database(self):
522         return self._fetch_objects(["file", "directory", "playlist"])
523
524     def _fetch_outputs(self):
525         return self._fetch_objects(["outputid"])
526
527     def _fetch_plugins(self):
528         return self._fetch_objects(["plugin"])
529
530     def _fetch_messages(self):
531         return self._fetch_objects(["channel"])
532
533     def _fetch_mounts(self):
534         return self._fetch_objects(["mount"])
535
536     def _fetch_neighbors(self):
537         return self._fetch_objects(["neighbor"])
538
539     def _fetch_composite(self):
540         obj = {}
541         for key, value in self._read_pairs(binary=True):
542             key = key.lower()
543             obj[key] = value
544             if key == 'binary':
545                 break
546         if not obj:
547             # If the song file was recognized, but there is no picture, the
548             # response is successful, but is otherwise empty.
549             return obj
550         amount = int(obj['binary'])
551         try:
552             obj['data'] = self._read_binary(amount)
553         except IOError as err:
554             raise ConnectionError('Error reading binary content: %s' % err)
555         if len(obj['data']) != amount:
556             raise ConnectionError('Error reading binary content: '
557                       'Expects %sB, got %s' % (amount, len(obj['data'])))
558         # Fetches trailing new line
559         self._read_line(binary=True)
560         # Fetches SUCCESS code
561         self._read_line(binary=True)
562         return obj
563
564     @iterator_wrapper
565     def _fetch_command_list(self):
566         return self._read_command_list()
567
568     def _hello(self):
569         line = self._rfile.readline()
570         if not line.endswith("\n"):
571             raise ConnectionError("Connection lost while reading MPD hello")
572         line = line.rstrip("\n")
573         if not line.startswith(HELLO_PREFIX):
574             raise ProtocolError("Got invalid MPD hello: '%s'" % line)
575         self.mpd_version = line[len(HELLO_PREFIX):].strip()
576
577     def _reset(self):
578         self.mpd_version = None
579         self._iterating = False
580         self._pending = []
581         self._command_list = None
582         self._sock = None
583         self._rfile = _NotConnected()
584         self._rbfile = _NotConnected()
585         self._wfile = _NotConnected()
586
587     def _connect_unix(self, path):
588         if not hasattr(socket, "AF_UNIX"):
589             raise ConnectionError(
590                 "Unix domain sockets not supported on this platform")
591         sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
592         sock.connect(path)
593         return sock
594
595     def _connect_tcp(self, host, port):
596         try:
597             flags = socket.AI_ADDRCONFIG
598         except AttributeError:
599             flags = 0
600         err = None
601         for res in socket.getaddrinfo(host, port, socket.AF_UNSPEC,
602                                       socket.SOCK_STREAM, socket.IPPROTO_TCP,
603                                       flags):
604             af, socktype, proto, _, sa = res
605             sock = None
606             try:
607                 sock = socket.socket(af, socktype, proto)
608                 sock.settimeout(CONNECTION_TIMEOUT)
609                 sock.connect(sa)
610                 sock.settimeout(None)
611                 return sock
612             except socket.error as socket_err:
613                 err = socket_err
614                 if sock is not None:
615                     sock.close()
616         if err is not None:
617             raise ConnectionError(str(err))
618         else:
619             raise ConnectionError("getaddrinfo returns an empty list")
620
621     def noidle(self):
622         # noidle's special case
623         if not self._pending or self._pending[0] != 'idle':
624             raise CommandError(
625                 'cannot send noidle if send_idle was not called')
626         del self._pending[0]
627         self._write_command("noidle")
628         return self._fetch_list()
629
630     def connect(self, host=None, port=None):
631         """Connects the MPD server
632
633         :param str host: hostname, IP or FQDN (defaults to `localhost` or socket, see below for details)
634         :param port: port number (defaults to 6600)
635         :type port: str or int
636
637         The connect method honors MPD_HOST/MPD_PORT environment variables.
638
639         .. note:: Default host/port
640
641           If host evaluate to :py:obj:`False`
642            * use ``MPD_HOST`` environment variable if set, extract password if present,
643            * else looks for a existing file in ``${XDG_RUNTIME_DIR:-/run/}/mpd/socket``
644            * else set host to ``localhost``
645
646           If port evaluate to :py:obj:`False`
647            * if ``MPD_PORT`` environment variable is set, use it for port
648            * else use ``6600``
649         """
650         if not host:
651             host = self.host
652         else:
653             self.host = host
654         if not port:
655             port = self.port
656         else:
657             self.port = port
658         if self._sock is not None:
659             raise ConnectionError("Already connected")
660         if host.startswith("/"):
661             self._sock = self._connect_unix(host)
662         else:
663             self._sock = self._connect_tcp(host, port)
664         self._rfile = self._sock.makefile("r", encoding='utf-8', errors='surrogateescape')
665         self._rbfile = self._sock.makefile("rb")
666         self._wfile = self._sock.makefile("w", encoding='utf-8')
667         try:
668             self._hello()
669         except:
670             self.disconnect()
671             raise
672
673     def disconnect(self):
674         """Closes the MPD connection.
675         The client closes the actual socket, it does not use the
676         'close' request from MPD protocol (as suggested in documentation).
677         """
678         if hasattr(self._rfile, 'close'):
679             self._rfile.close()
680         if hasattr(self._rbfile, 'close'):
681             self._rbfile.close()
682         if hasattr(self._wfile, 'close'):
683             self._wfile.close()
684         if hasattr(self._sock, 'close'):
685             self._sock.close()
686         self._reset()
687
688     def fileno(self):
689         if self._sock is None:
690             raise ConnectionError("Not connected")
691         return self._sock.fileno()
692
693     def command_list_ok_begin(self):
694         if self._command_list is not None:
695             raise CommandListError("Already in command list")
696         if self._iterating:
697             raise IteratingError("Cannot begin command list while iterating")
698         if self._pending:
699             raise PendingCommandError("Cannot begin command list "
700                                       "with pending commands")
701         self._write_command("command_list_ok_begin")
702         self._command_list = []
703
704     def command_list_end(self):
705         if self._command_list is None:
706             raise CommandListError("Not in command list")
707         if self._iterating:
708             raise IteratingError("Already iterating over a command list")
709         self._write_command("command_list_end")
710         return self._fetch_command_list()
711
712
713 def escape(text):
714     return text.replace("\\", "\\\\").replace('"', '\\"')
715
716 # vim: set expandtab shiftwidth=4 softtabstop=4 textwidth=79: