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