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 . import CONNECTION_MAX, CONNECTION_TIMEOUT
14 from . import ERROR_PREFIX, SUCCESS, NEXT
16 log = logging.getLogger(__name__)
21 def __init__(self, host: str | None = None, port: str | int | None = None, password: str | None = None):
22 self._pool = ConnectionPool(max_connections=CONNECTION_MAX)
24 #: host used with the current connection (:py:obj:`str`)
25 self.host = host or self.server_discovery[0]
26 #: password detected in :envvar:`MPD_HOST` environment variable (:py:obj:`str`)
27 self.password = password or self.server_discovery[2]
28 #: port used with the current connection (:py:obj:`int`, :py:obj:`str`)
29 self.port = port or self.server_discovery[1]
30 self.mpd_timeout = CONNECTION_TIMEOUT
31 log.info('Using %s:%s to connect', self.host, self.port)
33 def _get_envvars(self):
35 Retrieve MPD env. var. to overrides default "localhost:6600"
38 disco_host = 'localhost'
39 disco_port = os.getenv('MPD_PORT', '6600')
41 _host = os.getenv('MPD_HOST', '')
43 # If password is set: MPD_HOST=pass@host
45 mpd_host_env = _host.split('@', 1)
47 # A password is actually set
49 'password detected in MPD_HOST, set client pwd attribute')
52 disco_host = mpd_host_env[1]
53 log.debug('host detected in MPD_HOST: %s', disco_host)
55 # No password set but leading @ is an abstract socket
56 disco_host = '@'+mpd_host_env[1]
58 'host detected in MPD_HOST: %s (abstract socket)', disco_host)
60 # MPD_HOST is a plain host
62 log.debug('host detected in MPD_HOST: %s', disco_host)
65 xdg_runtime_dir = os.getenv('XDG_RUNTIME_DIR', '/run')
66 rundir = os.path.join(xdg_runtime_dir, 'mpd/socket')
67 if os.path.exists(rundir):
70 'host detected in ${XDG_RUNTIME_DIR}/run: %s (unix socket)', disco_host)
71 _mpd_timeout = os.getenv('MPD_TIMEOUT', '')
72 if _mpd_timeout.isdigit():
73 self.mpd_timeout = int(_mpd_timeout)
74 log.debug('timeout detected in MPD_TIMEOUT: %d', self.mpd_timeout)
75 else: # Use CONNECTION_TIMEOUT as default even if MPD_TIMEOUT carries gargage
76 self.mpd_timeout = CONNECTION_TIMEOUT
77 self.server_discovery = (disco_host, disco_port, pwd)
79 def __getattr__(self, attr):
81 wrapper = CmdHandler(self._pool, self.host, self.port, self.password, self.mpd_timeout)
82 if command not in wrapper._commands:
83 command = command.replace("_", " ")
84 if command not in wrapper._commands:
86 f"'CmdHandler' object has no attribute '{attr}'")
87 return lambda *args: wrapper(command, args)
91 """MPD protocol version"""
92 host = (self.host, self.port)
93 version = {_.version for _ in self.connections}
95 log.warning('No connections yet in the connections pool for %s', host)
98 log.warning('More than one version in the connections pool for %s', host)
102 def connections(self):
103 """Open connections"""
104 host = (self.host, self.port)
105 return self._pool._connections.get(host, [])
107 async def close(self):
108 await self._pool.close()
113 def __init__(self, pool, server, port, password, timeout):
116 "clearerror": self._fetch_nothing,
117 "currentsong": self._fetch_object,
118 "idle": self._fetch_list,
120 "status": self._fetch_object,
121 "stats": self._fetch_object,
122 # Playback Option Commands
123 "consume": self._fetch_nothing,
124 "crossfade": self._fetch_nothing,
125 "mixrampdb": self._fetch_nothing,
126 "mixrampdelay": self._fetch_nothing,
127 "random": self._fetch_nothing,
128 "repeat": self._fetch_nothing,
129 "setvol": self._fetch_nothing,
130 "getvol": self._fetch_object,
131 "single": self._fetch_nothing,
132 "replay_gain_mode": self._fetch_nothing,
133 "replay_gain_status": self._fetch_item,
134 "volume": self._fetch_nothing,
135 # Playback Control Commands
136 "next": self._fetch_nothing,
137 "pause": self._fetch_nothing,
138 "play": self._fetch_nothing,
139 "playid": self._fetch_nothing,
140 "previous": self._fetch_nothing,
141 "seek": self._fetch_nothing,
142 "seekid": self._fetch_nothing,
143 "seekcur": self._fetch_nothing,
144 "stop": self._fetch_nothing,
146 "add": self._fetch_nothing,
147 "addid": self._fetch_item,
148 "clear": self._fetch_nothing,
149 "delete": self._fetch_nothing,
150 "deleteid": self._fetch_nothing,
151 "move": self._fetch_nothing,
152 "moveid": self._fetch_nothing,
153 "playlist": self._fetch_playlist,
154 "playlistfind": self._fetch_songs,
155 "playlistid": self._fetch_songs,
156 "playlistinfo": self._fetch_songs,
157 "playlistsearch": self._fetch_songs,
158 "plchanges": self._fetch_songs,
159 "plchangesposid": self._fetch_changes,
160 "prio": self._fetch_nothing,
161 "prioid": self._fetch_nothing,
162 "rangeid": self._fetch_nothing,
163 "shuffle": self._fetch_nothing,
164 "swap": self._fetch_nothing,
165 "swapid": self._fetch_nothing,
166 "addtagid": self._fetch_nothing,
167 "cleartagid": self._fetch_nothing,
168 # Stored Playlist Commands
169 "listplaylist": self._fetch_list,
170 "listplaylistinfo": self._fetch_songs,
171 "listplaylists": self._fetch_playlists,
172 "load": self._fetch_nothing,
173 "playlistadd": self._fetch_nothing,
174 "playlistclear": self._fetch_nothing,
175 "playlistdelete": self._fetch_nothing,
176 "playlistmove": self._fetch_nothing,
177 "rename": self._fetch_nothing,
178 "rm": self._fetch_nothing,
179 "save": self._fetch_nothing,
181 "albumart": self._fetch_composite,
182 "count": self._fetch_object,
183 "getfingerprint": self._fetch_object,
184 "find": self._fetch_songs,
185 "findadd": self._fetch_nothing,
186 "list": self._fetch_list,
187 "listall": self._fetch_database,
188 "listallinfo": self._fetch_database,
189 "listfiles": self._fetch_database,
190 "lsinfo": self._fetch_database,
191 "readcomments": self._fetch_object,
192 "readpicture": self._fetch_composite,
193 "search": self._fetch_songs,
194 "searchadd": self._fetch_nothing,
195 "searchaddpl": self._fetch_nothing,
196 "update": self._fetch_item,
197 "rescan": self._fetch_item,
198 # Mounts and neighbors
199 "mount": self._fetch_nothing,
200 "unmount": self._fetch_nothing,
201 "listmounts": self._fetch_mounts,
202 "listneighbors": self._fetch_neighbors,
204 "sticker get": self._fetch_item,
205 "sticker set": self._fetch_nothing,
206 "sticker delete": self._fetch_nothing,
207 "sticker list": self._fetch_list,
208 "sticker find": self._fetch_songs,
209 # Connection Commands
212 "password": self._fetch_nothing,
213 "ping": self._fetch_nothing,
214 "binarylimit": self._fetch_nothing,
215 "tagtypes": self._fetch_list,
216 "tagtypes disable": self._fetch_nothing,
217 "tagtypes enable": self._fetch_nothing,
218 "tagtypes clear": self._fetch_nothing,
219 "tagtypes all": self._fetch_nothing,
221 "partition": self._fetch_nothing,
222 "listpartitions": self._fetch_list,
223 "newpartition": self._fetch_nothing,
224 "delpartition": self._fetch_nothing,
225 "moveoutput": self._fetch_nothing,
226 # Audio Output Commands
227 "disableoutput": self._fetch_nothing,
228 "enableoutput": self._fetch_nothing,
229 "toggleoutput": self._fetch_nothing,
230 "outputs": self._fetch_outputs,
231 "outputset": self._fetch_nothing,
232 # Reflection Commands
233 "config": self._fetch_object,
234 "commands": self._fetch_list,
235 "notcommands": self._fetch_list,
236 "urlhandlers": self._fetch_list,
237 "decoders": self._fetch_plugins,
239 "subscribe": self._fetch_nothing,
240 "unsubscribe": self._fetch_nothing,
241 "channels": self._fetch_list,
242 "readmessages": self._fetch_messages,
243 "sendmessage": self._fetch_nothing,
246 self._command_list = None
249 self.host = (server, port)
250 self.password = password
251 self.timeout = timeout
252 #: current connection
253 self.connection: [None, Connection] = None
256 args = [str(_) for _ in self.args]
257 args = ','.join(args or [])
258 return f'{self.command}({args})'
260 async def __call__(self, command: str, args: list | None):
261 server, port = self.host
262 self.command = command
263 self.args = args or ''
264 self.connection = await self.pool.connect(server, port, timeout=self.timeout)
265 async with self.connection:
266 retval = self._commands[command]
267 await self._write_command(command, args)
269 return await retval()
272 async def _write_line(self, line):
273 self.connection.write(f"{line!s}\n".encode())
274 await self.connection.drain()
276 async def _write_command(self, command, args=None):
281 if isinstance(arg, tuple):
282 parts.append(f'{Range(arg)!s}')
284 parts.append(f'"{escape(str(arg))}"')
285 if '\n' in ' '.join(parts):
286 raise MPDCommandError('new line found in the command!')
287 #log.debug(' '.join(parts))
288 await self._write_line(' '.join(parts))
290 async def _read_binary(self, amount):
293 result = await self.connection.read(amount)
295 await self.connection.close()
296 raise ConnectionError(
297 "Connection lost while reading binary content")
299 amount -= len(result)
302 async def _read_line(self, binary=False):
303 line = await self.connection.readline()
304 line = line.decode('utf-8')
305 if not line.endswith('\n'):
306 await self.connection.close()
307 raise MPDConnectionError("Connection lost while reading line")
308 line = line.rstrip('\n')
309 if line.startswith(ERROR_PREFIX):
310 error = line[len(ERROR_PREFIX):].strip()
311 raise MPDCommandError(error)
312 if self._command_list is not None:
316 raise MPDProtocolError(f"Got unexpected '{SUCCESS}'")
317 elif line == SUCCESS:
321 async def _read_pair(self, separator, binary=False):
322 line = await self._read_line(binary=binary)
325 pair = line.split(separator, 1)
327 raise MPDProtocolError(f"Could not parse pair: '{line}'")
330 async def _read_pairs(self, separator=": ", binary=False):
331 pair = await self._read_pair(separator, binary=binary)
334 pair = await self._read_pair(separator, binary=binary)
336 async def _read_list(self):
338 async for key, value in self._read_pairs():
341 raise MPDProtocolError(
342 f"Expected key '{seen}', got '{key}'")
346 async def _read_playlist(self):
347 async for _, value in self._read_pairs(":"):
350 async def _read_objects(self, delimiters=None):
352 if delimiters is None:
354 async for key, value in self._read_pairs():
357 if key in delimiters:
361 if not isinstance(obj[key], list):
362 obj[key] = [obj[key], value]
364 obj[key].append(value)
370 async def _read_command_list(self):
372 for retval in self._command_list:
375 self._command_list = None
376 await self._fetch_nothing()
378 async def _fetch_nothing(self):
379 line = await self._read_line()
381 raise ProtocolError(f"Got unexpected return value: '{line}'")
383 async def _fetch_item(self):
384 pairs = [_ async for _ in self._read_pairs()]
389 async def _fetch_list(self):
390 return [_ async for _ in self._read_list()]
392 async def _fetch_playlist(self):
393 return [_ async for _ in self._read_pairs(':')]
395 async def _fetch_object(self):
396 objs = [obj async for obj in self._read_objects()]
401 async def _fetch_objects(self, delimiters):
402 return [_ async for _ in self._read_objects(delimiters)]
404 async def _fetch_changes(self):
405 return await self._fetch_objects(["cpos"])
407 async def _fetch_songs(self):
408 return await self._fetch_objects(["file"])
410 async def _fetch_playlists(self):
411 return await self._fetch_objects(["playlist"])
413 async def _fetch_database(self):
414 return await self._fetch_objects(["file", "directory", "playlist"])
416 async def _fetch_outputs(self):
417 return await self._fetch_objects(["outputid"])
419 async def _fetch_plugins(self):
420 return await self._fetch_objects(["plugin"])
422 async def _fetch_messages(self):
423 return await self._fetch_objects(["channel"])
425 async def _fetch_mounts(self):
426 return await self._fetch_objects(["mount"])
428 async def _fetch_neighbors(self):
429 return await self._fetch_objects(["neighbor"])
431 async def _fetch_composite(self):
433 async for key, value in self._read_pairs(binary=True):
439 # If the song file was recognized, but there is no picture, the
440 # response is successful, but is otherwise empty.
442 amount = int(obj['binary'])
444 obj['data'] = await self._read_binary(amount)
445 except IOError as err:
446 raise ConnectionError(
447 f'Error reading binary content: {err}') from err
448 data_bytes = len(obj['data'])
449 if data_bytes != amount: # can we ever get there?
450 raise ConnectionError('Error reading binary content: '
451 f'Expects {amount}B, got {data_bytes}')
452 # Fetches trailing new line
453 await self._read_line(binary=True)
454 #ALT: await self.connection.readuntil(b'\n')
455 # Fetches SUCCESS code
456 await self._read_line(binary=True)
457 #ALT: await self.connection.readuntil(b'OK\n')
460 async def _fetch_command_list(self):
461 return [_ async for _ in self._read_command_list()]