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