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