]> kaliko git repositories - python-musicpdaio.git/blob - mpdaio/client.py
Some improvement on ConnectionPool
[python-musicpdaio.git] / mpdaio / client.py
1 # -*- coding: utf-8 -*-
2 # SPDX-FileCopyrightText: 2012-2024  kaliko <kaliko@azylum.org>
3 # SPDX-License-Identifier: LGPL-3.0-or-later
4
5 import logging
6 import os
7
8 from .connection import ConnectionPool, Connection
9 from .exceptions import MPDConnectionError, MPDProtocolError, MPDCommandError
10 from .utils import Range, escape
11
12 from . import CONNECTION_MAX, CONNECTION_TIMEOUT
13 from . import ERROR_PREFIX, SUCCESS, NEXT
14
15 log = logging.getLogger(__name__)
16
17
18 class MPDClient:
19
20     def __init__(self, host: str | None = None, port: str | int | None = None, password: str | None = None):
21         self._commands = {
22             # Status Commands
23             "clearerror":         self._fetch_nothing,
24             "currentsong":        self._fetch_object,
25             "idle":               self._fetch_list,
26             # "noidle":             None,
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,
52             # Queue Commands
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,
87             # Database Commands
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,
110             # Sticker Commands
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
117             "close":              None,
118             "kill":               None,
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,
127             # Partition Commands
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,
145             # Client to Client
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,
151         }
152         self._get_envvars()
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
164         #: Protocol version
165         self.version: [None, str] = None
166         self._command_list = None
167         self.mpd_timeout = CONNECTION_TIMEOUT
168
169     def _get_envvars(self):
170         """
171         Retrieve MPD env. var. to overrides default "localhost:6600"
172         """
173         # Set some defaults
174         disco_host = 'localhost'
175         disco_port = os.getenv('MPD_PORT', '6600')
176         pwd = None
177         _host = os.getenv('MPD_HOST', '')
178         if _host:
179             # If password is set: MPD_HOST=pass@host
180             if '@' in _host:
181                 mpd_host_env = _host.split('@', 1)
182                 if mpd_host_env[0]:
183                     # A password is actually set
184                     log.debug(
185                         'password detected in MPD_HOST, set client pwd attribute')
186                     pwd = mpd_host_env[0]
187                     if mpd_host_env[1]:
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]
193                     log.debug(
194                         'host detected in MPD_HOST: %s (abstract socket)', disco_host)
195             else:
196                 # MPD_HOST is a plain host
197                 disco_host = _host
198                 log.debug('host detected in MPD_HOST: %s', disco_host)
199         else:
200             # Is socket there
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):
204                 disco_host = rundir
205                 log.debug(
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)
214
215     def __getattr__(self, attr):
216         # if attr == 'send_noidle':  # have send_noidle to cancel idle as well as noidle
217         #     return self.noidle
218         if attr.startswith("send_"):
219             command = attr.replace("send_", "", 1)
220             wrapper = self._send
221         elif attr.startswith("fetch_"):
222             command = attr.replace("fetch_", "", 1)
223             wrapper = self._fetch
224         else:
225             command = attr
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)
234
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():
240             # if self._pending:
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)
250             else:
251                 await self._write_command(command, args)
252                 if callable(retval):
253                     # log.debug('retvat: %s', retval)
254                     return await retval()
255                 return retval
256             return None
257
258     async def _write_line(self, line):
259         self.connection.write(f"{line!s}\n".encode())
260         await self.connection.drain()
261
262     async def _write_command(self, command, args=None):
263         if args is None:
264             args = []
265         parts = [command]
266         for arg in args:
267             if isinstance(arg, tuple):
268                 parts.append(f'{Range(arg)!s}')
269             else:
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))
274
275     def _read_binary(self, amount):
276         chunk = bytearray()
277         while amount > 0:
278             result = self._rbfile.read(amount)
279             if len(result) == 0:
280                 self.disconnect()
281                 raise ConnectionError(
282                     "Connection lost while reading binary content")
283             chunk.extend(result)
284             amount -= len(result)
285         return bytes(chunk)
286
287     async def _read_line(self, binary=False):
288         if binary:
289             line = self._rbfile.readline().decode('utf-8')
290         else:
291             line = await self.connection.readline()
292         line = line.decode('utf-8')
293         if not line.endswith('\n'):
294             await self.close()
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:
301             if line == NEXT:
302                 return None
303             if line == SUCCESS:
304                 raise MPDProtocolError(f"Got unexpected '{SUCCESS}'")
305         elif line == SUCCESS:
306             return None
307         return line
308
309     async def _read_pair(self, separator, binary=False):
310         line = await self._read_line(binary=binary)
311         if line is None:
312             return None
313         pair = line.split(separator, 1)
314         if len(pair) < 2:
315             raise MPDProtocolError(f"Could not parse pair: '{line}'")
316         return pair
317
318     async def _read_pairs(self, separator=": ", binary=False):
319         """OK"""
320         pair = await self._read_pair(separator, binary=binary)
321         while pair:
322             yield pair
323             pair = await self._read_pair(separator, binary=binary)
324
325     async def _read_list(self):
326         seen = None
327         async for key, value in self._read_pairs():
328             if key != seen:
329                 if seen is not None:
330                     raise MPDProtocolError(
331                         f"Expected key '{seen}', got '{key}'")
332                 seen = key
333             yield value
334
335     async def _read_playlist(self):
336         for _, value in await self._read_pairs(":"):
337             yield value
338
339     async def _read_objects(self, delimiters=None):
340         obj = {}
341         if delimiters is None:
342             delimiters = []
343         async for key, value in self._read_pairs():
344             key = key.lower()
345             if obj:
346                 if key in delimiters:
347                     yield obj
348                     obj = {}
349                 elif key in obj:
350                     if not isinstance(obj[key], list):
351                         obj[key] = [obj[key], value]
352                     else:
353                         obj[key].append(value)
354                     continue
355             obj[key] = value
356         if obj:
357             yield obj
358
359     def _read_command_list(self):
360         try:
361             for retval in self._command_list:
362                 yield retval()
363         finally:
364             self._command_list = None
365         self._fetch_nothing()
366
367     async def _fetch_nothing(self):
368         line = await self._read_line()
369         if line is not None:
370             raise ProtocolError(f"Got unexpected return value: '{line}'")
371
372     async def _fetch_item(self):
373         pairs = [_ async for _ in self._read_pairs()]
374         if len(pairs) != 1:
375             return None
376         return pairs[0][1]
377
378     async def _fetch_list(self):
379         return [_ async for _ in self._read_list()]
380
381     def _fetch_playlist(self):
382         return self._read_playlist()
383
384     async def _fetch_object(self):
385         objs = [obj async for obj in self._read_objects()]
386         if not objs:
387             return {}
388         return objs[0]
389
390     async def _fetch_objects(self, delimiters):
391         return [_ async for _ in self._read_objects(delimiters)]
392
393     def _fetch_changes(self):
394         return self._fetch_objects(["cpos"])
395
396     async def _fetch_songs(self):
397         return await self._fetch_objects(["file"])
398
399     def _fetch_playlists(self):
400         return self._fetch_objects(["playlist"])
401
402     def _fetch_database(self):
403         return self._fetch_objects(["file", "directory", "playlist"])
404
405     def _fetch_outputs(self):
406         return self._fetch_objects(["outputid"])
407
408     def _fetch_plugins(self):
409         return self._fetch_objects(["plugin"])
410
411     def _fetch_messages(self):
412         return self._fetch_objects(["channel"])
413
414     def _fetch_mounts(self):
415         return self._fetch_objects(["mount"])
416
417     def _fetch_neighbors(self):
418         return self._fetch_objects(["neighbor"])
419
420     async def _fetch_composite(self):
421         obj = {}
422         for key, value in self._read_pairs(binary=True):
423             key = key.lower()
424             obj[key] = value
425             if key == 'binary':
426                 break
427         if not obj:
428             # If the song file was recognized, but there is no picture, the
429             # response is successful, but is otherwise empty.
430             return obj
431         amount = int(obj['binary'])
432         try:
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)
445         return obj
446
447     def _fetch_command_list(self):
448         return self._read_command_list()
449
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
453
454     async def close(self):
455         await self._pool.close()
456
457
458 class CmdHandler:
459     #TODO: CmdHandler to intanciate in place of MPDClient._execute
460     # The MPDClient.__getattr__ wrapper should instanciate an CmdHandler object
461
462     def __init__(self):
463         pass