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 from . import CONNECTION_MAX, CONNECTION_TIMEOUT
13 from . import ERROR_PREFIX, SUCCESS, NEXT
15 log = logging.getLogger(__name__)
20 def __init__(self, host: str | None = None, port: str | int | None = None, password: str | None = None):
21 self._pool = ConnectionPool(max_connections=CONNECTION_MAX)
23 #: host used with the current connection (:py:obj:`str`)
24 self.host = host or self.server_discovery[0]
25 #: password detected in :envvar:`MPD_HOST` environment variable (:py:obj:`str`)
26 self.password = password or self.server_discovery[2]
27 #: port used with the current connection (:py:obj:`int`, :py:obj:`str`)
28 self.port = port or self.server_discovery[1]
29 log.info('logger : "%s"', __name__)
31 self.version: [None, str] = None
32 self.mpd_timeout = CONNECTION_TIMEOUT
34 def _get_envvars(self):
36 Retrieve MPD env. var. to overrides default "localhost:6600"
39 disco_host = 'localhost'
40 disco_port = os.getenv('MPD_PORT', '6600')
42 _host = os.getenv('MPD_HOST', '')
44 # If password is set: MPD_HOST=pass@host
46 mpd_host_env = _host.split('@', 1)
48 # A password is actually set
50 'password detected in MPD_HOST, set client pwd attribute')
53 disco_host = mpd_host_env[1]
54 log.debug('host detected in MPD_HOST: %s', disco_host)
56 # No password set but leading @ is an abstract socket
57 disco_host = '@'+mpd_host_env[1]
59 'host detected in MPD_HOST: %s (abstract socket)', disco_host)
61 # MPD_HOST is a plain host
63 log.debug('host detected in MPD_HOST: %s', disco_host)
66 xdg_runtime_dir = os.getenv('XDG_RUNTIME_DIR', '/run')
67 rundir = os.path.join(xdg_runtime_dir, 'mpd/socket')
68 if os.path.exists(rundir):
71 'host detected in ${XDG_RUNTIME_DIR}/run: %s (unix socket)', disco_host)
72 _mpd_timeout = os.getenv('MPD_TIMEOUT', '')
73 if _mpd_timeout.isdigit():
74 self.mpd_timeout = int(_mpd_timeout)
75 log.debug('timeout detected in MPD_TIMEOUT: %d', self.mpd_timeout)
76 else: # Use CONNECTION_TIMEOUT as default even if MPD_TIMEOUT carries gargage
77 self.mpd_timeout = CONNECTION_TIMEOUT
78 self.server_discovery = (disco_host, disco_port, pwd)
80 def __getattr__(self, attr):
82 wrapper = CmdHandler(self._pool, self.host, self.port, self.password, self.mpd_timeout)
83 if command not in wrapper._commands:
84 command = command.replace("_", " ")
85 if command not in wrapper._commands:
87 f"'CmdHandler' object has no attribute '{attr}'")
88 return lambda *args: wrapper(command, args)
90 async def close(self):
91 await self._pool.close()
95 #TODO: CmdHandler to intanciate in place of MPDClient._execute
96 # The MPDClient.__getattr__ wrapper should instanciate an CmdHandler object
98 def __init__(self, pool, server, port, password, timeout):
101 "clearerror": self._fetch_nothing,
102 "currentsong": self._fetch_object,
103 "idle": self._fetch_list,
105 "status": self._fetch_object,
106 "stats": self._fetch_object,
107 # Playback Option Commands
108 "consume": self._fetch_nothing,
109 "crossfade": self._fetch_nothing,
110 "mixrampdb": self._fetch_nothing,
111 "mixrampdelay": self._fetch_nothing,
112 "random": self._fetch_nothing,
113 "repeat": self._fetch_nothing,
114 "setvol": self._fetch_nothing,
115 "getvol": self._fetch_object,
116 "single": self._fetch_nothing,
117 "replay_gain_mode": self._fetch_nothing,
118 "replay_gain_status": self._fetch_item,
119 "volume": self._fetch_nothing,
120 # Playback Control Commands
121 "next": self._fetch_nothing,
122 "pause": self._fetch_nothing,
123 "play": self._fetch_nothing,
124 "playid": self._fetch_nothing,
125 "previous": self._fetch_nothing,
126 "seek": self._fetch_nothing,
127 "seekid": self._fetch_nothing,
128 "seekcur": self._fetch_nothing,
129 "stop": self._fetch_nothing,
131 "add": self._fetch_nothing,
132 "addid": self._fetch_item,
133 "clear": self._fetch_nothing,
134 "delete": self._fetch_nothing,
135 "deleteid": self._fetch_nothing,
136 "move": self._fetch_nothing,
137 "moveid": self._fetch_nothing,
138 "playlist": self._fetch_playlist,
139 "playlistfind": self._fetch_songs,
140 "playlistid": self._fetch_songs,
141 "playlistinfo": self._fetch_songs,
142 "playlistsearch": self._fetch_songs,
143 "plchanges": self._fetch_songs,
144 "plchangesposid": self._fetch_changes,
145 "prio": self._fetch_nothing,
146 "prioid": self._fetch_nothing,
147 "rangeid": self._fetch_nothing,
148 "shuffle": self._fetch_nothing,
149 "swap": self._fetch_nothing,
150 "swapid": self._fetch_nothing,
151 "addtagid": self._fetch_nothing,
152 "cleartagid": self._fetch_nothing,
153 # Stored Playlist Commands
154 "listplaylist": self._fetch_list,
155 "listplaylistinfo": self._fetch_songs,
156 "listplaylists": self._fetch_playlists,
157 "load": self._fetch_nothing,
158 "playlistadd": self._fetch_nothing,
159 "playlistclear": self._fetch_nothing,
160 "playlistdelete": self._fetch_nothing,
161 "playlistmove": self._fetch_nothing,
162 "rename": self._fetch_nothing,
163 "rm": self._fetch_nothing,
164 "save": self._fetch_nothing,
166 "albumart": self._fetch_composite,
167 "count": self._fetch_object,
168 "getfingerprint": self._fetch_object,
169 "find": self._fetch_songs,
170 "findadd": self._fetch_nothing,
171 "list": self._fetch_list,
172 "listall": self._fetch_database,
173 "listallinfo": self._fetch_database,
174 "listfiles": self._fetch_database,
175 "lsinfo": self._fetch_database,
176 "readcomments": self._fetch_object,
177 "readpicture": self._fetch_composite,
178 "search": self._fetch_songs,
179 "searchadd": self._fetch_nothing,
180 "searchaddpl": self._fetch_nothing,
181 "update": self._fetch_item,
182 "rescan": self._fetch_item,
183 # Mounts and neighbors
184 "mount": self._fetch_nothing,
185 "unmount": self._fetch_nothing,
186 "listmounts": self._fetch_mounts,
187 "listneighbors": self._fetch_neighbors,
189 "sticker get": self._fetch_item,
190 "sticker set": self._fetch_nothing,
191 "sticker delete": self._fetch_nothing,
192 "sticker list": self._fetch_list,
193 "sticker find": self._fetch_songs,
194 # Connection Commands
197 "password": self._fetch_nothing,
198 "ping": self._fetch_nothing,
199 "binarylimit": self._fetch_nothing,
200 "tagtypes": self._fetch_list,
201 "tagtypes disable": self._fetch_nothing,
202 "tagtypes enable": self._fetch_nothing,
203 "tagtypes clear": self._fetch_nothing,
204 "tagtypes all": self._fetch_nothing,
206 "partition": self._fetch_nothing,
207 "listpartitions": self._fetch_list,
208 "newpartition": self._fetch_nothing,
209 "delpartition": self._fetch_nothing,
210 "moveoutput": self._fetch_nothing,
211 # Audio Output Commands
212 "disableoutput": self._fetch_nothing,
213 "enableoutput": self._fetch_nothing,
214 "toggleoutput": self._fetch_nothing,
215 "outputs": self._fetch_outputs,
216 "outputset": self._fetch_nothing,
217 # Reflection Commands
218 "config": self._fetch_object,
219 "commands": self._fetch_list,
220 "notcommands": self._fetch_list,
221 "urlhandlers": self._fetch_list,
222 "decoders": self._fetch_plugins,
224 "subscribe": self._fetch_nothing,
225 "unsubscribe": self._fetch_nothing,
226 "channels": self._fetch_list,
227 "readmessages": self._fetch_messages,
228 "sendmessage": self._fetch_nothing,
231 self._command_list = None
234 self.host = (server, port)
235 self.password = password
236 self.timeout = timeout
237 #: current connection
238 self.connection: [None, Connection] = None
241 args = [str(_) for _ in self.args]
242 args = ','.join(args or [])
243 return f'{self.command}({args})'
245 async def __call__(self, command: str, args: list | None):
246 server, port = self.host
247 self.command = command
248 self.args = args or ''
249 self.connection = await self.pool.connect(server, port, timeout=self.timeout)
250 async with self.connection:
251 retval = self._commands[command]
252 await self._write_command(command, args)
254 return await retval()
257 async def _write_line(self, line):
258 self.connection.write(f"{line!s}\n".encode())
259 await self.connection.drain()
261 async def _write_command(self, command, args=None):
266 if isinstance(arg, tuple):
267 parts.append(f'{Range(arg)!s}')
269 parts.append(f'"{escape(str(arg))}"')
270 if '\n' in ' '.join(parts):
271 raise MPDCommandError('new line found in the command!')
272 log.debug(' '.join(parts))
273 await self._write_line(' '.join(parts))
275 def _read_binary(self, amount):
278 result = self._rbfile.read(amount)
281 raise ConnectionError(
282 "Connection lost while reading binary content")
284 amount -= len(result)
287 async def _read_line(self, binary=False):
289 line = self._rbfile.readline().decode('utf-8')
291 line = await self.connection.readline()
292 line = line.decode('utf-8')
293 if not line.endswith('\n'):
295 raise MPDConnectionError("Connection lost while reading line")
296 line = line.rstrip('\n')
297 if line.startswith(ERROR_PREFIX):
298 error = line[len(ERROR_PREFIX):].strip()
299 raise MPDCommandError(error)
300 if self._command_list is not None:
304 raise MPDProtocolError(f"Got unexpected '{SUCCESS}'")
305 elif line == SUCCESS:
309 async def _read_pair(self, separator, binary=False):
310 line = await self._read_line(binary=binary)
313 pair = line.split(separator, 1)
315 raise MPDProtocolError(f"Could not parse pair: '{line}'")
318 async def _read_pairs(self, separator=": ", binary=False):
320 pair = await self._read_pair(separator, binary=binary)
323 pair = await self._read_pair(separator, binary=binary)
325 async def _read_list(self):
327 async for key, value in self._read_pairs():
330 raise MPDProtocolError(
331 f"Expected key '{seen}', got '{key}'")
335 async def _read_playlist(self):
336 async for _, value in self._read_pairs(":"):
339 async def _read_objects(self, delimiters=None):
341 if delimiters is None:
343 async for key, value in self._read_pairs():
346 if key in delimiters:
350 if not isinstance(obj[key], list):
351 obj[key] = [obj[key], value]
353 obj[key].append(value)
359 async def _read_command_list(self):
361 for retval in self._command_list:
364 self._command_list = None
365 await self._fetch_nothing()
367 async def _fetch_nothing(self):
368 line = await self._read_line()
370 raise ProtocolError(f"Got unexpected return value: '{line}'")
372 async def _fetch_item(self):
373 pairs = [_ async for _ in self._read_pairs()]
378 async def _fetch_list(self):
379 return [_ async for _ in self._read_list()]
381 async def _fetch_playlist(self):
382 return [_ async for _ in self._read_pairs(':')]
384 async def _fetch_object(self):
385 objs = [obj async for obj in self._read_objects()]
390 async def _fetch_objects(self, delimiters):
391 return [_ async for _ in self._read_objects(delimiters)]
393 async def _fetch_changes(self):
394 return await self._fetch_objects(["cpos"])
396 async def _fetch_songs(self):
397 return await self._fetch_objects(["file"])
399 async def _fetch_playlists(self):
400 return await self._fetch_objects(["playlist"])
402 async def _fetch_database(self):
403 return await self._fetch_objects(["file", "directory", "playlist"])
405 async def _fetch_outputs(self):
406 return await self._fetch_objects(["outputid"])
408 async def _fetch_plugins(self):
409 return await self._fetch_objects(["plugin"])
411 async def _fetch_messages(self):
412 return await self._fetch_objects(["channel"])
414 async def _fetch_mounts(self):
415 return await self._fetch_objects(["mount"])
417 async def _fetch_neighbors(self):
418 return await self._fetch_objects(["neighbor"])
420 async def _fetch_composite(self):
422 for key, value in self._read_pairs(binary=True):
428 # If the song file was recognized, but there is no picture, the
429 # response is successful, but is otherwise empty.
431 amount = int(obj['binary'])
433 obj['data'] = self._read_binary(amount)
434 except IOError as err:
435 raise ConnectionError(
436 f'Error reading binary content: {err}') from err
437 data_bytes = len(obj['data'])
438 if data_bytes != amount: # can we ever get there?
439 raise ConnectionError('Error reading binary content: '
440 f'Expects {amount}B, got {data_bytes}')
441 # Fetches trailing new line
442 await self._read_line(binary=True)
443 # Fetches SUCCESS code
444 await self._read_line(binary=True)
447 async def _fetch_command_list(self):
448 return [_ async for _ in self._read_command_list()]