]> kaliko git repositories - python-musicpd.git/blob - musicpd.py
b44518b9837954ee775ae3f9d844d34888b4dd62
[python-musicpd.git] / musicpd.py
1 # SPDX-FileCopyrightText: 2012-2022  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: GPL-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.8.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._reset()
156         self._commands = {
157             # Status Commands
158             "clearerror":         self._fetch_nothing,
159             "currentsong":        self._fetch_object,
160             "idle":               self._fetch_list,
161             #"noidle":             None,
162             "status":             self._fetch_object,
163             "stats":              self._fetch_object,
164             # Playback Option Commands
165             "consume":            self._fetch_nothing,
166             "crossfade":          self._fetch_nothing,
167             "mixrampdb":          self._fetch_nothing,
168             "mixrampdelay":       self._fetch_nothing,
169             "random":             self._fetch_nothing,
170             "repeat":             self._fetch_nothing,
171             "setvol":             self._fetch_nothing,
172             "getvol":             self._fetch_object,
173             "single":             self._fetch_nothing,
174             "replay_gain_mode":   self._fetch_nothing,
175             "replay_gain_status": self._fetch_item,
176             "volume":             self._fetch_nothing,
177             # Playback Control Commands
178             "next":               self._fetch_nothing,
179             "pause":              self._fetch_nothing,
180             "play":               self._fetch_nothing,
181             "playid":             self._fetch_nothing,
182             "previous":           self._fetch_nothing,
183             "seek":               self._fetch_nothing,
184             "seekid":             self._fetch_nothing,
185             "seekcur":            self._fetch_nothing,
186             "stop":               self._fetch_nothing,
187             # Queue Commands
188             "add":                self._fetch_nothing,
189             "addid":              self._fetch_item,
190             "clear":              self._fetch_nothing,
191             "delete":             self._fetch_nothing,
192             "deleteid":           self._fetch_nothing,
193             "move":               self._fetch_nothing,
194             "moveid":             self._fetch_nothing,
195             "playlist":           self._fetch_playlist,
196             "playlistfind":       self._fetch_songs,
197             "playlistid":         self._fetch_songs,
198             "playlistinfo":       self._fetch_songs,
199             "playlistsearch":     self._fetch_songs,
200             "plchanges":          self._fetch_songs,
201             "plchangesposid":     self._fetch_changes,
202             "prio":               self._fetch_nothing,
203             "prioid":             self._fetch_nothing,
204             "rangeid":            self._fetch_nothing,
205             "shuffle":            self._fetch_nothing,
206             "swap":               self._fetch_nothing,
207             "swapid":             self._fetch_nothing,
208             "addtagid":           self._fetch_nothing,
209             "cleartagid":         self._fetch_nothing,
210             # Stored Playlist Commands
211             "listplaylist":       self._fetch_list,
212             "listplaylistinfo":   self._fetch_songs,
213             "listplaylists":      self._fetch_playlists,
214             "load":               self._fetch_nothing,
215             "playlistadd":        self._fetch_nothing,
216             "playlistclear":      self._fetch_nothing,
217             "playlistdelete":     self._fetch_nothing,
218             "playlistmove":       self._fetch_nothing,
219             "rename":             self._fetch_nothing,
220             "rm":                 self._fetch_nothing,
221             "save":               self._fetch_nothing,
222             # Database Commands
223             "albumart":           self._fetch_composite,
224             "count":              self._fetch_object,
225             "getfingerprint":     self._fetch_object,
226             "find":               self._fetch_songs,
227             "findadd":            self._fetch_nothing,
228             "list":               self._fetch_list,
229             "listall":            self._fetch_database,
230             "listallinfo":        self._fetch_database,
231             "listfiles":          self._fetch_database,
232             "lsinfo":             self._fetch_database,
233             "readcomments":       self._fetch_object,
234             "readpicture":        self._fetch_composite,
235             "search":             self._fetch_songs,
236             "searchadd":          self._fetch_nothing,
237             "searchaddpl":        self._fetch_nothing,
238             "update":             self._fetch_item,
239             "rescan":             self._fetch_item,
240             # Mounts and neighbors
241             "mount":              self._fetch_nothing,
242             "unmount":            self._fetch_nothing,
243             "listmounts":         self._fetch_mounts,
244             "listneighbors":      self._fetch_neighbors,
245             # Sticker Commands
246             "sticker get":        self._fetch_item,
247             "sticker set":        self._fetch_nothing,
248             "sticker delete":     self._fetch_nothing,
249             "sticker list":       self._fetch_list,
250             "sticker find":       self._fetch_songs,
251             # Connection Commands
252             "close":              None,
253             "kill":               None,
254             "password":           self._fetch_nothing,
255             "ping":               self._fetch_nothing,
256             "binarylimit":        self._fetch_nothing,
257             "tagtypes":           self._fetch_list,
258             "tagtypes disable":   self._fetch_nothing,
259             "tagtypes enable":    self._fetch_nothing,
260             "tagtypes clear":     self._fetch_nothing,
261             "tagtypes all":       self._fetch_nothing,
262             # Partition Commands
263             "partition":          self._fetch_nothing,
264             "listpartitions":     self._fetch_list,
265             "newpartition":       self._fetch_nothing,
266             "delpartition":       self._fetch_nothing,
267             "moveoutput":         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.getenv('MPD_PORT', '6600')
298         if os.getenv('MPD_HOST'):
299             # If password is set: MPD_HOST=pass@host
300             if '@' in os.getenv('MPD_HOST'):
301                 mpd_host_env = os.getenv('MPD_HOST').split('@', 1)
302                 if mpd_host_env[0]:
303                     # A password is actually set
304                     self.pwd = mpd_host_env[0]
305                     if mpd_host_env[1]:
306                         self.host = mpd_host_env[1]
307                 elif mpd_host_env[1]:
308                     # No password set but leading @ is an abstract socket
309                     self.host = '@'+mpd_host_env[1]
310             else:
311                 # MPD_HOST is a plain host
312                 self.host = os.getenv('MPD_HOST')
313         else:
314             # Is socket there
315             xdg_runtime_dir = os.getenv('XDG_RUNTIME_DIR', '/run')
316             rundir = os.path.join(xdg_runtime_dir, 'mpd/socket')
317             if os.path.exists(rundir):
318                 self.host = rundir
319         self.mpd_timeout = os.getenv('MPD_TIMEOUT')
320         if self.mpd_timeout and self.mpd_timeout.isdigit():
321             self.mpd_timeout = int(self.mpd_timeout)
322         else:  # Use CONNECTION_TIMEOUT as default even if MPD_TIMEOUT carries gargage
323             self.mpd_timeout = CONNECTION_TIMEOUT
324
325     def __getattr__(self, attr):
326         if attr == 'send_noidle':  # have send_noidle to cancel idle as well as noidle
327             return self.noidle()
328         if attr.startswith("send_"):
329             command = attr.replace("send_", "", 1)
330             wrapper = self._send
331         elif attr.startswith("fetch_"):
332             command = attr.replace("fetch_", "", 1)
333             wrapper = self._fetch
334         else:
335             command = attr
336             wrapper = self._execute
337         if command not in self._commands:
338             command = command.replace("_", " ")
339             if command not in self._commands:
340                 cls = self.__class__.__name__
341                 raise AttributeError(f"'{cls}' object has no attribute '{attr}'")
342         return lambda *args: wrapper(command, args)
343
344     def _send(self, command, args):
345         if self._command_list is not None:
346             raise CommandListError("Cannot use send_%s in a command list" %
347                                    command.replace(" ", "_"))
348         self._write_command(command, args)
349         retval = self._commands[command]
350         if retval is not None:
351             self._pending.append(command)
352
353     def _fetch(self, command, args=None):  # pylint: disable=unused-argument
354         cmd_fmt = command.replace(" ", "_")
355         if self._command_list is not None:
356             raise CommandListError(f"Cannot use fetch_{cmd_fmt} in a command list")
357         if self._iterating:
358             raise IteratingError(f"Cannot use fetch_{cmd_fmt} while iterating")
359         if not self._pending:
360             raise PendingCommandError("No pending commands to fetch")
361         if self._pending[0] != command:
362             raise PendingCommandError(f"'{command}' is not the currently pending command")
363         del self._pending[0]
364         retval = self._commands[command]
365         if callable(retval):
366             return retval()
367         return retval
368
369     def _execute(self, command, args):  # pylint: disable=unused-argument
370         if self._iterating:
371             raise IteratingError(f"Cannot execute '{command}' while iterating")
372         if self._pending:
373             raise PendingCommandError(f"Cannot execute '{command}' with pending commands")
374         retval = self._commands[command]
375         if self._command_list is not None:
376             if not callable(retval):
377                 raise CommandListError(f"'{command}' not allowed in command list")
378             self._write_command(command, args)
379             self._command_list.append(retval)
380         else:
381             self._write_command(command, args)
382             if callable(retval):
383                 return retval()
384             return retval
385         return None
386
387     def _write_line(self, line):
388         self._wfile.write(f"{line!s}\n")
389         self._wfile.flush()
390
391     def _write_command(self, command, args=None):
392         if args is None:
393             args = []
394         parts = [command]
395         for arg in args:
396             if isinstance(arg, tuple):
397                 parts.append('{0!s}'.format(Range(arg)))
398             else:
399                 parts.append('"%s"' % escape(str(arg)))
400         if '\n' in ' '.join(parts):
401             raise CommandError('new line found in the command!')
402         self._write_line(" ".join(parts))
403
404     def _read_binary(self, amount):
405         chunk = bytearray()
406         while amount > 0:
407             result = self._rbfile.read(amount)
408             if len(result) == 0:
409                 self.disconnect()
410                 raise ConnectionError("Connection lost while reading binary content")
411             chunk.extend(result)
412             amount -= len(result)
413         return bytes(chunk)
414
415     def _read_line(self, binary=False):
416         if binary:
417             line = self._rbfile.readline().decode('utf-8')
418         else:
419             line = self._rfile.readline()
420         if not line.endswith("\n"):
421             self.disconnect()
422             raise ConnectionError("Connection lost while reading line")
423         line = line.rstrip("\n")
424         if line.startswith(ERROR_PREFIX):
425             error = line[len(ERROR_PREFIX):].strip()
426             raise CommandError(error)
427         if self._command_list is not None:
428             if line == NEXT:
429                 return None
430             if line == SUCCESS:
431                 raise ProtocolError(f"Got unexpected '{SUCCESS}'")
432         elif line == SUCCESS:
433             return None
434         return line
435
436     def _read_pair(self, separator, binary=False):
437         line = self._read_line(binary=binary)
438         if line is None:
439             return None
440         pair = line.split(separator, 1)
441         if len(pair) < 2:
442             raise ProtocolError(f"Could not parse pair: '{line}'")
443         return pair
444
445     def _read_pairs(self, separator=": ", binary=False):
446         pair = self._read_pair(separator, binary=binary)
447         while pair:
448             yield pair
449             pair = self._read_pair(separator, binary=binary)
450
451     def _read_list(self):
452         seen = None
453         for key, value in self._read_pairs():
454             if key != seen:
455                 if seen is not None:
456                     raise ProtocolError(f"Expected key '{seen}', got '{key}'")
457                 seen = key
458             yield value
459
460     def _read_playlist(self):
461         for _, value in self._read_pairs(":"):
462             yield value
463
464     def _read_objects(self, delimiters=None):
465         obj = {}
466         if delimiters is None:
467             delimiters = []
468         for key, value in self._read_pairs():
469             key = key.lower()
470             if obj:
471                 if key in delimiters:
472                     yield obj
473                     obj = {}
474                 elif key in obj:
475                     if not isinstance(obj[key], list):
476                         obj[key] = [obj[key], value]
477                     else:
478                         obj[key].append(value)
479                     continue
480             obj[key] = value
481         if obj:
482             yield obj
483
484     def _read_command_list(self):
485         try:
486             for retval in self._command_list:
487                 yield retval()
488         finally:
489             self._command_list = None
490         self._fetch_nothing()
491
492     def _fetch_nothing(self):
493         line = self._read_line()
494         if line is not None:
495             raise ProtocolError(f"Got unexpected return value: '{line}'")
496
497     def _fetch_item(self):
498         pairs = list(self._read_pairs())
499         if len(pairs) != 1:
500             return None
501         return pairs[0][1]
502
503     @iterator_wrapper
504     def _fetch_list(self):
505         return self._read_list()
506
507     @iterator_wrapper
508     def _fetch_playlist(self):
509         return self._read_playlist()
510
511     def _fetch_object(self):
512         objs = list(self._read_objects())
513         if not objs:
514             return {}
515         return objs[0]
516
517     @iterator_wrapper
518     def _fetch_objects(self, delimiters):
519         return self._read_objects(delimiters)
520
521     def _fetch_changes(self):
522         return self._fetch_objects(["cpos"])
523
524     def _fetch_songs(self):
525         return self._fetch_objects(["file"])
526
527     def _fetch_playlists(self):
528         return self._fetch_objects(["playlist"])
529
530     def _fetch_database(self):
531         return self._fetch_objects(["file", "directory", "playlist"])
532
533     def _fetch_outputs(self):
534         return self._fetch_objects(["outputid"])
535
536     def _fetch_plugins(self):
537         return self._fetch_objects(["plugin"])
538
539     def _fetch_messages(self):
540         return self._fetch_objects(["channel"])
541
542     def _fetch_mounts(self):
543         return self._fetch_objects(["mount"])
544
545     def _fetch_neighbors(self):
546         return self._fetch_objects(["neighbor"])
547
548     def _fetch_composite(self):
549         obj = {}
550         for key, value in self._read_pairs(binary=True):
551             key = key.lower()
552             obj[key] = value
553             if key == 'binary':
554                 break
555         if not obj:
556             # If the song file was recognized, but there is no picture, the
557             # response is successful, but is otherwise empty.
558             return obj
559         amount = int(obj['binary'])
560         try:
561             obj['data'] = self._read_binary(amount)
562         except IOError as err:
563             raise ConnectionError(f'Error reading binary content: {err}') from err
564         data_bytes = len(obj['data'])
565         if data_bytes != amount:  # can we ever get there?
566             raise ConnectionError('Error reading binary content: '
567                     f'Expects {amount}B, got {data_bytes}')
568         # Fetches trailing new line
569         self._read_line(binary=True)
570         # Fetches SUCCESS code
571         self._read_line(binary=True)
572         return obj
573
574     @iterator_wrapper
575     def _fetch_command_list(self):
576         return self._read_command_list()
577
578     def _hello(self):
579         line = self._rfile.readline()
580         if not line.endswith("\n"):
581             raise ConnectionError("Connection lost while reading MPD hello")
582         line = line.rstrip("\n")
583         if not line.startswith(HELLO_PREFIX):
584             raise ProtocolError(f"Got invalid MPD hello: '{line}'")
585         self.mpd_version = line[len(HELLO_PREFIX):].strip()
586
587     def _reset(self):
588         self.mpd_version = None
589         self._iterating = False
590         self._pending = []
591         self._command_list = None
592         self._sock = None
593         self._rfile = _NotConnected()
594         self._rbfile = _NotConnected()
595         self._wfile = _NotConnected()
596
597     def _connect_unix(self, path):
598         if not hasattr(socket, "AF_UNIX"):
599             raise ConnectionError("Unix domain sockets not supported on this platform")
600         # abstract socket
601         if path.startswith('@'):
602             path = '\0'+path[1:]
603         sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
604         sock.settimeout(self.mpd_timeout)
605         sock.connect(path)
606         sock.settimeout(self.socket_timeout)
607         return sock
608
609     def _connect_tcp(self, host, port):
610         try:
611             flags = socket.AI_ADDRCONFIG
612         except AttributeError:
613             flags = 0
614         err = None
615         for res in socket.getaddrinfo(host, port, socket.AF_UNSPEC,
616                                       socket.SOCK_STREAM, socket.IPPROTO_TCP,
617                                       flags):
618             af, socktype, proto, _, sa = res
619             sock = None
620             try:
621                 sock = socket.socket(af, socktype, proto)
622                 sock.settimeout(self.mpd_timeout)
623                 sock.connect(sa)
624                 sock.settimeout(self.socket_timeout)
625                 return sock
626             except socket.error as socket_err:
627                 err = socket_err
628                 if sock is not None:
629                     sock.close()
630         if err is not None:
631             raise ConnectionError(str(err))
632         raise ConnectionError("getaddrinfo returns an empty list")
633
634     def noidle(self):
635         # noidle's special case
636         if not self._pending or self._pending[0] != 'idle':
637             raise CommandError('cannot send noidle if send_idle was not called')
638         del self._pending[0]
639         self._write_command("noidle")
640         return self._fetch_list()
641
642     def connect(self, host=None, port=None):
643         """Connects the MPD server
644
645         :param str host: hostname, IP or FQDN (defaults to `localhost` or socket, see below for details)
646         :param port: port number (defaults to 6600)
647         :type port: str or int
648
649         The connect method honors MPD_HOST/MPD_PORT environment variables.
650
651         The underlying socket also honors MPD_TIMEOUT environment variable
652         and defaults to :py:obj:`musicpd.CONNECTION_TIMEOUT` (connect command only).
653
654         If you want to have a timeout for each command once you got connected,
655         set its value in :py:obj:`MPDClient.socket_timeout` (in second) or at
656         module level in :py:obj:`musicpd.SOCKET_TIMEOUT`.
657
658         .. note:: Default host/port
659
660           If host evaluate to :py:obj:`False`
661            * use ``MPD_HOST`` environment variable if set, extract password if present,
662            * else looks for an existing file in ``${XDG_RUNTIME_DIR:-/run/}/mpd/socket``
663            * else set host to ``localhost``
664
665           If port evaluate to :py:obj:`False`
666            * if ``MPD_PORT`` environment variable is set, use it for port
667            * else use ``6600``
668         """
669         if not host:
670             host = self.host
671         else:
672             self.host = host
673         if not port:
674             port = self.port
675         else:
676             self.port = port
677         if self._sock is not None:
678             raise ConnectionError("Already connected")
679         if host[0] in ['/', '@']:
680             self._sock = self._connect_unix(host)
681         else:
682             self._sock = self._connect_tcp(host, port)
683         self._rfile = self._sock.makefile("r", encoding='utf-8', errors='surrogateescape')
684         self._rbfile = self._sock.makefile("rb")
685         self._wfile = self._sock.makefile("w", encoding='utf-8')
686         try:
687             self._hello()
688         except:
689             self.disconnect()
690             raise
691
692     @property
693     def socket_timeout(self):
694         """Socket timeout in second (defaults to :py:obj:`SOCKET_TIMEOUT`).
695         Use None to disable socket timout."""
696         return self._socket_timeout
697
698     @socket_timeout.setter
699     def socket_timeout(self, timeout):
700         self._socket_timeout = timeout
701         if getattr(self._sock, 'settimeout', False):
702             self._sock.settimeout(self._socket_timeout)
703
704     def disconnect(self):
705         """Closes the MPD connection.
706         The client closes the actual socket, it does not use the
707         'close' request from MPD protocol (as suggested in documentation).
708         """
709         if hasattr(self._rfile, 'close'):
710             self._rfile.close()
711         if hasattr(self._rbfile, 'close'):
712             self._rbfile.close()
713         if hasattr(self._wfile, 'close'):
714             self._wfile.close()
715         if hasattr(self._sock, 'close'):
716             self._sock.close()
717         self._reset()
718
719     def __enter__(self):
720         self.connect()
721         return self
722
723     def __exit__(self, exception_type, exception_value, exception_traceback):
724         self.disconnect()
725
726     def fileno(self):
727         """Return the socket’s file descriptor (a small integer).
728         This is useful with :py:obj:`select.select`.
729         """
730         if self._sock is None:
731             raise ConnectionError("Not connected")
732         return self._sock.fileno()
733
734     def command_list_ok_begin(self):
735         if self._command_list is not None:
736             raise CommandListError("Already in command list")
737         if self._iterating:
738             raise IteratingError("Cannot begin command list while iterating")
739         if self._pending:
740             raise PendingCommandError("Cannot begin command list with pending commands")
741         self._write_command("command_list_ok_begin")
742         self._command_list = []
743
744     def command_list_end(self):
745         if self._command_list is None:
746             raise CommandListError("Not in command list")
747         if self._iterating:
748             raise IteratingError("Already iterating over a command list")
749         self._write_command("command_list_end")
750         return self._fetch_command_list()
751
752
753 def escape(text):
754     return text.replace("\\", "\\\\").replace('"', '\\"')
755
756 # vim: set expandtab shiftwidth=4 softtabstop=4 textwidth=79: