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):
23 "clearerror": self._fetch_nothing,
24 "currentsong": self._fetch_object,
25 "idle": self._fetch_list,
27 "status": self._fetch_object,
28 "stats": self._fetch_object,
29 # Playback Option Commands
30 "consume": self._fetch_nothing,
31 "crossfade": self._fetch_nothing,
32 "mixrampdb": self._fetch_nothing,
33 "mixrampdelay": self._fetch_nothing,
34 "random": self._fetch_nothing,
35 "repeat": self._fetch_nothing,
36 "setvol": self._fetch_nothing,
37 "getvol": self._fetch_object,
38 "single": self._fetch_nothing,
39 "replay_gain_mode": self._fetch_nothing,
40 "replay_gain_status": self._fetch_item,
41 "volume": self._fetch_nothing,
42 # Playback Control Commands
43 "next": self._fetch_nothing,
44 "pause": self._fetch_nothing,
45 "play": self._fetch_nothing,
46 "playid": self._fetch_nothing,
47 "previous": self._fetch_nothing,
48 "seek": self._fetch_nothing,
49 "seekid": self._fetch_nothing,
50 "seekcur": self._fetch_nothing,
51 "stop": self._fetch_nothing,
53 "add": self._fetch_nothing,
54 "addid": self._fetch_item,
55 "clear": self._fetch_nothing,
56 "delete": self._fetch_nothing,
57 "deleteid": self._fetch_nothing,
58 "move": self._fetch_nothing,
59 "moveid": self._fetch_nothing,
60 "playlist": self._fetch_playlist,
61 "playlistfind": self._fetch_songs,
62 "playlistid": self._fetch_songs,
63 "playlistinfo": self._fetch_songs,
64 "playlistsearch": self._fetch_songs,
65 "plchanges": self._fetch_songs,
66 "plchangesposid": self._fetch_changes,
67 "prio": self._fetch_nothing,
68 "prioid": self._fetch_nothing,
69 "rangeid": self._fetch_nothing,
70 "shuffle": self._fetch_nothing,
71 "swap": self._fetch_nothing,
72 "swapid": self._fetch_nothing,
73 "addtagid": self._fetch_nothing,
74 "cleartagid": self._fetch_nothing,
75 # Stored Playlist Commands
76 "listplaylist": self._fetch_list,
77 "listplaylistinfo": self._fetch_songs,
78 "listplaylists": self._fetch_playlists,
79 "load": self._fetch_nothing,
80 "playlistadd": self._fetch_nothing,
81 "playlistclear": self._fetch_nothing,
82 "playlistdelete": self._fetch_nothing,
83 "playlistmove": self._fetch_nothing,
84 "rename": self._fetch_nothing,
85 "rm": self._fetch_nothing,
86 "save": self._fetch_nothing,
88 "albumart": self._fetch_composite,
89 "count": self._fetch_object,
90 "getfingerprint": self._fetch_object,
91 "find": self._fetch_songs,
92 "findadd": self._fetch_nothing,
93 "list": self._fetch_list,
94 "listall": self._fetch_database,
95 "listallinfo": self._fetch_database,
96 "listfiles": self._fetch_database,
97 "lsinfo": self._fetch_database,
98 "readcomments": self._fetch_object,
99 "readpicture": self._fetch_composite,
100 "search": self._fetch_songs,
101 "searchadd": self._fetch_nothing,
102 "searchaddpl": self._fetch_nothing,
103 "update": self._fetch_item,
104 "rescan": self._fetch_item,
105 # Mounts and neighbors
106 "mount": self._fetch_nothing,
107 "unmount": self._fetch_nothing,
108 "listmounts": self._fetch_mounts,
109 "listneighbors": self._fetch_neighbors,
111 "sticker get": self._fetch_item,
112 "sticker set": self._fetch_nothing,
113 "sticker delete": self._fetch_nothing,
114 "sticker list": self._fetch_list,
115 "sticker find": self._fetch_songs,
116 # Connection Commands
119 "password": self._fetch_nothing,
120 "ping": self._fetch_nothing,
121 "binarylimit": self._fetch_nothing,
122 "tagtypes": self._fetch_list,
123 "tagtypes disable": self._fetch_nothing,
124 "tagtypes enable": self._fetch_nothing,
125 "tagtypes clear": self._fetch_nothing,
126 "tagtypes all": self._fetch_nothing,
128 "partition": self._fetch_nothing,
129 "listpartitions": self._fetch_list,
130 "newpartition": self._fetch_nothing,
131 "delpartition": self._fetch_nothing,
132 "moveoutput": self._fetch_nothing,
133 # Audio Output Commands
134 "disableoutput": self._fetch_nothing,
135 "enableoutput": self._fetch_nothing,
136 "toggleoutput": self._fetch_nothing,
137 "outputs": self._fetch_outputs,
138 "outputset": self._fetch_nothing,
139 # Reflection Commands
140 "config": self._fetch_object,
141 "commands": self._fetch_list,
142 "notcommands": self._fetch_list,
143 "urlhandlers": self._fetch_list,
144 "decoders": self._fetch_plugins,
146 "subscribe": self._fetch_nothing,
147 "unsubscribe": self._fetch_nothing,
148 "channels": self._fetch_list,
149 "readmessages": self._fetch_messages,
150 "sendmessage": self._fetch_nothing,
153 #: host used with the current connection (:py:obj:`str`)
154 self.host = host or self.server_discovery[0]
155 #: password detected in :envvar:`MPD_HOST` environment variable (:py:obj:`str`)
156 self.password = password or self.server_discovery[2]
157 #: port used with the current connection (:py:obj:`int`, :py:obj:`str`)
158 self.port = port or self.server_discovery[1]
159 # self._get_envvars()
160 self._pool = ConnectionPool(max_connections=CONNECTION_MAX)
161 log.info('logger : "%s"', __name__)
162 #: current connection
163 self.connection: [None, Connection] = None
165 self.version: [None, str] = None
166 self._command_list = None
167 self.mpd_timeout = CONNECTION_TIMEOUT
169 def _get_envvars(self):
171 Retrieve MPD env. var. to overrides default "localhost:6600"
174 disco_host = 'localhost'
175 disco_port = os.getenv('MPD_PORT', '6600')
177 _host = os.getenv('MPD_HOST', '')
179 # If password is set: MPD_HOST=pass@host
181 mpd_host_env = _host.split('@', 1)
183 # A password is actually set
185 'password detected in MPD_HOST, set client pwd attribute')
186 pwd = mpd_host_env[0]
188 disco_host = mpd_host_env[1]
189 log.debug('host detected in MPD_HOST: %s', disco_host)
190 elif mpd_host_env[1]:
191 # No password set but leading @ is an abstract socket
192 disco_host = '@'+mpd_host_env[1]
194 'host detected in MPD_HOST: %s (abstract socket)', disco_host)
196 # MPD_HOST is a plain host
198 log.debug('host detected in MPD_HOST: %s', disco_host)
201 xdg_runtime_dir = os.getenv('XDG_RUNTIME_DIR', '/run')
202 rundir = os.path.join(xdg_runtime_dir, 'mpd/socket')
203 if os.path.exists(rundir):
206 'host detected in ${XDG_RUNTIME_DIR}/run: %s (unix socket)', disco_host)
207 _mpd_timeout = os.getenv('MPD_TIMEOUT', '')
208 if _mpd_timeout.isdigit():
209 self.mpd_timeout = int(_mpd_timeout)
210 log.debug('timeout detected in MPD_TIMEOUT: %d', self.mpd_timeout)
211 else: # Use CONNECTION_TIMEOUT as default even if MPD_TIMEOUT carries gargage
212 self.mpd_timeout = CONNECTION_TIMEOUT
213 self.server_discovery = (disco_host, disco_port, pwd)
215 def __getattr__(self, attr):
216 # if attr == 'send_noidle': # have send_noidle to cancel idle as well as noidle
218 if attr.startswith("send_"):
219 command = attr.replace("send_", "", 1)
221 elif attr.startswith("fetch_"):
222 command = attr.replace("fetch_", "", 1)
223 wrapper = self._fetch
226 wrapper = self._execute
227 if command not in self._commands:
228 command = command.replace("_", " ")
229 if command not in self._commands:
230 cls = self.__class__.__name__
231 raise AttributeError(
232 f"'{cls}' object has no attribute '{attr}'")
233 return lambda *args: wrapper(command, args)
235 async def _execute(self, command, args): # pylint: disable=unused-argument
236 log.debug(f'#{command}')
237 # self.connection = await self._pool.connect(self.host, self.port, timeout=self.mpd_timeout)
238 # await self._get_connection()
239 async with await self._get_connection():
241 # raise MPDCommandError(
242 # f"Cannot execute '{command}' with pending commands")
243 retval = self._commands[command]
244 if self._command_list is not None:
245 if not callable(retval):
246 raise MPDCommandError(
247 f"'{command}' not allowed in command list")
248 self._write_command(command, args)
249 self._command_list.append(retval)
251 await self._write_command(command, args)
253 # log.debug('retvat: %s', retval)
254 return await retval()
258 async def _write_line(self, line):
259 self.connection.write(f"{line!s}\n".encode())
260 await self.connection.drain()
262 async def _write_command(self, command, args=None):
267 if isinstance(arg, tuple):
268 parts.append(f'{Range(arg)!s}')
270 parts.append(f'"{escape(str(arg))}"')
271 if '\n' in ' '.join(parts):
272 raise MPDCommandError('new line found in the command!')
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 for _, value in await 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 def _read_command_list(self):
361 for retval in self._command_list:
364 self._command_list = None
365 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 def _fetch_playlist(self):
382 return self._read_playlist()
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 def _fetch_changes(self):
394 return self._fetch_objects(["cpos"])
396 async def _fetch_songs(self):
397 return await self._fetch_objects(["file"])
399 def _fetch_playlists(self):
400 return self._fetch_objects(["playlist"])
402 def _fetch_database(self):
403 return self._fetch_objects(["file", "directory", "playlist"])
405 def _fetch_outputs(self):
406 return self._fetch_objects(["outputid"])
408 def _fetch_plugins(self):
409 return self._fetch_objects(["plugin"])
411 def _fetch_messages(self):
412 return self._fetch_objects(["channel"])
414 def _fetch_mounts(self):
415 return self._fetch_objects(["mount"])
417 def _fetch_neighbors(self):
418 return 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 def _fetch_command_list(self):
448 return self._read_command_list()
450 async def _get_connection(self) -> Connection:
451 self.connection = await self._pool.connect(self.host, self.port, timeout=self.mpd_timeout)
452 return self.connection
454 async def close(self):
455 await self._pool.close()
459 #TODO: CmdHandler to intanciate in place of MPDClient._execute
460 # The MPDClient.__getattr__ wrapper should instanciate an CmdHandler object