]> kaliko git repositories - python-musicpdaio.git/blob - mpdaio/client.py
Add per connection command wrapper
[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                 log.debug('retvat: %s', retval)
255                 return await retval()
256             return retval
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         #log.debug(' '.join(parts))
274         await self._write_line(' '.join(parts))
275
276     def _read_binary(self, amount):
277         chunk = bytearray()
278         while amount > 0:
279             result = self._rbfile.read(amount)
280             if len(result) == 0:
281                 self.disconnect()
282                 raise ConnectionError(
283                     "Connection lost while reading binary content")
284             chunk.extend(result)
285             amount -= len(result)
286         return bytes(chunk)
287
288     async def _read_line(self, binary=False):
289         if binary:
290             line = self._rbfile.readline().decode('utf-8')
291         else:
292             line = await self.connection.readline()
293         line = line.decode('utf-8')
294         if not line.endswith('\n'):
295             await self.close()
296             raise MPDConnectionError("Connection lost while reading line")
297         line = line.rstrip('\n')
298         if line.startswith(ERROR_PREFIX):
299             error = line[len(ERROR_PREFIX):].strip()
300             raise MPDCommandError(error)
301         if self._command_list is not None:
302             if line == NEXT:
303                 return None
304             if line == SUCCESS:
305                 raise MPDProtocolError(f"Got unexpected '{SUCCESS}'")
306         elif line == SUCCESS:
307             return None
308         return line
309
310     async def _read_pair(self, separator, binary=False):
311         line = await self._read_line(binary=binary)
312         if line is None:
313             return None
314         pair = line.split(separator, 1)
315         if len(pair) < 2:
316             raise MPDProtocolError(f"Could not parse pair: '{line}'")
317         return pair
318
319     async def _read_pairs(self, separator=": ", binary=False):
320         """OK"""
321         pair = await self._read_pair(separator, binary=binary)
322         while pair:
323             yield pair
324             pair = await self._read_pair(separator, binary=binary)
325
326     async def _read_list(self):
327         seen = None
328         async for key, value in self._read_pairs():
329             if key != seen:
330                 if seen is not None:
331                     raise MPDProtocolError(
332                         f"Expected key '{seen}', got '{key}'")
333                 seen = key
334             yield value
335
336     async def _read_playlist(self):
337         for _, value in await self._read_pairs(":"):
338             yield value
339
340     async def _read_objects(self, delimiters=None):
341         obj = {}
342         if delimiters is None:
343             delimiters = []
344         async for key, value in self._read_pairs():
345             key = key.lower()
346             if obj:
347                 if key in delimiters:
348                     yield obj
349                     obj = {}
350                 elif key in obj:
351                     if not isinstance(obj[key], list):
352                         obj[key] = [obj[key], value]
353                     else:
354                         obj[key].append(value)
355                     continue
356             obj[key] = value
357         if obj:
358             yield obj
359
360     def _read_command_list(self):
361         try:
362             for retval in self._command_list:
363                 yield retval()
364         finally:
365             self._command_list = None
366         self._fetch_nothing()
367
368     async def _fetch_nothing(self):
369         line = await self._read_line()
370         if line is not None:
371             raise ProtocolError(f"Got unexpected return value: '{line}'")
372
373     async def _fetch_item(self):
374         pairs = [_ async for _ in self._read_pairs()]
375         if len(pairs) != 1:
376             return None
377         return pairs[0][1]
378
379     async def _fetch_list(self):
380         return [_ async for _ in self._read_list()]
381
382     def _fetch_playlist(self):
383         return self._read_playlist()
384
385     async def _fetch_object(self):
386         objs = [obj async for obj in self._read_objects()]
387         if not objs:
388             return {}
389         return objs[0]
390
391     async def _fetch_objects(self, delimiters):
392         return [_ async for _ in self._read_objects(delimiters)]
393
394     def _fetch_changes(self):
395         return self._fetch_objects(["cpos"])
396
397     async def _fetch_songs(self):
398         return await self._fetch_objects(["file"])
399
400     def _fetch_playlists(self):
401         return self._fetch_objects(["playlist"])
402
403     def _fetch_database(self):
404         return self._fetch_objects(["file", "directory", "playlist"])
405
406     def _fetch_outputs(self):
407         return self._fetch_objects(["outputid"])
408
409     def _fetch_plugins(self):
410         return self._fetch_objects(["plugin"])
411
412     def _fetch_messages(self):
413         return self._fetch_objects(["channel"])
414
415     def _fetch_mounts(self):
416         return self._fetch_objects(["mount"])
417
418     def _fetch_neighbors(self):
419         return self._fetch_objects(["neighbor"])
420
421     async def _fetch_composite(self):
422         obj = {}
423         for key, value in self._read_pairs(binary=True):
424             key = key.lower()
425             obj[key] = value
426             if key == 'binary':
427                 break
428         if not obj:
429             # If the song file was recognized, but there is no picture, the
430             # response is successful, but is otherwise empty.
431             return obj
432         amount = int(obj['binary'])
433         try:
434             obj['data'] = self._read_binary(amount)
435         except IOError as err:
436             raise ConnectionError(
437                 f'Error reading binary content: {err}') from err
438         data_bytes = len(obj['data'])
439         if data_bytes != amount:  # can we ever get there?
440             raise ConnectionError('Error reading binary content: '
441                                   f'Expects {amount}B, got {data_bytes}')
442         # Fetches trailing new line
443         await self._read_line(binary=True)
444         # Fetches SUCCESS code
445         await self._read_line(binary=True)
446         return obj
447
448     def _fetch_command_list(self):
449         return self._read_command_list()