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