]> kaliko git repositories - python-musicpd.git/blob - musicpd.py
Fixed send_noidle (introduced with e8daa719)
[python-musicpd.git] / musicpd.py
1 # SPDX-FileCopyrightText: 2012-2023  kaliko <kaliko@azylum.org>
2 # SPDX-FileCopyrightText: 2021       Wonko der Verständige <wonko@hanstool.org>
3 # SPDX-FileCopyrightText: 2019       Naglis Jonaitis <naglis@mailbox.org>
4 # SPDX-FileCopyrightText: 2019       Bart Van Loon <bbb@bbbart.be>
5 # SPDX-FileCopyrightText: 2008-2010  J. Alexander Treuman <jat@spatialrift.net>
6 # SPDX-License-Identifier: LGPL-3.0-or-later
7 """python-musicpd: Python Music Player Daemon client library"""
8
9
10 import socket
11 import os
12
13 from functools import wraps
14
15 HELLO_PREFIX = "OK MPD "
16 ERROR_PREFIX = "ACK "
17 SUCCESS = "OK"
18 NEXT = "list_OK"
19 VERSION = '0.9.0b0'
20 #: Seconds before a connection attempt times out
21 #: (overriden by MPD_TIMEOUT env. var.)
22 CONNECTION_TIMEOUT = 30
23 #: Socket timeout in second (Default is None for no timeout)
24 SOCKET_TIMEOUT = None
25
26
27 def iterator_wrapper(func):
28     """Decorator handling iterate option"""
29     @wraps(func)
30     def decorated_function(instance, *args, **kwargs):
31         generator = func(instance, *args, **kwargs)
32         if not instance.iterate:
33             return list(generator)
34         instance._iterating = True
35
36         def iterator(gen):
37             try:
38                 for item in gen:
39                     yield item
40             finally:
41                 instance._iterating = False
42         return iterator(generator)
43     return decorated_function
44
45
46 class MPDError(Exception):
47     pass
48
49
50 class ConnectionError(MPDError):
51     pass
52
53
54 class ProtocolError(MPDError):
55     pass
56
57
58 class CommandError(MPDError):
59     pass
60
61
62 class CommandListError(MPDError):
63     pass
64
65
66 class PendingCommandError(MPDError):
67     pass
68
69
70 class IteratingError(MPDError):
71     pass
72
73
74 class Range:
75
76     def __init__(self, tpl):
77         self.tpl = tpl
78         self._check()
79
80     def __str__(self):
81         if len(self.tpl) == 0:
82             return ':'
83         if len(self.tpl) == 1:
84             return '{0}:'.format(self.tpl[0])
85         return '{0[0]}:{0[1]}'.format(self.tpl)
86
87     def __repr__(self):
88         return 'Range({0})'.format(self.tpl)
89
90     def _check(self):
91         if not isinstance(self.tpl, tuple):
92             raise CommandError('Wrong type, provide a tuple')
93         if len(self.tpl) not in [0, 1, 2]:
94             raise CommandError('length not in [0, 1, 2]')
95         for index in self.tpl:
96             try:
97                 index = int(index)
98             except (TypeError, ValueError) as err:
99                 raise CommandError('Not a tuple of int') from err
100
101
102 class _NotConnected:
103
104     def __getattr__(self, attr):
105         return self._dummy
106
107     def _dummy(self, *args):
108         raise ConnectionError("Not connected")
109
110
111 class MPDClient:
112     """MPDClient instance will look for ``MPD_HOST``/``MPD_PORT``/``XDG_RUNTIME_DIR`` environment
113     variables and set instance attribute ``host``, ``port`` and ``pwd``
114     accordingly. Regarding ``MPD_HOST`` format to expose password refer
115     MPD client manual :manpage:`mpc (1)`.
116
117     Then :py:obj:`musicpd.MPDClient.connect` will use ``host`` and ``port`` as defaults if not provided as args.
118
119     Cf. :py:obj:`musicpd.MPDClient.connect` for details.
120
121     >>> from os import environ
122     >>> environ['MPD_HOST'] = 'pass@mpdhost'
123     >>> cli = musicpd.MPDClient()
124     >>> cli.pwd == environ['MPD_HOST'].split('@')[0]
125     True
126     >>> cli.host == environ['MPD_HOST'].split('@')[1]
127     True
128     >>> cli.connect() # will use host/port as set in MPD_HOST/MPD_PORT
129
130     :ivar str host: host used with the current connection
131     :ivar str,int port: port used with the current connection
132     :ivar str pwd: password detected in ``MPD_HOST`` environment variable
133
134     .. warning:: Instance attribute host/port/pwd
135
136       While :py:attr:`musicpd.MPDClient().host` and
137       :py:attr:`musicpd.MPDClient().port` keep track of current connection
138       host and port, :py:attr:`musicpd.MPDClient().pwd` is set once with
139       password extracted from environment variable.
140       Calling :py:meth:`musicpd.MPDClient().password()` with a new password
141       won't update :py:attr:`musicpd.MPDClient().pwd` value.
142
143       Moreover, :py:attr:`musicpd.MPDClient().pwd` is only an helper attribute
144       exposing password extracted from ``MPD_HOST`` environment variable, it
145       will not be used as default value for the :py:meth:`password` method
146     """
147
148     def __init__(self):
149         self.iterate = False
150         #: Socket timeout value in seconds
151         self._socket_timeout = SOCKET_TIMEOUT
152         #: Current connection timeout value, defaults to
153         #: :py:obj:`CONNECTION_TIMEOUT` or env. var. ``MPD_TIMEOUT`` if provided
154         self.mpd_timeout = None
155         self.mpd_version = ''
156         """Protocol version as exposed by the server as a :py:obj:`str`
157
158         .. note:: This is the version of the protocol spoken, not the real version of the daemon."""
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             "getvol":             self._fetch_object,
177             "single":             self._fetch_nothing,
178             "replay_gain_mode":   self._fetch_nothing,
179             "replay_gain_status": self._fetch_item,
180             "volume":             self._fetch_nothing,
181             # Playback Control Commands
182             "next":               self._fetch_nothing,
183             "pause":              self._fetch_nothing,
184             "play":               self._fetch_nothing,
185             "playid":             self._fetch_nothing,
186             "previous":           self._fetch_nothing,
187             "seek":               self._fetch_nothing,
188             "seekid":             self._fetch_nothing,
189             "seekcur":            self._fetch_nothing,
190             "stop":               self._fetch_nothing,
191             # Queue Commands
192             "add":                self._fetch_nothing,
193             "addid":              self._fetch_item,
194             "clear":              self._fetch_nothing,
195             "delete":             self._fetch_nothing,
196             "deleteid":           self._fetch_nothing,
197             "move":               self._fetch_nothing,
198             "moveid":             self._fetch_nothing,
199             "playlist":           self._fetch_playlist,
200             "playlistfind":       self._fetch_songs,
201             "playlistid":         self._fetch_songs,
202             "playlistinfo":       self._fetch_songs,
203             "playlistsearch":     self._fetch_songs,
204             "plchanges":          self._fetch_songs,
205             "plchangesposid":     self._fetch_changes,
206             "prio":               self._fetch_nothing,
207             "prioid":             self._fetch_nothing,
208             "rangeid":            self._fetch_nothing,
209             "shuffle":            self._fetch_nothing,
210             "swap":               self._fetch_nothing,
211             "swapid":             self._fetch_nothing,
212             "addtagid":           self._fetch_nothing,
213             "cleartagid":         self._fetch_nothing,
214             # Stored Playlist Commands
215             "listplaylist":       self._fetch_list,
216             "listplaylistinfo":   self._fetch_songs,
217             "listplaylists":      self._fetch_playlists,
218             "load":               self._fetch_nothing,
219             "playlistadd":        self._fetch_nothing,
220             "playlistclear":      self._fetch_nothing,
221             "playlistdelete":     self._fetch_nothing,
222             "playlistmove":       self._fetch_nothing,
223             "rename":             self._fetch_nothing,
224             "rm":                 self._fetch_nothing,
225             "save":               self._fetch_nothing,
226             # Database Commands
227             "albumart":           self._fetch_composite,
228             "count":              self._fetch_object,
229             "getfingerprint":     self._fetch_object,
230             "find":               self._fetch_songs,
231             "findadd":            self._fetch_nothing,
232             "list":               self._fetch_list,
233             "listall":            self._fetch_database,
234             "listallinfo":        self._fetch_database,
235             "listfiles":          self._fetch_database,
236             "lsinfo":             self._fetch_database,
237             "readcomments":       self._fetch_object,
238             "readpicture":        self._fetch_composite,
239             "search":             self._fetch_songs,
240             "searchadd":          self._fetch_nothing,
241             "searchaddpl":        self._fetch_nothing,
242             "update":             self._fetch_item,
243             "rescan":             self._fetch_item,
244             # Mounts and neighbors
245             "mount":              self._fetch_nothing,
246             "unmount":            self._fetch_nothing,
247             "listmounts":         self._fetch_mounts,
248             "listneighbors":      self._fetch_neighbors,
249             # Sticker Commands
250             "sticker get":        self._fetch_item,
251             "sticker set":        self._fetch_nothing,
252             "sticker delete":     self._fetch_nothing,
253             "sticker list":       self._fetch_list,
254             "sticker find":       self._fetch_songs,
255             # Connection Commands
256             "close":              None,
257             "kill":               None,
258             "password":           self._fetch_nothing,
259             "ping":               self._fetch_nothing,
260             "binarylimit":        self._fetch_nothing,
261             "tagtypes":           self._fetch_list,
262             "tagtypes disable":   self._fetch_nothing,
263             "tagtypes enable":    self._fetch_nothing,
264             "tagtypes clear":     self._fetch_nothing,
265             "tagtypes all":       self._fetch_nothing,
266             # Partition Commands
267             "partition":          self._fetch_nothing,
268             "listpartitions":     self._fetch_list,
269             "newpartition":       self._fetch_nothing,
270             "delpartition":       self._fetch_nothing,
271             "moveoutput":         self._fetch_nothing,
272             # Audio Output Commands
273             "disableoutput":      self._fetch_nothing,
274             "enableoutput":       self._fetch_nothing,
275             "toggleoutput":       self._fetch_nothing,
276             "outputs":            self._fetch_outputs,
277             "outputset":          self._fetch_nothing,
278             # Reflection Commands
279             "config":             self._fetch_object,
280             "commands":           self._fetch_list,
281             "notcommands":        self._fetch_list,
282             "urlhandlers":        self._fetch_list,
283             "decoders":           self._fetch_plugins,
284             # Client to Client
285             "subscribe":          self._fetch_nothing,
286             "unsubscribe":        self._fetch_nothing,
287             "channels":           self._fetch_list,
288             "readmessages":       self._fetch_messages,
289             "sendmessage":        self._fetch_nothing,
290         }
291         self._get_envvars()
292
293     def _get_envvars(self):
294         """
295         Retrieve MPD env. var. to overrides "localhost:6600"
296             Use MPD_HOST/MPD_PORT if set
297             else use MPD_HOST=${XDG_RUNTIME_DIR:-/run/}/mpd/socket if file exists
298         """
299         self.host = 'localhost'
300         self.pwd = None
301         self.port = os.getenv('MPD_PORT', '6600')
302         _host = os.getenv('MPD_HOST', '')
303         if _host:
304             # If password is set: MPD_HOST=pass@host
305             if '@' in _host:
306                 mpd_host_env = _host.split('@', 1)
307                 if mpd_host_env[0]:
308                     # A password is actually set
309                     self.pwd = mpd_host_env[0]
310                     if mpd_host_env[1]:
311                         self.host = mpd_host_env[1]
312                 elif mpd_host_env[1]:
313                     # No password set but leading @ is an abstract socket
314                     self.host = '@'+mpd_host_env[1]
315             else:
316                 # MPD_HOST is a plain host
317                 self.host = _host
318         else:
319             # Is socket there
320             xdg_runtime_dir = os.getenv('XDG_RUNTIME_DIR', '/run')
321             rundir = os.path.join(xdg_runtime_dir, 'mpd/socket')
322             if os.path.exists(rundir):
323                 self.host = rundir
324         _mpd_timeout = os.getenv('MPD_TIMEOUT', '')
325         if _mpd_timeout.isdigit():
326             self.mpd_timeout = int(_mpd_timeout)
327         else:  # Use CONNECTION_TIMEOUT as default even if MPD_TIMEOUT carries gargage
328             self.mpd_timeout = CONNECTION_TIMEOUT
329
330     def __getattr__(self, attr):
331         if attr == 'send_noidle':  # have send_noidle to cancel idle as well as noidle
332             return self.noidle
333         if attr.startswith("send_"):
334             command = attr.replace("send_", "", 1)
335             wrapper = self._send
336         elif attr.startswith("fetch_"):
337             command = attr.replace("fetch_", "", 1)
338             wrapper = self._fetch
339         else:
340             command = attr
341             wrapper = self._execute
342         if command not in self._commands:
343             command = command.replace("_", " ")
344             if command not in self._commands:
345                 cls = self.__class__.__name__
346                 raise AttributeError(f"'{cls}' object has no attribute '{attr}'")
347         return lambda *args: wrapper(command, args)
348
349     def _send(self, command, args):
350         if self._command_list is not None:
351             raise CommandListError("Cannot use send_%s in a command list" %
352                                    command.replace(" ", "_"))
353         self._write_command(command, args)
354         retval = self._commands[command]
355         if retval is not None:
356             self._pending.append(command)
357
358     def _fetch(self, command, args=None):  # pylint: disable=unused-argument
359         cmd_fmt = command.replace(" ", "_")
360         if self._command_list is not None:
361             raise CommandListError(f"Cannot use fetch_{cmd_fmt} in a command list")
362         if self._iterating:
363             raise IteratingError(f"Cannot use fetch_{cmd_fmt} while iterating")
364         if not self._pending:
365             raise PendingCommandError("No pending commands to fetch")
366         if self._pending[0] != command:
367             raise PendingCommandError(f"'{command}' is not the currently pending command")
368         del self._pending[0]
369         retval = self._commands[command]
370         if callable(retval):
371             return retval()
372         return retval
373
374     def _execute(self, command, args):  # pylint: disable=unused-argument
375         if self._iterating:
376             raise IteratingError(f"Cannot execute '{command}' while iterating")
377         if self._pending:
378             raise PendingCommandError(f"Cannot execute '{command}' with pending commands")
379         retval = self._commands[command]
380         if self._command_list is not None:
381             if not callable(retval):
382                 raise CommandListError(f"'{command}' not allowed in command list")
383             self._write_command(command, args)
384             self._command_list.append(retval)
385         else:
386             self._write_command(command, args)
387             if callable(retval):
388                 return retval()
389             return retval
390         return None
391
392     def _write_line(self, line):
393         self._wfile.write(f"{line!s}\n")
394         self._wfile.flush()
395
396     def _write_command(self, command, args=None):
397         if args is None:
398             args = []
399         parts = [command]
400         for arg in args:
401             if isinstance(arg, tuple):
402                 parts.append('{0!s}'.format(Range(arg)))
403             else:
404                 parts.append('"%s"' % escape(str(arg)))
405         if '\n' in ' '.join(parts):
406             raise CommandError('new line found in the command!')
407         self._write_line(" ".join(parts))
408
409     def _read_binary(self, amount):
410         chunk = bytearray()
411         while amount > 0:
412             result = self._rbfile.read(amount)
413             if len(result) == 0:
414                 self.disconnect()
415                 raise ConnectionError("Connection lost while reading binary content")
416             chunk.extend(result)
417             amount -= len(result)
418         return bytes(chunk)
419
420     def _read_line(self, binary=False):
421         if binary:
422             line = self._rbfile.readline().decode('utf-8')
423         else:
424             line = self._rfile.readline()
425         if not line.endswith("\n"):
426             self.disconnect()
427             raise ConnectionError("Connection lost while reading line")
428         line = line.rstrip("\n")
429         if line.startswith(ERROR_PREFIX):
430             error = line[len(ERROR_PREFIX):].strip()
431             raise CommandError(error)
432         if self._command_list is not None:
433             if line == NEXT:
434                 return None
435             if line == SUCCESS:
436                 raise ProtocolError(f"Got unexpected '{SUCCESS}'")
437         elif line == SUCCESS:
438             return None
439         return line
440
441     def _read_pair(self, separator, binary=False):
442         line = self._read_line(binary=binary)
443         if line is None:
444             return None
445         pair = line.split(separator, 1)
446         if len(pair) < 2:
447             raise ProtocolError(f"Could not parse pair: '{line}'")
448         return pair
449
450     def _read_pairs(self, separator=": ", binary=False):
451         pair = self._read_pair(separator, binary=binary)
452         while pair:
453             yield pair
454             pair = self._read_pair(separator, binary=binary)
455
456     def _read_list(self):
457         seen = None
458         for key, value in self._read_pairs():
459             if key != seen:
460                 if seen is not None:
461                     raise ProtocolError(f"Expected key '{seen}', got '{key}'")
462                 seen = key
463             yield value
464
465     def _read_playlist(self):
466         for _, value in self._read_pairs(":"):
467             yield value
468
469     def _read_objects(self, delimiters=None):
470         obj = {}
471         if delimiters is None:
472             delimiters = []
473         for key, value in self._read_pairs():
474             key = key.lower()
475             if obj:
476                 if key in delimiters:
477                     yield obj
478                     obj = {}
479                 elif key in obj:
480                     if not isinstance(obj[key], list):
481                         obj[key] = [obj[key], value]
482                     else:
483                         obj[key].append(value)
484                     continue
485             obj[key] = value
486         if obj:
487             yield obj
488
489     def _read_command_list(self):
490         try:
491             for retval in self._command_list:
492                 yield retval()
493         finally:
494             self._command_list = None
495         self._fetch_nothing()
496
497     def _fetch_nothing(self):
498         line = self._read_line()
499         if line is not None:
500             raise ProtocolError(f"Got unexpected return value: '{line}'")
501
502     def _fetch_item(self):
503         pairs = list(self._read_pairs())
504         if len(pairs) != 1:
505             return None
506         return pairs[0][1]
507
508     @iterator_wrapper
509     def _fetch_list(self):
510         return self._read_list()
511
512     @iterator_wrapper
513     def _fetch_playlist(self):
514         return self._read_playlist()
515
516     def _fetch_object(self):
517         objs = list(self._read_objects())
518         if not objs:
519             return {}
520         return objs[0]
521
522     @iterator_wrapper
523     def _fetch_objects(self, delimiters):
524         return self._read_objects(delimiters)
525
526     def _fetch_changes(self):
527         return self._fetch_objects(["cpos"])
528
529     def _fetch_songs(self):
530         return self._fetch_objects(["file"])
531
532     def _fetch_playlists(self):
533         return self._fetch_objects(["playlist"])
534
535     def _fetch_database(self):
536         return self._fetch_objects(["file", "directory", "playlist"])
537
538     def _fetch_outputs(self):
539         return self._fetch_objects(["outputid"])
540
541     def _fetch_plugins(self):
542         return self._fetch_objects(["plugin"])
543
544     def _fetch_messages(self):
545         return self._fetch_objects(["channel"])
546
547     def _fetch_mounts(self):
548         return self._fetch_objects(["mount"])
549
550     def _fetch_neighbors(self):
551         return self._fetch_objects(["neighbor"])
552
553     def _fetch_composite(self):
554         obj = {}
555         for key, value in self._read_pairs(binary=True):
556             key = key.lower()
557             obj[key] = value
558             if key == 'binary':
559                 break
560         if not obj:
561             # If the song file was recognized, but there is no picture, the
562             # response is successful, but is otherwise empty.
563             return obj
564         amount = int(obj['binary'])
565         try:
566             obj['data'] = self._read_binary(amount)
567         except IOError as err:
568             raise ConnectionError(f'Error reading binary content: {err}') from err
569         data_bytes = len(obj['data'])
570         if data_bytes != amount:  # can we ever get there?
571             raise ConnectionError('Error reading binary content: '
572                     f'Expects {amount}B, got {data_bytes}')
573         # Fetches trailing new line
574         self._read_line(binary=True)
575         # Fetches SUCCESS code
576         self._read_line(binary=True)
577         return obj
578
579     @iterator_wrapper
580     def _fetch_command_list(self):
581         return self._read_command_list()
582
583     def _hello(self):
584         line = self._rfile.readline()
585         if not line.endswith("\n"):
586             raise ConnectionError("Connection lost while reading MPD hello")
587         line = line.rstrip("\n")
588         if not line.startswith(HELLO_PREFIX):
589             raise ProtocolError(f"Got invalid MPD hello: '{line}'")
590         self.mpd_version = line[len(HELLO_PREFIX):].strip()
591
592     def _reset(self):
593         self.mpd_version = ''
594         self._iterating = False
595         self._pending = []
596         self._command_list = None
597         self._sock = None
598         self._rfile = _NotConnected()
599         self._rbfile = _NotConnected()
600         self._wfile = _NotConnected()
601
602     def _connect_unix(self, path):
603         if not hasattr(socket, "AF_UNIX"):
604             raise ConnectionError("Unix domain sockets not supported on this platform")
605         # abstract socket
606         if path.startswith('@'):
607             path = '\0'+path[1:]
608         sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
609         sock.settimeout(self.mpd_timeout)
610         sock.connect(path)
611         sock.settimeout(self.socket_timeout)
612         return sock
613
614     def _connect_tcp(self, host, port):
615         try:
616             flags = socket.AI_ADDRCONFIG
617         except AttributeError:
618             flags = 0
619         err = None
620         for res in socket.getaddrinfo(host, port, socket.AF_UNSPEC,
621                                       socket.SOCK_STREAM, socket.IPPROTO_TCP,
622                                       flags):
623             af, socktype, proto, _, sa = res
624             sock = None
625             try:
626                 sock = socket.socket(af, socktype, proto)
627                 sock.settimeout(self.mpd_timeout)
628                 sock.connect(sa)
629                 sock.settimeout(self.socket_timeout)
630                 return sock
631             except socket.error as socket_err:
632                 err = socket_err
633                 if sock is not None:
634                     sock.close()
635         if err is not None:
636             raise ConnectionError(str(err))
637         raise ConnectionError("getaddrinfo returns an empty list")
638
639     def noidle(self):
640         # noidle's special case
641         if not self._pending or self._pending[0] != 'idle':
642             raise CommandError('cannot send noidle if send_idle was not called')
643         del self._pending[0]
644         self._write_command("noidle")
645         return self._fetch_list()
646
647     def connect(self, host=None, port=None):
648         """Connects the MPD server
649
650         :param str host: hostname, IP or FQDN (defaults to `localhost` or socket, see below for details)
651         :param port: port number (defaults to 6600)
652         :type port: str or int
653
654         The connect method honors MPD_HOST/MPD_PORT environment variables.
655
656         The underlying socket also honors MPD_TIMEOUT environment variable
657         and defaults to :py:obj:`musicpd.CONNECTION_TIMEOUT` (connect command only).
658
659         If you want to have a timeout for each command once you got connected,
660         set its value in :py:obj:`MPDClient.socket_timeout` (in second) or at
661         module level in :py:obj:`musicpd.SOCKET_TIMEOUT`.
662
663         .. note:: Default host/port
664
665           If host evaluate to :py:obj:`False`
666            * use ``MPD_HOST`` environment variable if set, extract password if present,
667            * else looks for an existing file in ``${XDG_RUNTIME_DIR:-/run/}/mpd/socket``
668            * else set host to ``localhost``
669
670           If port evaluate to :py:obj:`False`
671            * if ``MPD_PORT`` environment variable is set, use it for port
672            * else use ``6600``
673         """
674         if not host:
675             host = self.host
676         else:
677             self.host = host
678         if not port:
679             port = self.port
680         else:
681             self.port = port
682         if self._sock is not None:
683             raise ConnectionError("Already connected")
684         if host[0] in ['/', '@']:
685             self._sock = self._connect_unix(host)
686         else:
687             self._sock = self._connect_tcp(host, port)
688         self._rfile = self._sock.makefile("r", encoding='utf-8', errors='surrogateescape')
689         self._rbfile = self._sock.makefile("rb")
690         self._wfile = self._sock.makefile("w", encoding='utf-8')
691         try:
692             self._hello()
693         except:
694             self.disconnect()
695             raise
696
697     @property
698     def socket_timeout(self):
699         """Socket timeout in second (defaults to :py:obj:`SOCKET_TIMEOUT`).
700         Use None to disable socket timout."""
701         return self._socket_timeout
702
703     @socket_timeout.setter
704     def socket_timeout(self, timeout):
705         self._socket_timeout = timeout
706         if getattr(self._sock, 'settimeout', False):
707             self._sock.settimeout(self._socket_timeout)
708
709     def disconnect(self):
710         """Closes the MPD connection.
711         The client closes the actual socket, it does not use the
712         'close' request from MPD protocol (as suggested in documentation).
713         """
714         if hasattr(self._rfile, 'close'):
715             self._rfile.close()
716         if hasattr(self._rbfile, 'close'):
717             self._rbfile.close()
718         if hasattr(self._wfile, 'close'):
719             self._wfile.close()
720         if hasattr(self._sock, 'close'):
721             self._sock.close()
722         self._reset()
723
724     def __enter__(self):
725         self.connect()
726         return self
727
728     def __exit__(self, exception_type, exception_value, exception_traceback):
729         self.disconnect()
730
731     def fileno(self):
732         """Return the socket’s file descriptor (a small integer).
733         This is useful with :py:obj:`select.select`.
734         """
735         if self._sock is None:
736             raise ConnectionError("Not connected")
737         return self._sock.fileno()
738
739     def command_list_ok_begin(self):
740         if self._command_list is not None:
741             raise CommandListError("Already in command list")
742         if self._iterating:
743             raise IteratingError("Cannot begin command list while iterating")
744         if self._pending:
745             raise PendingCommandError("Cannot begin command list with pending commands")
746         self._write_command("command_list_ok_begin")
747         self._command_list = []
748
749     def command_list_end(self):
750         if self._command_list is None:
751             raise CommandListError("Not in command list")
752         if self._iterating:
753             raise IteratingError("Already iterating over a command list")
754         self._write_command("command_list_end")
755         return self._fetch_command_list()
756
757
758 def escape(text):
759     return text.replace("\\", "\\\\").replace('"', '\\"')
760
761 # vim: set expandtab shiftwidth=4 softtabstop=4 textwidth=79: