1 # -*- coding: utf-8 -*-
2 # SPDX-FileCopyrightText: 2012-2024 kaliko <kaliko@azylum.org>
3 # SPDX-License-Identifier: LGPL-3.0-or-later
8 from .connection import ConnectionPool, Connection
9 from .exceptions import MPDConnectionError, MPDProtocolError, MPDCommandError
10 from .utils import Range, escape
12 HELLO_PREFIX = 'OK MPD '
18 #: Seconds before a connection attempt times out
19 #: (overriden by :envvar:`MPD_TIMEOUT` env. var.)
20 CONNECTION_TIMEOUT = 30
21 #: Socket timeout in second > 0 (Default is :py:obj:`None` for no timeout)
23 #: Maximum concurrent connections
26 logging.basicConfig(level=logging.DEBUG,
27 format='%(levelname)-8s %(module)-10s %(message)s')
28 log = logging.getLogger(__name__)
36 "clearerror": self._fetch_nothing,
37 "currentsong": self._fetch_object,
38 "idle": self._fetch_list,
40 "status": self._fetch_object,
41 "stats": self._fetch_object,
42 # Playback Option Commands
43 "consume": self._fetch_nothing,
44 "crossfade": self._fetch_nothing,
45 "mixrampdb": self._fetch_nothing,
46 "mixrampdelay": self._fetch_nothing,
47 "random": self._fetch_nothing,
48 "repeat": self._fetch_nothing,
49 "setvol": self._fetch_nothing,
50 "getvol": self._fetch_object,
51 "single": self._fetch_nothing,
52 "replay_gain_mode": self._fetch_nothing,
53 "replay_gain_status": self._fetch_item,
54 "volume": self._fetch_nothing,
55 # Playback Control Commands
56 "next": self._fetch_nothing,
57 "pause": self._fetch_nothing,
58 "play": self._fetch_nothing,
59 "playid": self._fetch_nothing,
60 "previous": self._fetch_nothing,
61 "seek": self._fetch_nothing,
62 "seekid": self._fetch_nothing,
63 "seekcur": self._fetch_nothing,
64 "stop": self._fetch_nothing,
66 "add": self._fetch_nothing,
67 "addid": self._fetch_item,
68 "clear": self._fetch_nothing,
69 "delete": self._fetch_nothing,
70 "deleteid": self._fetch_nothing,
71 "move": self._fetch_nothing,
72 "moveid": self._fetch_nothing,
73 "playlist": self._fetch_playlist,
74 "playlistfind": self._fetch_songs,
75 "playlistid": self._fetch_songs,
76 "playlistinfo": self._fetch_songs,
77 "playlistsearch": self._fetch_songs,
78 "plchanges": self._fetch_songs,
79 "plchangesposid": self._fetch_changes,
80 "prio": self._fetch_nothing,
81 "prioid": self._fetch_nothing,
82 "rangeid": self._fetch_nothing,
83 "shuffle": self._fetch_nothing,
84 "swap": self._fetch_nothing,
85 "swapid": self._fetch_nothing,
86 "addtagid": self._fetch_nothing,
87 "cleartagid": self._fetch_nothing,
88 # Stored Playlist Commands
89 "listplaylist": self._fetch_list,
90 "listplaylistinfo": self._fetch_songs,
91 "listplaylists": self._fetch_playlists,
92 "load": self._fetch_nothing,
93 "playlistadd": self._fetch_nothing,
94 "playlistclear": self._fetch_nothing,
95 "playlistdelete": self._fetch_nothing,
96 "playlistmove": self._fetch_nothing,
97 "rename": self._fetch_nothing,
98 "rm": self._fetch_nothing,
99 "save": self._fetch_nothing,
101 "albumart": self._fetch_composite,
102 "count": self._fetch_object,
103 "getfingerprint": self._fetch_object,
104 "find": self._fetch_songs,
105 "findadd": self._fetch_nothing,
106 "list": self._fetch_list,
107 "listall": self._fetch_database,
108 "listallinfo": self._fetch_database,
109 "listfiles": self._fetch_database,
110 "lsinfo": self._fetch_database,
111 "readcomments": self._fetch_object,
112 "readpicture": self._fetch_composite,
113 "search": self._fetch_songs,
114 "searchadd": self._fetch_nothing,
115 "searchaddpl": self._fetch_nothing,
116 "update": self._fetch_item,
117 "rescan": self._fetch_item,
118 # Mounts and neighbors
119 "mount": self._fetch_nothing,
120 "unmount": self._fetch_nothing,
121 "listmounts": self._fetch_mounts,
122 "listneighbors": self._fetch_neighbors,
124 "sticker get": self._fetch_item,
125 "sticker set": self._fetch_nothing,
126 "sticker delete": self._fetch_nothing,
127 "sticker list": self._fetch_list,
128 "sticker find": self._fetch_songs,
129 # Connection Commands
132 "password": self._fetch_nothing,
133 "ping": self._fetch_nothing,
134 "binarylimit": self._fetch_nothing,
135 "tagtypes": self._fetch_list,
136 "tagtypes disable": self._fetch_nothing,
137 "tagtypes enable": self._fetch_nothing,
138 "tagtypes clear": self._fetch_nothing,
139 "tagtypes all": self._fetch_nothing,
141 "partition": self._fetch_nothing,
142 "listpartitions": self._fetch_list,
143 "newpartition": self._fetch_nothing,
144 "delpartition": self._fetch_nothing,
145 "moveoutput": self._fetch_nothing,
146 # Audio Output Commands
147 "disableoutput": self._fetch_nothing,
148 "enableoutput": self._fetch_nothing,
149 "toggleoutput": self._fetch_nothing,
150 "outputs": self._fetch_outputs,
151 "outputset": self._fetch_nothing,
152 # Reflection Commands
153 "config": self._fetch_object,
154 "commands": self._fetch_list,
155 "notcommands": self._fetch_list,
156 "urlhandlers": self._fetch_list,
157 "decoders": self._fetch_plugins,
159 "subscribe": self._fetch_nothing,
160 "unsubscribe": self._fetch_nothing,
161 "channels": self._fetch_list,
162 "readmessages": self._fetch_messages,
163 "sendmessage": self._fetch_nothing,
165 #: host used with the current connection (:py:obj:`str`)
167 #: password detected in :envvar:`MPD_HOST` environment variable (:py:obj:`str`)
169 #: port used with the current connection (:py:obj:`int`, :py:obj:`str`)
172 self._pool = ConnectionPool(max_connections=CONNECTION_MAX)
173 log.info('logger : "%s"', __name__)
174 #: current connection
175 self.connection: [None,Connection] = None
177 self.version: [None,str] = None
178 self._command_list = None
180 def _get_envvars(self):
182 Retrieve MPD env. var. to overrides default "localhost:6600"
185 self.host = 'localhost'
186 self.port = os.getenv('MPD_PORT', '6600')
187 _host = os.getenv('MPD_HOST', '')
189 # If password is set: MPD_HOST=pass@host
191 mpd_host_env = _host.split('@', 1)
193 # A password is actually set
195 'password detected in MPD_HOST, set client pwd attribute')
196 self.pwd = mpd_host_env[0]
198 self.host = mpd_host_env[1]
199 log.debug('host detected in MPD_HOST: %s', self.host)
200 elif mpd_host_env[1]:
201 # No password set but leading @ is an abstract socket
202 self.host = '@'+mpd_host_env[1]
204 'host detected in MPD_HOST: %s (abstract socket)', self.host)
206 # MPD_HOST is a plain host
208 log.debug('host detected in MPD_HOST: %s', self.host)
211 xdg_runtime_dir = os.getenv('XDG_RUNTIME_DIR', '/run')
212 rundir = os.path.join(xdg_runtime_dir, 'mpd/socket')
213 if os.path.exists(rundir):
216 'host detected in ${XDG_RUNTIME_DIR}/run: %s (unix socket)', self.host)
217 _mpd_timeout = os.getenv('MPD_TIMEOUT', '')
218 if _mpd_timeout.isdigit():
219 self.mpd_timeout = int(_mpd_timeout)
220 log.debug('timeout detected in MPD_TIMEOUT: %d', self.mpd_timeout)
221 else: # Use CONNECTION_TIMEOUT as default even if MPD_TIMEOUT carries gargage
222 self.mpd_timeout = CONNECTION_TIMEOUT
224 def __getattr__(self, attr):
225 # if attr == 'send_noidle': # have send_noidle to cancel idle as well as noidle
227 if attr.startswith("send_"):
228 command = attr.replace("send_", "", 1)
230 elif attr.startswith("fetch_"):
231 command = attr.replace("fetch_", "", 1)
232 wrapper = self._fetch
235 wrapper = self._execute
236 if command not in self._commands:
237 command = command.replace("_", " ")
238 if command not in self._commands:
239 cls = self.__class__.__name__
240 raise AttributeError(
241 f"'{cls}' object has no attribute '{attr}'")
242 return lambda *args: wrapper(command, args)
244 async def _execute(self, command, args): # pylint: disable=unused-argument
245 self.connection = await self._pool.connect(self.host, self.port)
246 async with self.connection:
248 # raise MPDCommandError(
249 # f"Cannot execute '{command}' with pending commands")
250 retval = self._commands[command]
251 if self._command_list is not None:
252 if not callable(retval):
253 raise MPDCommandError(
254 f"'{command}' not allowed in command list")
255 self._write_command(command, args)
256 self._command_list.append(retval)
258 await self._write_command(command, args)
260 log.debug('retvat: %s', retval)
261 return await retval()
265 async def _write_line(self, line):
266 self.connection.write(f"{line!s}\n".encode())
267 await self.connection.drain()
269 async def _write_command(self, command, args=None):
274 if isinstance(arg, tuple):
275 parts.append(f'{Range(arg)!s}')
277 parts.append(f'"{escape(str(arg))}"')
278 if '\n' in ' '.join(parts):
279 raise MPDCommandError('new line found in the command!')
280 await self._write_line(' '.join(parts))
282 def _read_binary(self, amount):
285 result = self._rbfile.read(amount)
288 raise ConnectionError(
289 "Connection lost while reading binary content")
291 amount -= len(result)
294 async def _read_line(self, binary=False):
296 line = self._rbfile.readline().decode('utf-8')
298 line = await self.connection.readline()
299 line = line.decode('utf-8')
300 if not line.endswith('\n'):
302 raise MPDConnectionError("Connection lost while reading line")
303 line = line.rstrip('\n')
304 if line.startswith(ERROR_PREFIX):
305 error = line[len(ERROR_PREFIX):].strip()
306 raise MPDCommandError(error)
307 if self._command_list is not None:
311 raise MPDProtocolError(f"Got unexpected '{SUCCESS}'")
312 elif line == SUCCESS:
316 async def _read_pair(self, separator, binary=False):
317 line = await self._read_line(binary=binary)
320 pair = line.split(separator, 1)
322 raise MPDProtocolError(f"Could not parse pair: '{line}'")
325 async def _read_pairs(self, separator=": ", binary=False):
327 pair = await self._read_pair(separator, binary=binary)
330 pair = await self._read_pair(separator, binary=binary)
332 async def _read_list(self):
334 for key, value in await self._read_pairs():
337 raise MPDProtocolError(f"Expected key '{seen}', got '{key}'")
341 async def _read_playlist(self):
342 for _, value in await self._read_pairs(":"):
345 async def _read_objects(self, delimiters=None):
347 if delimiters is None:
349 async for key, value in self._read_pairs():
352 if key in delimiters:
356 if not isinstance(obj[key], list):
357 obj[key] = [obj[key], value]
359 obj[key].append(value)
365 def _read_command_list(self):
367 for retval in self._command_list:
370 self._command_list = None
371 self._fetch_nothing()
373 async def _fetch_nothing(self):
374 line = await self._read_line()
376 raise ProtocolError(f"Got unexpected return value: '{line}'")
378 async def _fetch_item(self):
379 pairs = list(await self._read_pairs())
384 def _fetch_list(self):
385 return self._read_list()
387 def _fetch_playlist(self):
388 return self._read_playlist()
390 async def _fetch_object(self):
391 objs = [obj async for obj in self._read_objects()]
396 async def _fetch_objects(self, delimiters):
397 return [_ async for _ in self._read_objects(delimiters)]
399 def _fetch_changes(self):
400 return self._fetch_objects(["cpos"])
402 async def _fetch_songs(self):
403 return await self._fetch_objects(["file"])
405 def _fetch_playlists(self):
406 return self._fetch_objects(["playlist"])
408 def _fetch_database(self):
409 return self._fetch_objects(["file", "directory", "playlist"])
411 def _fetch_outputs(self):
412 return self._fetch_objects(["outputid"])
414 def _fetch_plugins(self):
415 return self._fetch_objects(["plugin"])
417 def _fetch_messages(self):
418 return self._fetch_objects(["channel"])
420 def _fetch_mounts(self):
421 return self._fetch_objects(["mount"])
423 def _fetch_neighbors(self):
424 return self._fetch_objects(["neighbor"])
426 async def _fetch_composite(self):
428 for key, value in self._read_pairs(binary=True):
434 # If the song file was recognized, but there is no picture, the
435 # response is successful, but is otherwise empty.
437 amount = int(obj['binary'])
439 obj['data'] = self._read_binary(amount)
440 except IOError as err:
441 raise ConnectionError(
442 f'Error reading binary content: {err}') from err
443 data_bytes = len(obj['data'])
444 if data_bytes != amount: # can we ever get there?
445 raise ConnectionError('Error reading binary content: '
446 f'Expects {amount}B, got {data_bytes}')
447 # Fetches trailing new line
448 await self._read_line(binary=True)
449 # Fetches SUCCESS code
450 await self._read_line(binary=True)
453 def _fetch_command_list(self):
454 return self._read_command_list()
456 async def _hello(self):
457 """Consume HELLO_PREFIX"""
458 data = await self.connection.readuntil(b'\n')
459 rcv = data.decode('utf-8')
460 if not rcv.startswith(HELLO_PREFIX):
461 raise MPDProtocolError(f'Got invalid MPD hello: "{rcv}"')
462 log.debug('consumed hello prefix: %r', rcv)
463 self.version = rcv.split('\n')[0][len(HELLO_PREFIX):]
464 log.info('protocol version: %s', self.version)
466 async def connect(self, server=None, port=None) -> Connection:
472 self.connection = await self._pool.connect(server, port)
473 except (ValueError, OSError) as err:
474 #TODO: Is this the right way to raise Excep
475 raise MPDConnectionError(err) from err
476 async with self.connection:
479 async def close(self):
480 await self.connection.close()