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