]> kaliko git repositories - python-musicpdaio.git/blob - mpdaio/client.py
Swith more MPD command to asyncio
[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._pool = ConnectionPool(max_connections=CONNECTION_MAX)
22         self._get_envvars()
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__)
30         #: Protocol version
31         self.version: [None, str] = None
32         self.mpd_timeout = CONNECTION_TIMEOUT
33
34     def _get_envvars(self):
35         """
36         Retrieve MPD env. var. to overrides default "localhost:6600"
37         """
38         # Set some defaults
39         disco_host = 'localhost'
40         disco_port = os.getenv('MPD_PORT', '6600')
41         pwd = None
42         _host = os.getenv('MPD_HOST', '')
43         if _host:
44             # If password is set: MPD_HOST=pass@host
45             if '@' in _host:
46                 mpd_host_env = _host.split('@', 1)
47                 if mpd_host_env[0]:
48                     # A password is actually set
49                     log.debug(
50                         'password detected in MPD_HOST, set client pwd attribute')
51                     pwd = mpd_host_env[0]
52                     if mpd_host_env[1]:
53                         disco_host = mpd_host_env[1]
54                         log.debug('host detected in MPD_HOST: %s', disco_host)
55                 elif mpd_host_env[1]:
56                     # No password set but leading @ is an abstract socket
57                     disco_host = '@'+mpd_host_env[1]
58                     log.debug(
59                         'host detected in MPD_HOST: %s (abstract socket)', disco_host)
60             else:
61                 # MPD_HOST is a plain host
62                 disco_host = _host
63                 log.debug('host detected in MPD_HOST: %s', disco_host)
64         else:
65             # Is socket there
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):
69                 disco_host = rundir
70                 log.debug(
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)
79
80     def __getattr__(self, attr):
81         command = 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:
86                 raise AttributeError(
87                     f"'CmdHandler' object has no attribute '{attr}'")
88         return lambda *args: wrapper(command, args)
89
90     async def close(self):
91         await self._pool.close()
92
93
94 class CmdHandler:
95     #TODO: CmdHandler to intanciate in place of MPDClient._execute
96     # The MPDClient.__getattr__ wrapper should instanciate an CmdHandler object
97
98     def __init__(self, pool, server, port, password, timeout):
99         self._commands = {
100             # Status Commands
101             "clearerror":         self._fetch_nothing,
102             "currentsong":        self._fetch_object,
103             "idle":               self._fetch_list,
104             # "noidle":             None,
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,
130             # Queue Commands
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,
165             # Database Commands
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,
188             # Sticker Commands
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
195             "close":              None,
196             "kill":               None,
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,
205             # Partition Commands
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,
223             # Client to Client
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,
229         }
230         self.command = None
231         self._command_list = None
232         self.args = None
233         self.pool = pool
234         self.host = (server, port)
235         self.password = password
236         self.timeout = timeout
237         #: current connection
238         self.connection: [None, Connection] = None
239
240     def __repr__(self):
241         args = [str(_) for _ in self.args]
242         args = ','.join(args or [])
243         return f'{self.command}({args})'
244
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)
253             if callable(retval):
254                 return await retval()
255             return retval
256
257     async def _write_line(self, line):
258         self.connection.write(f"{line!s}\n".encode())
259         await self.connection.drain()
260
261     async def _write_command(self, command, args=None):
262         if args is None:
263             args = []
264         parts = [command]
265         for arg in args:
266             if isinstance(arg, tuple):
267                 parts.append(f'{Range(arg)!s}')
268             else:
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))
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         async for _, value in 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     async 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         await 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     async def _fetch_playlist(self):
382         return [_ async for _ in self._read_pairs(':')]
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     async def _fetch_changes(self):
394         return await self._fetch_objects(["cpos"])
395
396     async def _fetch_songs(self):
397         return await self._fetch_objects(["file"])
398
399     async def _fetch_playlists(self):
400         return await self._fetch_objects(["playlist"])
401
402     async def _fetch_database(self):
403         return await self._fetch_objects(["file", "directory", "playlist"])
404
405     async def _fetch_outputs(self):
406         return await self._fetch_objects(["outputid"])
407
408     async def _fetch_plugins(self):
409         return await self._fetch_objects(["plugin"])
410
411     async def _fetch_messages(self):
412         return await self._fetch_objects(["channel"])
413
414     async def _fetch_mounts(self):
415         return await self._fetch_objects(["mount"])
416
417     async def _fetch_neighbors(self):
418         return await 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     async def _fetch_command_list(self):
448         return [_ async for _ in self._read_command_list()]