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