]> kaliko git repositories - python-musicpd.git/blob - musicpd.py
Add getfingerprint 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             "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             "tagtypes":           self._fetch_list,
260             "tagtypes disable":   self._fetch_nothing,
261             "tagtypes enable":    self._fetch_nothing,
262             "tagtypes clear":     self._fetch_nothing,
263             "tagtypes all":       self._fetch_nothing,
264             # Partition Commands
265             "partition":          self._fetch_nothing,
266             "listpartitions":     self._fetch_list,
267             "newpartition":       self._fetch_nothing,
268             # Audio Output Commands
269             "disableoutput":      self._fetch_nothing,
270             "enableoutput":       self._fetch_nothing,
271             "toggleoutput":       self._fetch_nothing,
272             "outputs":            self._fetch_outputs,
273             "outputset":          self._fetch_nothing,
274             # Reflection Commands
275             "config":             self._fetch_object,
276             "commands":           self._fetch_list,
277             "notcommands":        self._fetch_list,
278             "urlhandlers":        self._fetch_list,
279             "decoders":           self._fetch_plugins,
280             # Client to Client
281             "subscribe":          self._fetch_nothing,
282             "unsubscribe":        self._fetch_nothing,
283             "channels":           self._fetch_list,
284             "readmessages":       self._fetch_messages,
285             "sendmessage":        self._fetch_nothing,
286         }
287         self._get_envvars()
288
289     def _get_envvars(self):
290         """
291         Retrieve MPD env. var. to overrides "localhost:6600"
292             Use MPD_HOST/MPD_PORT if set
293             else use MPD_HOST=${XDG_RUNTIME_DIR:-/run/}/mpd/socket if file exists
294         """
295         self.host = 'localhost'
296         self.pwd = None
297         self.port = os.environ.get('MPD_PORT', '6600')
298         mpd_host_env = os.environ.get('MPD_HOST')
299         if mpd_host_env:
300             # If password is set:
301             # mpd_host_env = ['pass', 'host'] because MPD_HOST=pass@host
302             mpd_host_env = mpd_host_env.split('@')
303             mpd_host_env.reverse()
304             self.host = mpd_host_env[0]
305             if len(mpd_host_env) > 1 and mpd_host_env[1]:
306                 self.pwd = mpd_host_env[1]
307         else:
308             # Is socket there
309             xdg_runtime_dir = os.environ.get('XDG_RUNTIME_DIR', '/run')
310             rundir = os.path.join(xdg_runtime_dir, 'mpd/socket')
311             if os.path.exists(rundir):
312                 self.host = rundir
313
314     def __getattr__(self, attr):
315         if attr == 'send_noidle':  # have send_noidle to cancel idle as well as noidle
316             return self.noidle()
317         if attr.startswith("send_"):
318             command = attr.replace("send_", "", 1)
319             wrapper = self._send
320         elif attr.startswith("fetch_"):
321             command = attr.replace("fetch_", "", 1)
322             wrapper = self._fetch
323         else:
324             command = attr
325             wrapper = self._execute
326         if command not in self._commands:
327             command = command.replace("_", " ")
328             if command not in self._commands:
329                 raise AttributeError("'%s' object has no attribute '%s'" %
330                                      (self.__class__.__name__, attr))
331         return lambda *args: wrapper(command, args)
332
333     def _send(self, command, args):
334         if self._command_list is not None:
335             raise CommandListError("Cannot use send_%s in a command list" %
336                                    command.replace(" ", "_"))
337         self._write_command(command, args)
338         retval = self._commands[command]
339         if retval is not None:
340             self._pending.append(command)
341
342     def _fetch(self, command, args=None):
343         if self._command_list is not None:
344             raise CommandListError("Cannot use fetch_%s in a command list" %
345                                    command.replace(" ", "_"))
346         if self._iterating:
347             raise IteratingError("Cannot use fetch_%s while iterating" %
348                                  command.replace(" ", "_"))
349         if not self._pending:
350             raise PendingCommandError("No pending commands to fetch")
351         if self._pending[0] != command:
352             raise PendingCommandError("'%s' is not the currently "
353                                       "pending command" % command)
354         del self._pending[0]
355         retval = self._commands[command]
356         if callable(retval):
357             return retval()
358         return retval
359
360     def _execute(self, command, args):
361         if self._iterating:
362             raise IteratingError("Cannot execute '%s' while iterating" %
363                                  command)
364         if self._pending:
365             raise PendingCommandError(
366                 "Cannot execute '%s' with pending commands" % command)
367         retval = self._commands[command]
368         if self._command_list is not None:
369             if not callable(retval):
370                 raise CommandListError(
371                     "'%s' not allowed in command list" % command)
372             self._write_command(command, args)
373             self._command_list.append(retval)
374         else:
375             self._write_command(command, args)
376             if callable(retval):
377                 return retval()
378             return retval
379
380     def _write_line(self, line):
381         self._wfile.write("%s\n" % line)
382         self._wfile.flush()
383
384     def _write_command(self, command, args=None):
385         if args is None:
386             args = []
387         parts = [command]
388         for arg in args:
389             if isinstance(arg, tuple):
390                 parts.append('{0!s}'.format(Range(arg)))
391             else:
392                 parts.append('"%s"' % escape(str(arg)))
393         self._write_line(" ".join(parts))
394
395     def _read_binary(self, amount):
396         chunk = bytearray()
397         while amount > 0:
398             result = self._rbfile.read(amount)
399             if len(result) == 0:
400                 self.disconnect()
401                 raise ConnectionError("Connection lost while reading binary content")
402             chunk.extend(result)
403             amount -= len(result)
404         return bytes(chunk)
405
406     def _read_line(self, binary=False):
407         if binary:
408             line = self._rbfile.readline().decode('utf-8')
409         else:
410             line = self._rfile.readline()
411         if not line.endswith("\n"):
412             self.disconnect()
413             raise ConnectionError("Connection lost while reading line")
414         line = line.rstrip("\n")
415         if line.startswith(ERROR_PREFIX):
416             error = line[len(ERROR_PREFIX):].strip()
417             raise CommandError(error)
418         if self._command_list is not None:
419             if line == NEXT:
420                 return
421             if line == SUCCESS:
422                 raise ProtocolError("Got unexpected '%s'" % SUCCESS)
423         elif line == SUCCESS:
424             return
425         return line
426
427     def _read_pair(self, separator, binary=False):
428         line = self._read_line(binary=binary)
429         if line is None:
430             return
431         pair = line.split(separator, 1)
432         if len(pair) < 2:
433             raise ProtocolError("Could not parse pair: '%s'" % line)
434         return pair
435
436     def _read_pairs(self, separator=": ", binary=False):
437         pair = self._read_pair(separator, binary=binary)
438         while pair:
439             yield pair
440             pair = self._read_pair(separator, binary=binary)
441
442     def _read_list(self):
443         seen = None
444         for key, value in self._read_pairs():
445             if key != seen:
446                 if seen is not None:
447                     raise ProtocolError("Expected key '%s', got '%s'" %
448                                         (seen, key))
449                 seen = key
450             yield value
451
452     def _read_playlist(self):
453         for _, value in self._read_pairs(":"):
454             yield value
455
456     def _read_objects(self, delimiters=None):
457         obj = {}
458         if delimiters is None:
459             delimiters = []
460         for key, value in self._read_pairs():
461             key = key.lower()
462             if obj:
463                 if key in delimiters:
464                     yield obj
465                     obj = {}
466                 elif key in obj:
467                     if not isinstance(obj[key], list):
468                         obj[key] = [obj[key], value]
469                     else:
470                         obj[key].append(value)
471                     continue
472             obj[key] = value
473         if obj:
474             yield obj
475
476     def _read_command_list(self):
477         try:
478             for retval in self._command_list:
479                 yield retval()
480         finally:
481             self._command_list = None
482         self._fetch_nothing()
483
484     def _fetch_nothing(self):
485         line = self._read_line()
486         if line is not None:
487             raise ProtocolError("Got unexpected return value: '%s'" % line)
488
489     def _fetch_item(self):
490         pairs = list(self._read_pairs())
491         if len(pairs) != 1:
492             return
493         return pairs[0][1]
494
495     @iterator_wrapper
496     def _fetch_list(self):
497         return self._read_list()
498
499     @iterator_wrapper
500     def _fetch_playlist(self):
501         return self._read_playlist()
502
503     def _fetch_object(self):
504         objs = list(self._read_objects())
505         if not objs:
506             return {}
507         return objs[0]
508
509     @iterator_wrapper
510     def _fetch_objects(self, delimiters):
511         return self._read_objects(delimiters)
512
513     def _fetch_changes(self):
514         return self._fetch_objects(["cpos"])
515
516     def _fetch_songs(self):
517         return self._fetch_objects(["file"])
518
519     def _fetch_playlists(self):
520         return self._fetch_objects(["playlist"])
521
522     def _fetch_database(self):
523         return self._fetch_objects(["file", "directory", "playlist"])
524
525     def _fetch_outputs(self):
526         return self._fetch_objects(["outputid"])
527
528     def _fetch_plugins(self):
529         return self._fetch_objects(["plugin"])
530
531     def _fetch_messages(self):
532         return self._fetch_objects(["channel"])
533
534     def _fetch_mounts(self):
535         return self._fetch_objects(["mount"])
536
537     def _fetch_neighbors(self):
538         return self._fetch_objects(["neighbor"])
539
540     def _fetch_composite(self):
541         obj = {}
542         for key, value in self._read_pairs(binary=True):
543             key = key.lower()
544             obj[key] = value
545             if key == 'binary':
546                 break
547         if not obj:
548             # If the song file was recognized, but there is no picture, the
549             # response is successful, but is otherwise empty.
550             return obj
551         amount = int(obj['binary'])
552         try:
553             obj['data'] = self._read_binary(amount)
554         except IOError as err:
555             raise ConnectionError('Error reading binary content: %s' % err)
556         if len(obj['data']) != amount:
557             raise ConnectionError('Error reading binary content: '
558                       'Expects %sB, got %s' % (amount, len(obj['data'])))
559         # Fetches trailing new line
560         self._read_line(binary=True)
561         # Fetches SUCCESS code
562         self._read_line(binary=True)
563         return obj
564
565     @iterator_wrapper
566     def _fetch_command_list(self):
567         return self._read_command_list()
568
569     def _hello(self):
570         line = self._rfile.readline()
571         if not line.endswith("\n"):
572             raise ConnectionError("Connection lost while reading MPD hello")
573         line = line.rstrip("\n")
574         if not line.startswith(HELLO_PREFIX):
575             raise ProtocolError("Got invalid MPD hello: '%s'" % line)
576         self.mpd_version = line[len(HELLO_PREFIX):].strip()
577
578     def _reset(self):
579         self.mpd_version = None
580         self._iterating = False
581         self._pending = []
582         self._command_list = None
583         self._sock = None
584         self._rfile = _NotConnected()
585         self._rbfile = _NotConnected()
586         self._wfile = _NotConnected()
587
588     def _connect_unix(self, path):
589         if not hasattr(socket, "AF_UNIX"):
590             raise ConnectionError(
591                 "Unix domain sockets not supported on this platform")
592         sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
593         sock.connect(path)
594         return sock
595
596     def _connect_tcp(self, host, port):
597         try:
598             flags = socket.AI_ADDRCONFIG
599         except AttributeError:
600             flags = 0
601         err = None
602         for res in socket.getaddrinfo(host, port, socket.AF_UNSPEC,
603                                       socket.SOCK_STREAM, socket.IPPROTO_TCP,
604                                       flags):
605             af, socktype, proto, _, sa = res
606             sock = None
607             try:
608                 sock = socket.socket(af, socktype, proto)
609                 sock.settimeout(CONNECTION_TIMEOUT)
610                 sock.connect(sa)
611                 sock.settimeout(None)
612                 return sock
613             except socket.error as socket_err:
614                 err = socket_err
615                 if sock is not None:
616                     sock.close()
617         if err is not None:
618             raise ConnectionError(str(err))
619         else:
620             raise ConnectionError("getaddrinfo returns an empty list")
621
622     def noidle(self):
623         # noidle's special case
624         if not self._pending or self._pending[0] != 'idle':
625             raise CommandError(
626                 'cannot send noidle if send_idle was not called')
627         del self._pending[0]
628         self._write_command("noidle")
629         return self._fetch_list()
630
631     def connect(self, host=None, port=None):
632         """Connects the MPD server
633
634         :param str host: hostname, IP or FQDN (defaults to `localhost` or socket, see below for details)
635         :param port: port number (defaults to 6600)
636         :type port: str or int
637
638         The connect method honors MPD_HOST/MPD_PORT environment variables.
639
640         .. note:: Default host/port
641
642           If host evaluate to :py:obj:`False`
643            * use ``MPD_HOST`` environment variable if set, extract password if present,
644            * else looks for a existing file in ``${XDG_RUNTIME_DIR:-/run/}/mpd/socket``
645            * else set host to ``localhost``
646
647           If port evaluate to :py:obj:`False`
648            * if ``MPD_PORT`` environment variable is set, use it for port
649            * else use ``6600``
650         """
651         if not host:
652             host = self.host
653         else:
654             self.host = host
655         if not port:
656             port = self.port
657         else:
658             self.port = port
659         if self._sock is not None:
660             raise ConnectionError("Already connected")
661         if host.startswith("/"):
662             self._sock = self._connect_unix(host)
663         else:
664             self._sock = self._connect_tcp(host, port)
665         self._rfile = self._sock.makefile("r", encoding='utf-8', errors='surrogateescape')
666         self._rbfile = self._sock.makefile("rb")
667         self._wfile = self._sock.makefile("w", encoding='utf-8')
668         try:
669             self._hello()
670         except:
671             self.disconnect()
672             raise
673
674     def disconnect(self):
675         """Closes the MPD connection.
676         The client closes the actual socket, it does not use the
677         'close' request from MPD protocol (as suggested in documentation).
678         """
679         if hasattr(self._rfile, 'close'):
680             self._rfile.close()
681         if hasattr(self._rbfile, 'close'):
682             self._rbfile.close()
683         if hasattr(self._wfile, 'close'):
684             self._wfile.close()
685         if hasattr(self._sock, 'close'):
686             self._sock.close()
687         self._reset()
688
689     def fileno(self):
690         if self._sock is None:
691             raise ConnectionError("Not connected")
692         return self._sock.fileno()
693
694     def command_list_ok_begin(self):
695         if self._command_list is not None:
696             raise CommandListError("Already in command list")
697         if self._iterating:
698             raise IteratingError("Cannot begin command list while iterating")
699         if self._pending:
700             raise PendingCommandError("Cannot begin command list "
701                                       "with pending commands")
702         self._write_command("command_list_ok_begin")
703         self._command_list = []
704
705     def command_list_end(self):
706         if self._command_list is None:
707             raise CommandListError("Not in command list")
708         if self._iterating:
709             raise IteratingError("Already iterating over a command list")
710         self._write_command("command_list_end")
711         return self._fetch_command_list()
712
713
714 def escape(text):
715     return text.replace("\\", "\\\\").replace('"', '\\"')
716
717 # vim: set expandtab shiftwidth=4 softtabstop=4 textwidth=79: