]> kaliko git repositories - python-musicpd.git/blob - musicpd.py
Releasing 0.9.0
[python-musicpd.git] / musicpd.py
1 # -*- coding: utf-8 -*-
2 # SPDX-FileCopyrightText: 2012-2024  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.0'
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 > 0 (Default is :py:obj:`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 MPS's password method 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 MPD's password command.
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(f'{Range(arg)!s}')
440             else:
441                 parts.append(f'"{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         try:
646             sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
647             sock.settimeout(self.mpd_timeout)
648             sock.connect(path)
649             sock.settimeout(self.socket_timeout)
650         except socket.error as socket_err:
651             raise ConnectionError(socket_err) from socket_err
652         return sock
653
654     def _connect_tcp(self, host, port):
655         try:
656             flags = socket.AI_ADDRCONFIG
657         except AttributeError:
658             flags = 0
659         err = None
660         try:
661             gai = socket.getaddrinfo(host, port, socket.AF_UNSPEC,
662                                      socket.SOCK_STREAM, socket.IPPROTO_TCP,
663                                      flags)
664         except socket.error as gaierr:
665             raise ConnectionError(gaierr) from gaierr
666         for res in gai:
667             af, socktype, proto, _, sa = res
668             sock = None
669             try:
670                 log.debug('opening socket %s', sa)
671                 sock = socket.socket(af, socktype, proto)
672                 sock.settimeout(self.mpd_timeout)
673                 sock.connect(sa)
674                 sock.settimeout(self.socket_timeout)
675                 return sock
676             except socket.error as socket_err:
677                 log.debug('opening socket %s failed: %s', sa, socket_err)
678                 err = socket_err
679                 if sock is not None:
680                     sock.close()
681         if err is not None:
682             raise ConnectionError(err)
683         raise ConnectionError("getaddrinfo returns an empty list")
684
685     def noidle(self):
686         # noidle's special case
687         if not self._pending or self._pending[0] != 'idle':
688             raise CommandError('cannot send noidle if send_idle was not called')
689         del self._pending[0]
690         self._write_command("noidle")
691         return self._fetch_list()
692
693     def connect(self, host=None, port=None):
694         """Connects the MPD server
695
696         :param str host: hostname, IP or FQDN (defaults to *localhost* or socket)
697         :param port: port number (defaults to *6600*)
698         :type port: str or int
699
700         If host/port are :py:obj:`None` the socket uses :py:attr:`host`/:py:attr:`port`
701         attributes as defaults. Cf. :py:obj:`MPDClient` for the logic behind default host/port.
702
703         The underlying socket also honors :envvar:`MPD_TIMEOUT` environment variable
704         and defaults to :py:obj:`musicpd.CONNECTION_TIMEOUT` (connect command only).
705
706         If you want to have a timeout for each command once you got connected,
707         set its value in :py:obj:`MPDClient.socket_timeout` (in second) or at
708         module level in :py:obj:`musicpd.SOCKET_TIMEOUT`.
709         """
710         if not host:
711             host = self.host
712         else:
713             self.host = host
714         if not port:
715             port = self.port
716         else:
717             self.port = port
718         if self._sock is not None:
719             raise ConnectionError("Already connected")
720         if host[0] in ['/', '@']:
721             log.debug('Connecting unix socket %s', host)
722             self._sock = self._connect_unix(host)
723         else:
724             log.debug('Connecting tcp socket %s:%s (timeout: %ss)', host, port, self.mpd_timeout)
725             self._sock = self._connect_tcp(host, port)
726         self._rfile = self._sock.makefile("r", encoding='utf-8', errors='surrogateescape')
727         self._rbfile = self._sock.makefile("rb")
728         self._wfile = self._sock.makefile("w", encoding='utf-8')
729         try:
730             self._hello()
731         except:
732             self.disconnect()
733             raise
734         log.debug('Connected')
735
736     @property
737     def socket_timeout(self):
738         """Socket timeout in second (defaults to :py:obj:`SOCKET_TIMEOUT`).
739         Use :py:obj:`None` to disable socket timout.
740
741         :setter: Set the socket timeout (integer > 0)
742         :type: int or None
743         """
744         return self._socket_timeout
745
746     @socket_timeout.setter
747     def socket_timeout(self, timeout):
748         if timeout is not None:
749             if int(timeout) <= 0:
750                 raise ValueError('socket_timeout expects a non zero positive integer')
751             self._socket_timeout = int(timeout)
752         else:
753             self._socket_timeout = timeout
754         if getattr(self._sock, 'settimeout', False):
755             self._sock.settimeout(self._socket_timeout)
756
757
758     def disconnect(self):
759         """Closes the MPD connection.
760         The client closes the actual socket, it does not use the
761         'close' request from MPD protocol (as suggested in documentation).
762         """
763         if hasattr(self._rfile, 'close'):
764             self._rfile.close()
765         if hasattr(self._rbfile, 'close'):
766             self._rbfile.close()
767         if hasattr(self._wfile, 'close'):
768             self._wfile.close()
769         if hasattr(self._sock, 'close'):
770             self._sock.close()
771         self._reset()
772
773     def __enter__(self):
774         self.connect()
775         return self
776
777     def __exit__(self, exception_type, exception_value, exception_traceback):
778         self.disconnect()
779
780     def fileno(self):
781         """Return the socket’s file descriptor (a small integer).
782         This is useful with :py:obj:`select.select`.
783         """
784         if self._sock is None:
785             raise ConnectionError("Not connected")
786         return self._sock.fileno()
787
788     def command_list_ok_begin(self):
789         if self._command_list is not None:
790             raise CommandListError("Already in command list")
791         if self._iterating:
792             raise IteratingError("Cannot begin command list while iterating")
793         if self._pending:
794             raise PendingCommandError("Cannot begin command list with pending commands")
795         self._write_command("command_list_ok_begin")
796         self._command_list = []
797
798     def command_list_end(self):
799         if self._command_list is None:
800             raise CommandListError("Not in command list")
801         if self._iterating:
802             raise IteratingError("Already iterating over a command list")
803         self._write_command("command_list_end")
804         return self._fetch_command_list()
805
806
807 def escape(text):
808     return text.replace("\\", "\\\\").replace('"', '\\"')
809
810 # vim: set expandtab shiftwidth=4 softtabstop=4 textwidth=79: