1 # -*- coding: utf-8 -*-
2 # SPDX-FileCopyrightText: 2012-2024 kaliko <kaliko@azylum.org>
3 # SPDX-FileCopyrightText: 2008-2010 J. Alexander Treuman <jat@spatialrift.net>
4 # SPDX-License-Identifier: LGPL-3.0-or-later
9 from .connection import ConnectionPool, Connection
10 from .exceptions import MPDConnectionError, MPDProtocolError, MPDCommandError
11 from .utils import Range, escape
13 from .const import CONNECTION_MAX, CONNECTION_TIMEOUT
14 from .const import HELLO_PREFIX, ERROR_PREFIX, SUCCESS, NEXT
16 log = logging.getLogger(__name__)
20 """:synopsis: Main class to instanciate building an MPD client.
22 :param host: MPD server IP|FQDN to connect to
23 :param port: MPD port to connect to
24 :param password: MPD password
26 **musicpdaio** tries to come with sane defaults, then running
27 |mpdaio.MPDClient| with no explicit argument will try default values
28 to connect to MPD. Cf. :ref:`reference` for more about
29 :ref:`defaults<default_settings>`.
31 The class is also exposed in mpdaio namespace.
34 >>> cli = mpdaio.MPDClient(host='example.org')
35 >>> print(await cli.currentsong())
39 def __init__(self, host: str | None = None,
40 port: str | int | None = None,
41 password: str | None = None):
43 self._pool = ConnectionPool(max_connections=CONNECTION_MAX)
45 self.mpd_timeout = CONNECTION_TIMEOUT
47 #: Host used to make connections (:py:obj:`str`)
48 self.host = host or self.server_discovery[0]
49 #: password used to connect (:py:obj:`str`)
50 self.pwd = password or self.server_discovery[2]
51 #: port used with the current connection (:py:obj:`int`, :py:obj:`str`)
52 self.port = port or self.server_discovery[1]
53 log.info('Using %s:%s to connect', self.host, self.port)
55 def _get_envvars(self):
57 Retrieve MPD env. var. to overrides default "localhost:6600"
60 disco_host = 'localhost'
61 disco_port = os.getenv('MPD_PORT', '6600')
63 _host = os.getenv('MPD_HOST', '')
65 # If password is set: MPD_HOST=pass@host
67 mpd_host_env = _host.split('@', 1)
69 # A password is actually set
71 'password detected in MPD_HOST, set client pwd attribute')
74 disco_host = mpd_host_env[1]
75 log.debug('host detected in MPD_HOST: %s', disco_host)
77 # No password set but leading @ is an abstract socket
78 disco_host = '@'+mpd_host_env[1]
80 'host detected in MPD_HOST: %s (abstract socket)', disco_host)
82 # MPD_HOST is a plain host
84 log.debug('host detected in MPD_HOST: %s', disco_host)
87 xdg_runtime_dir = os.getenv('XDG_RUNTIME_DIR', '/run')
88 rundir = os.path.join(xdg_runtime_dir, 'mpd/socket')
89 if os.path.exists(rundir):
92 'host detected in ${XDG_RUNTIME_DIR}/run: %s (unix socket)', disco_host)
93 _mpd_timeout = os.getenv('MPD_TIMEOUT', '')
94 if _mpd_timeout.isdigit():
95 self.mpd_timeout = int(_mpd_timeout)
96 log.debug('timeout detected in MPD_TIMEOUT: %d', self.mpd_timeout)
97 else: # Use CONNECTION_TIMEOUT as default even if MPD_TIMEOUT carries gargage
98 self.mpd_timeout = CONNECTION_TIMEOUT
99 self.server_discovery = (disco_host, disco_port, pwd)
101 def __getattr__(self, attr):
103 wrapper = CmdHandler(self._pool, self.host, self.port, self.pwd, self.mpd_timeout)
104 if command not in wrapper._commands:
105 command = command.replace("_", " ")
106 if command not in wrapper._commands:
107 raise AttributeError(
108 f"'CmdHandler' object has no attribute '{attr}'")
109 return lambda *args: wrapper(command, args)
112 def version(self) -> str:
113 """MPD protocol version
115 host = (self.host, self.port)
116 version = {_.version for _ in self.connections}
118 log.warning('No connections yet in the connections pool for %s', host)
121 log.warning('More than one version in the connections pool for %s', host)
125 def connections(self) -> list[Connection]:
126 """connections in the pool"""
127 host = (self.host, self.port)
128 return self._pool._connections.get(host, [])
130 async def close(self) -> None:
131 """:synopsis: Close connections in the pool"""
132 await self._pool.close()
137 def __init__(self, pool, server, port, password, timeout):
140 "clearerror": self._fetch_nothing,
141 "currentsong": self._fetch_object,
142 "idle": self._fetch_list,
143 "noidle": self._fetch_nothing,
144 "status": self._fetch_object,
145 "stats": self._fetch_object,
146 # Playback Option Commands
147 "consume": self._fetch_nothing,
148 "crossfade": self._fetch_nothing,
149 "mixrampdb": self._fetch_nothing,
150 "mixrampdelay": self._fetch_nothing,
151 "random": self._fetch_nothing,
152 "repeat": self._fetch_nothing,
153 "setvol": self._fetch_nothing,
154 "getvol": self._fetch_object,
155 "single": self._fetch_nothing,
156 "replay_gain_mode": self._fetch_nothing,
157 "replay_gain_status": self._fetch_item,
158 "volume": self._fetch_nothing,
159 # Playback Control Commands
160 "next": self._fetch_nothing,
161 "pause": self._fetch_nothing,
162 "play": self._fetch_nothing,
163 "playid": self._fetch_nothing,
164 "previous": self._fetch_nothing,
165 "seek": self._fetch_nothing,
166 "seekid": self._fetch_nothing,
167 "seekcur": self._fetch_nothing,
168 "stop": self._fetch_nothing,
170 "add": self._fetch_nothing,
171 "addid": self._fetch_item,
172 "clear": self._fetch_nothing,
173 "delete": self._fetch_nothing,
174 "deleteid": self._fetch_nothing,
175 "move": self._fetch_nothing,
176 "moveid": self._fetch_nothing,
177 "playlist": self._fetch_playlist,
178 "playlistfind": self._fetch_songs,
179 "playlistid": self._fetch_songs,
180 "playlistinfo": self._fetch_songs,
181 "playlistsearch": self._fetch_songs,
182 "plchanges": self._fetch_songs,
183 "plchangesposid": self._fetch_changes,
184 "prio": self._fetch_nothing,
185 "prioid": self._fetch_nothing,
186 "rangeid": self._fetch_nothing,
187 "shuffle": self._fetch_nothing,
188 "swap": self._fetch_nothing,
189 "swapid": self._fetch_nothing,
190 "addtagid": self._fetch_nothing,
191 "cleartagid": self._fetch_nothing,
192 # Stored Playlist Commands
193 "listplaylist": self._fetch_list,
194 "listplaylistinfo": self._fetch_songs,
195 "listplaylists": self._fetch_playlists,
196 "load": self._fetch_nothing,
197 "playlistadd": self._fetch_nothing,
198 "playlistclear": self._fetch_nothing,
199 "playlistdelete": self._fetch_nothing,
200 "playlistmove": self._fetch_nothing,
201 "rename": self._fetch_nothing,
202 "rm": self._fetch_nothing,
203 "save": self._fetch_nothing,
205 "albumart": self._fetch_composite,
206 "count": self._fetch_object,
207 "getfingerprint": self._fetch_object,
208 "find": self._fetch_songs,
209 "findadd": self._fetch_nothing,
210 "list": self._fetch_list,
211 "listall": self._fetch_database,
212 "listallinfo": self._fetch_database,
213 "listfiles": self._fetch_database,
214 "lsinfo": self._fetch_database,
215 "readcomments": self._fetch_object,
216 "readpicture": self._fetch_composite,
217 "search": self._fetch_songs,
218 "searchadd": self._fetch_nothing,
219 "searchaddpl": self._fetch_nothing,
220 "update": self._fetch_item,
221 "rescan": self._fetch_item,
222 # Mounts and neighbors
223 "mount": self._fetch_nothing,
224 "unmount": self._fetch_nothing,
225 "listmounts": self._fetch_mounts,
226 "listneighbors": self._fetch_neighbors,
228 "sticker get": self._fetch_item,
229 "sticker set": self._fetch_nothing,
230 "sticker delete": self._fetch_nothing,
231 "sticker list": self._fetch_list,
232 "sticker find": self._fetch_songs,
233 # Connection Commands
236 "password": self._fetch_nothing,
237 "ping": self._fetch_nothing,
238 "binarylimit": self._fetch_nothing,
239 "tagtypes": self._fetch_list,
240 "tagtypes disable": self._fetch_nothing,
241 "tagtypes enable": self._fetch_nothing,
242 "tagtypes clear": self._fetch_nothing,
243 "tagtypes all": self._fetch_nothing,
245 "partition": self._fetch_nothing,
246 "listpartitions": self._fetch_list,
247 "newpartition": self._fetch_nothing,
248 "delpartition": self._fetch_nothing,
249 "moveoutput": self._fetch_nothing,
250 # Audio Output Commands
251 "disableoutput": self._fetch_nothing,
252 "enableoutput": self._fetch_nothing,
253 "toggleoutput": self._fetch_nothing,
254 "outputs": self._fetch_outputs,
255 "outputset": self._fetch_nothing,
256 # Reflection Commands
257 "config": self._fetch_object,
258 "commands": self._fetch_list,
259 "notcommands": self._fetch_list,
260 "urlhandlers": self._fetch_list,
261 "decoders": self._fetch_plugins,
263 "subscribe": self._fetch_nothing,
264 "unsubscribe": self._fetch_nothing,
265 "channels": self._fetch_list,
266 "readmessages": self._fetch_messages,
267 "sendmessage": self._fetch_nothing,
270 self._command_list = None
273 self.host = (server, port)
274 self.password = password
275 self.timeout = timeout
276 #: current connection
277 self.connection: [None, Connection] = None
280 args = [str(_) for _ in self.args]
281 args = ','.join(args or [])
282 return f'{self.command}({args})'
284 async def __call__(self, command: str, args: list | None):
285 server, port = self.host
286 self.command = command
287 self.args = args or ''
288 self.connection = await self.pool.connect(server, port, self.timeout)
289 async with self.connection:
290 await self._init_connection()
291 retval = self._commands[command]
292 await self._write_command(command, args)
294 return await retval()
297 async def _init_connection(self):
298 """Init connection if needed
300 * Consumes the hello line and sets the protocol version
301 * Send password command if a password is provided
303 if not self.connection.version:
305 if self.password and not self.connection.auth:
306 # Need to send password
307 await self._write_command('password', [self.password])
308 await self._fetch_nothing()
309 self.connection.auth = True
311 async def _hello(self) -> None:
312 """Consume HELLO_PREFIX"""
313 data = await self.connection.readuntil(b'\n')
314 rcv = data.decode('utf-8')
315 if not rcv.startswith(HELLO_PREFIX):
316 raise MPDProtocolError(f'Got invalid MPD hello: "{rcv}"')
317 log.debug('consumed hello prefix: %r', rcv)
318 self.connection.version = rcv.split('\n')[0][len(HELLO_PREFIX):]
320 async def _write_line(self, line):
321 self.connection.write(f"{line!s}\n".encode())
322 await self.connection.drain()
324 async def _write_command(self, command, args=None):
329 if isinstance(arg, tuple):
330 parts.append(f'{Range(arg)!s}')
332 parts.append(f'"{escape(str(arg))}"')
333 if '\n' in ' '.join(parts):
334 raise MPDCommandError('new line found in the command!')
335 #log.debug(' '.join(parts))
336 await self._write_line(' '.join(parts))
338 async def _read_binary(self, amount):
341 result = await self.connection.read(amount)
343 await self.connection.close()
344 raise ConnectionError(
345 "Connection lost while reading binary content")
347 amount -= len(result)
350 async def _read_line(self, binary=False):
351 line = await self.connection.readline()
352 line = line.decode('utf-8')
353 if not line.endswith('\n'):
354 await self.connection.close()
355 raise MPDConnectionError("Connection lost while reading line")
356 line = line.rstrip('\n')
357 if line.startswith(ERROR_PREFIX):
358 error = line[len(ERROR_PREFIX):].strip()
359 raise MPDCommandError(error)
360 if self._command_list is not None:
364 raise MPDProtocolError(f"Got unexpected '{SUCCESS}'")
365 elif line == SUCCESS:
369 async def _read_pair(self, separator, binary=False):
370 line = await self._read_line(binary=binary)
373 pair = line.split(separator, 1)
375 raise MPDProtocolError(f"Could not parse pair: '{line}'")
378 async def _read_pairs(self, separator=": ", binary=False):
379 pair = await self._read_pair(separator, binary=binary)
382 pair = await self._read_pair(separator, binary=binary)
384 async def _read_list(self):
386 async for key, value in self._read_pairs():
389 raise MPDProtocolError(
390 f"Expected key '{seen}', got '{key}'")
394 async def _read_playlist(self):
395 async for _, value in self._read_pairs(":"):
398 async def _read_objects(self, delimiters=None):
400 if delimiters is None:
402 async for key, value in self._read_pairs():
405 if key in delimiters:
409 if not isinstance(obj[key], list):
410 obj[key] = [obj[key], value]
412 obj[key].append(value)
418 async def _read_command_list(self):
420 for retval in self._command_list:
423 self._command_list = None
424 await self._fetch_nothing()
426 async def _fetch_nothing(self):
427 line = await self._read_line()
429 raise MPDProtocolError(f"Got unexpected return value: '{line}'")
431 async def _fetch_item(self):
432 pairs = [_ async for _ in self._read_pairs()]
437 async def _fetch_list(self):
438 return [_ async for _ in self._read_list()]
440 async def _fetch_playlist(self):
441 return [_ async for _ in self._read_pairs(':')]
443 async def _fetch_object(self):
444 objs = [obj async for obj in self._read_objects()]
449 async def _fetch_objects(self, delimiters):
450 return [_ async for _ in self._read_objects(delimiters)]
452 async def _fetch_changes(self):
453 return await self._fetch_objects(["cpos"])
455 async def _fetch_songs(self):
456 return await self._fetch_objects(["file"])
458 async def _fetch_playlists(self):
459 return await self._fetch_objects(["playlist"])
461 async def _fetch_database(self):
462 return await self._fetch_objects(["file", "directory", "playlist"])
464 async def _fetch_outputs(self):
465 return await self._fetch_objects(["outputid"])
467 async def _fetch_plugins(self):
468 return await self._fetch_objects(["plugin"])
470 async def _fetch_messages(self):
471 return await self._fetch_objects(["channel"])
473 async def _fetch_mounts(self):
474 return await self._fetch_objects(["mount"])
476 async def _fetch_neighbors(self):
477 return await self._fetch_objects(["neighbor"])
479 async def _fetch_composite(self):
481 async for key, value in self._read_pairs(binary=True):
487 # If the song file was recognized, but there is no picture, the
488 # response is successful, but is otherwise empty.
490 amount = int(obj['binary'])
492 obj['data'] = await self._read_binary(amount)
493 except IOError as err:
494 raise ConnectionError(
495 f'Error reading binary content: {err}') from err
496 data_bytes = len(obj['data'])
497 if data_bytes != amount: # can we ever get there?
498 raise ConnectionError('Error reading binary content: '
499 f'Expects {amount}B, got {data_bytes}')
500 # Fetches trailing new line
501 await self._read_line(binary=True)
502 #ALT: await self.connection.readuntil(b'\n')
503 # Fetches SUCCESS code
504 await self._read_line(binary=True)
505 #ALT: await self.connection.readuntil(b'OK\n')
508 async def _fetch_command_list(self):
509 return [_ async for _ in self._read_command_list()]