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