]> kaliko git repositories - python-musicpdaio.git/blob - mpdaio/client.py
Partially implement protocol (AsyncIO POC)
[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 HELLO_PREFIX = 'OK MPD '
13 ERROR_PREFIX = 'ACK '
14 SUCCESS = 'OK'
15 NEXT = 'list_OK'
16 #: Module version
17 VERSION = '0.9.0b2'
18 #: Seconds before a connection attempt times out
19 #: (overriden by :envvar:`MPD_TIMEOUT` env. var.)
20 CONNECTION_TIMEOUT = 30
21 #: Socket timeout in second > 0 (Default is :py:obj:`None` for no timeout)
22 SOCKET_TIMEOUT = None
23 #: Maximum concurrent connections
24 CONNECTION_MAX = 10
25
26 logging.basicConfig(level=logging.DEBUG,
27                     format='%(levelname)-8s %(module)-10s %(message)s')
28 log = logging.getLogger(__name__)
29
30
31 class MPDClient:
32
33     def __init__(self,):
34         self._commands = {
35             # Status Commands
36             "clearerror":         self._fetch_nothing,
37             "currentsong":        self._fetch_object,
38             "idle":               self._fetch_list,
39             # "noidle":             None,
40             "status":             self._fetch_object,
41             "stats":              self._fetch_object,
42             # Playback Option Commands
43             "consume":            self._fetch_nothing,
44             "crossfade":          self._fetch_nothing,
45             "mixrampdb":          self._fetch_nothing,
46             "mixrampdelay":       self._fetch_nothing,
47             "random":             self._fetch_nothing,
48             "repeat":             self._fetch_nothing,
49             "setvol":             self._fetch_nothing,
50             "getvol":             self._fetch_object,
51             "single":             self._fetch_nothing,
52             "replay_gain_mode":   self._fetch_nothing,
53             "replay_gain_status": self._fetch_item,
54             "volume":             self._fetch_nothing,
55             # Playback Control Commands
56             "next":               self._fetch_nothing,
57             "pause":              self._fetch_nothing,
58             "play":               self._fetch_nothing,
59             "playid":             self._fetch_nothing,
60             "previous":           self._fetch_nothing,
61             "seek":               self._fetch_nothing,
62             "seekid":             self._fetch_nothing,
63             "seekcur":            self._fetch_nothing,
64             "stop":               self._fetch_nothing,
65             # Queue Commands
66             "add":                self._fetch_nothing,
67             "addid":              self._fetch_item,
68             "clear":              self._fetch_nothing,
69             "delete":             self._fetch_nothing,
70             "deleteid":           self._fetch_nothing,
71             "move":               self._fetch_nothing,
72             "moveid":             self._fetch_nothing,
73             "playlist":           self._fetch_playlist,
74             "playlistfind":       self._fetch_songs,
75             "playlistid":         self._fetch_songs,
76             "playlistinfo":       self._fetch_songs,
77             "playlistsearch":     self._fetch_songs,
78             "plchanges":          self._fetch_songs,
79             "plchangesposid":     self._fetch_changes,
80             "prio":               self._fetch_nothing,
81             "prioid":             self._fetch_nothing,
82             "rangeid":            self._fetch_nothing,
83             "shuffle":            self._fetch_nothing,
84             "swap":               self._fetch_nothing,
85             "swapid":             self._fetch_nothing,
86             "addtagid":           self._fetch_nothing,
87             "cleartagid":         self._fetch_nothing,
88             # Stored Playlist Commands
89             "listplaylist":       self._fetch_list,
90             "listplaylistinfo":   self._fetch_songs,
91             "listplaylists":      self._fetch_playlists,
92             "load":               self._fetch_nothing,
93             "playlistadd":        self._fetch_nothing,
94             "playlistclear":      self._fetch_nothing,
95             "playlistdelete":     self._fetch_nothing,
96             "playlistmove":       self._fetch_nothing,
97             "rename":             self._fetch_nothing,
98             "rm":                 self._fetch_nothing,
99             "save":               self._fetch_nothing,
100             # Database Commands
101             "albumart":           self._fetch_composite,
102             "count":              self._fetch_object,
103             "getfingerprint":     self._fetch_object,
104             "find":               self._fetch_songs,
105             "findadd":            self._fetch_nothing,
106             "list":               self._fetch_list,
107             "listall":            self._fetch_database,
108             "listallinfo":        self._fetch_database,
109             "listfiles":          self._fetch_database,
110             "lsinfo":             self._fetch_database,
111             "readcomments":       self._fetch_object,
112             "readpicture":        self._fetch_composite,
113             "search":             self._fetch_songs,
114             "searchadd":          self._fetch_nothing,
115             "searchaddpl":        self._fetch_nothing,
116             "update":             self._fetch_item,
117             "rescan":             self._fetch_item,
118             # Mounts and neighbors
119             "mount":              self._fetch_nothing,
120             "unmount":            self._fetch_nothing,
121             "listmounts":         self._fetch_mounts,
122             "listneighbors":      self._fetch_neighbors,
123             # Sticker Commands
124             "sticker get":        self._fetch_item,
125             "sticker set":        self._fetch_nothing,
126             "sticker delete":     self._fetch_nothing,
127             "sticker list":       self._fetch_list,
128             "sticker find":       self._fetch_songs,
129             # Connection Commands
130             "close":              None,
131             "kill":               None,
132             "password":           self._fetch_nothing,
133             "ping":               self._fetch_nothing,
134             "binarylimit":        self._fetch_nothing,
135             "tagtypes":           self._fetch_list,
136             "tagtypes disable":   self._fetch_nothing,
137             "tagtypes enable":    self._fetch_nothing,
138             "tagtypes clear":     self._fetch_nothing,
139             "tagtypes all":       self._fetch_nothing,
140             # Partition Commands
141             "partition":          self._fetch_nothing,
142             "listpartitions":     self._fetch_list,
143             "newpartition":       self._fetch_nothing,
144             "delpartition":       self._fetch_nothing,
145             "moveoutput":         self._fetch_nothing,
146             # Audio Output Commands
147             "disableoutput":      self._fetch_nothing,
148             "enableoutput":       self._fetch_nothing,
149             "toggleoutput":       self._fetch_nothing,
150             "outputs":            self._fetch_outputs,
151             "outputset":          self._fetch_nothing,
152             # Reflection Commands
153             "config":             self._fetch_object,
154             "commands":           self._fetch_list,
155             "notcommands":        self._fetch_list,
156             "urlhandlers":        self._fetch_list,
157             "decoders":           self._fetch_plugins,
158             # Client to Client
159             "subscribe":          self._fetch_nothing,
160             "unsubscribe":        self._fetch_nothing,
161             "channels":           self._fetch_list,
162             "readmessages":       self._fetch_messages,
163             "sendmessage":        self._fetch_nothing,
164         }
165         #: host used with the current connection (:py:obj:`str`)
166         self.host = None
167         #: password detected in :envvar:`MPD_HOST` environment variable (:py:obj:`str`)
168         self.pwd = None
169         #: port used with the current connection (:py:obj:`int`, :py:obj:`str`)
170         self.port = None
171         self._get_envvars()
172         self._pool = ConnectionPool(max_connections=CONNECTION_MAX)
173         log.info('logger : "%s"', __name__)
174         #: current connection
175         self.connection: [None,Connection] = None
176         #: Protocol version
177         self.version: [None,str] = None
178         self._command_list = None
179
180     def _get_envvars(self):
181         """
182         Retrieve MPD env. var. to overrides default "localhost:6600"
183         """
184         # Set some defaults
185         self.host = 'localhost'
186         self.port = os.getenv('MPD_PORT', '6600')
187         _host = os.getenv('MPD_HOST', '')
188         if _host:
189             # If password is set: MPD_HOST=pass@host
190             if '@' in _host:
191                 mpd_host_env = _host.split('@', 1)
192                 if mpd_host_env[0]:
193                     # A password is actually set
194                     log.debug(
195                         'password detected in MPD_HOST, set client pwd attribute')
196                     self.pwd = mpd_host_env[0]
197                     if mpd_host_env[1]:
198                         self.host = mpd_host_env[1]
199                         log.debug('host detected in MPD_HOST: %s', self.host)
200                 elif mpd_host_env[1]:
201                     # No password set but leading @ is an abstract socket
202                     self.host = '@'+mpd_host_env[1]
203                     log.debug(
204                         'host detected in MPD_HOST: %s (abstract socket)', self.host)
205             else:
206                 # MPD_HOST is a plain host
207                 self.host = _host
208                 log.debug('host detected in MPD_HOST: %s', self.host)
209         else:
210             # Is socket there
211             xdg_runtime_dir = os.getenv('XDG_RUNTIME_DIR', '/run')
212             rundir = os.path.join(xdg_runtime_dir, 'mpd/socket')
213             if os.path.exists(rundir):
214                 self.host = rundir
215                 log.debug(
216                     'host detected in ${XDG_RUNTIME_DIR}/run: %s (unix socket)', self.host)
217         _mpd_timeout = os.getenv('MPD_TIMEOUT', '')
218         if _mpd_timeout.isdigit():
219             self.mpd_timeout = int(_mpd_timeout)
220             log.debug('timeout detected in MPD_TIMEOUT: %d', self.mpd_timeout)
221         else:  # Use CONNECTION_TIMEOUT as default even if MPD_TIMEOUT carries gargage
222             self.mpd_timeout = CONNECTION_TIMEOUT
223
224     def __getattr__(self, attr):
225         # if attr == 'send_noidle':  # have send_noidle to cancel idle as well as noidle
226         #     return self.noidle
227         if attr.startswith("send_"):
228             command = attr.replace("send_", "", 1)
229             wrapper = self._send
230         elif attr.startswith("fetch_"):
231             command = attr.replace("fetch_", "", 1)
232             wrapper = self._fetch
233         else:
234             command = attr
235             wrapper = self._execute
236         if command not in self._commands:
237             command = command.replace("_", " ")
238             if command not in self._commands:
239                 cls = self.__class__.__name__
240                 raise AttributeError(
241                     f"'{cls}' object has no attribute '{attr}'")
242         return lambda *args: wrapper(command, args)
243
244     async def _execute(self, command, args):  # pylint: disable=unused-argument
245         self.connection = await self._pool.connect(self.host, self.port)
246         async with self.connection:
247             # if self._pending:
248             #     raise MPDCommandError(
249             #         f"Cannot execute '{command}' with pending commands")
250             retval = self._commands[command]
251             if self._command_list is not None:
252                 if not callable(retval):
253                     raise MPDCommandError(
254                         f"'{command}' not allowed in command list")
255                 self._write_command(command, args)
256                 self._command_list.append(retval)
257             else:
258                 await self._write_command(command, args)
259                 if callable(retval):
260                     log.debug('retvat: %s', retval)
261                     return await retval()
262                 return retval
263             return None
264
265     async def _write_line(self, line):
266         self.connection.write(f"{line!s}\n".encode())
267         await self.connection.drain()
268
269     async def _write_command(self, command, args=None):
270         if args is None:
271             args = []
272         parts = [command]
273         for arg in args:
274             if isinstance(arg, tuple):
275                 parts.append(f'{Range(arg)!s}')
276             else:
277                 parts.append(f'"{escape(str(arg))}"')
278         if '\n' in ' '.join(parts):
279             raise MPDCommandError('new line found in the command!')
280         await self._write_line(' '.join(parts))
281
282     def _read_binary(self, amount):
283         chunk = bytearray()
284         while amount > 0:
285             result = self._rbfile.read(amount)
286             if len(result) == 0:
287                 self.disconnect()
288                 raise ConnectionError(
289                     "Connection lost while reading binary content")
290             chunk.extend(result)
291             amount -= len(result)
292         return bytes(chunk)
293
294     async def _read_line(self, binary=False):
295         if binary:
296             line = self._rbfile.readline().decode('utf-8')
297         else:
298             line = await self.connection.readline()
299         line = line.decode('utf-8')
300         if not line.endswith('\n'):
301             await self.close()
302             raise MPDConnectionError("Connection lost while reading line")
303         line = line.rstrip('\n')
304         if line.startswith(ERROR_PREFIX):
305             error = line[len(ERROR_PREFIX):].strip()
306             raise MPDCommandError(error)
307         if self._command_list is not None:
308             if line == NEXT:
309                 return None
310             if line == SUCCESS:
311                 raise MPDProtocolError(f"Got unexpected '{SUCCESS}'")
312         elif line == SUCCESS:
313             return None
314         return line
315
316     async def _read_pair(self, separator, binary=False):
317         line = await self._read_line(binary=binary)
318         if line is None:
319             return None
320         pair = line.split(separator, 1)
321         if len(pair) < 2:
322             raise MPDProtocolError(f"Could not parse pair: '{line}'")
323         return pair
324
325     async def _read_pairs(self, separator=": ", binary=False):
326         """OK"""
327         pair = await self._read_pair(separator, binary=binary)
328         while pair:
329             yield pair
330             pair = await self._read_pair(separator, binary=binary)
331
332     async def _read_list(self):
333         seen = None
334         for key, value in await self._read_pairs():
335             if key != seen:
336                 if seen is not None:
337                     raise MPDProtocolError(f"Expected key '{seen}', got '{key}'")
338                 seen = key
339             yield value
340
341     async def _read_playlist(self):
342         for _, value in await self._read_pairs(":"):
343             yield value
344
345     async def _read_objects(self, delimiters=None):
346         obj = {}
347         if delimiters is None:
348             delimiters = []
349         async for key, value in self._read_pairs():
350             key = key.lower()
351             if obj:
352                 if key in delimiters:
353                     yield obj
354                     obj = {}
355                 elif key in obj:
356                     if not isinstance(obj[key], list):
357                         obj[key] = [obj[key], value]
358                     else:
359                         obj[key].append(value)
360                     continue
361             obj[key] = value
362         if obj:
363             yield obj
364
365     def _read_command_list(self):
366         try:
367             for retval in self._command_list:
368                 yield retval()
369         finally:
370             self._command_list = None
371         self._fetch_nothing()
372
373     async def _fetch_nothing(self):
374         line = await self._read_line()
375         if line is not None:
376             raise ProtocolError(f"Got unexpected return value: '{line}'")
377
378     async def _fetch_item(self):
379         pairs = list(await self._read_pairs())
380         if len(pairs) != 1:
381             return None
382         return pairs[0][1]
383
384     def _fetch_list(self):
385         return self._read_list()
386
387     def _fetch_playlist(self):
388         return self._read_playlist()
389
390     async def _fetch_object(self):
391         objs = [obj async for obj in self._read_objects()]
392         if not objs:
393             return {}
394         return objs[0]
395
396     async def _fetch_objects(self, delimiters):
397         return [_ async for _ in self._read_objects(delimiters)]
398
399     def _fetch_changes(self):
400         return self._fetch_objects(["cpos"])
401
402     async def _fetch_songs(self):
403         return await self._fetch_objects(["file"])
404
405     def _fetch_playlists(self):
406         return self._fetch_objects(["playlist"])
407
408     def _fetch_database(self):
409         return self._fetch_objects(["file", "directory", "playlist"])
410
411     def _fetch_outputs(self):
412         return self._fetch_objects(["outputid"])
413
414     def _fetch_plugins(self):
415         return self._fetch_objects(["plugin"])
416
417     def _fetch_messages(self):
418         return self._fetch_objects(["channel"])
419
420     def _fetch_mounts(self):
421         return self._fetch_objects(["mount"])
422
423     def _fetch_neighbors(self):
424         return self._fetch_objects(["neighbor"])
425
426     async def _fetch_composite(self):
427         obj = {}
428         for key, value in self._read_pairs(binary=True):
429             key = key.lower()
430             obj[key] = value
431             if key == 'binary':
432                 break
433         if not obj:
434             # If the song file was recognized, but there is no picture, the
435             # response is successful, but is otherwise empty.
436             return obj
437         amount = int(obj['binary'])
438         try:
439             obj['data'] = self._read_binary(amount)
440         except IOError as err:
441             raise ConnectionError(
442                 f'Error reading binary content: {err}') from err
443         data_bytes = len(obj['data'])
444         if data_bytes != amount:  # can we ever get there?
445             raise ConnectionError('Error reading binary content: '
446                                   f'Expects {amount}B, got {data_bytes}')
447         # Fetches trailing new line
448         await self._read_line(binary=True)
449         # Fetches SUCCESS code
450         await self._read_line(binary=True)
451         return obj
452
453     def _fetch_command_list(self):
454         return self._read_command_list()
455
456     async def _hello(self):
457         """Consume HELLO_PREFIX"""
458         data = await self.connection.readuntil(b'\n')
459         rcv = data.decode('utf-8')
460         if not rcv.startswith(HELLO_PREFIX):
461             raise MPDProtocolError(f'Got invalid MPD hello: "{rcv}"')
462         log.debug('consumed hello prefix: %r', rcv)
463         self.version = rcv.split('\n')[0][len(HELLO_PREFIX):]
464         log.info('protocol version: %s', self.version)
465
466     async def connect(self, server=None, port=None) -> Connection:
467         if not server:
468             server = self.host
469         if not port:
470             port = self.port
471         try:
472             self.connection = await self._pool.connect(server, port)
473         except (ValueError, OSError) as err:
474             #TODO: Is this the right way to raise Excep
475             raise MPDConnectionError(err) from err
476         async with self.connection:
477             await self._hello()
478
479     async def close(self):
480         await self.connection.close()