]> kaliko git repositories - python-musicpdaio.git/blob - mpdaio/client.py
Add connections propertie
[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.connections}
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     @property
101     def connections(self):
102         """Open connections"""
103         host = (self.host, self.port)
104         return self._pool._connections.get(host, [])
105
106     async def close(self):
107         await self._pool.close()
108
109
110 class CmdHandler:
111
112     def __init__(self, pool, server, port, password, timeout):
113         self._commands = {
114             # Status Commands
115             "clearerror":         self._fetch_nothing,
116             "currentsong":        self._fetch_object,
117             "idle":               self._fetch_list,
118             # "noidle":             None,
119             "status":             self._fetch_object,
120             "stats":              self._fetch_object,
121             # Playback Option Commands
122             "consume":            self._fetch_nothing,
123             "crossfade":          self._fetch_nothing,
124             "mixrampdb":          self._fetch_nothing,
125             "mixrampdelay":       self._fetch_nothing,
126             "random":             self._fetch_nothing,
127             "repeat":             self._fetch_nothing,
128             "setvol":             self._fetch_nothing,
129             "getvol":             self._fetch_object,
130             "single":             self._fetch_nothing,
131             "replay_gain_mode":   self._fetch_nothing,
132             "replay_gain_status": self._fetch_item,
133             "volume":             self._fetch_nothing,
134             # Playback Control Commands
135             "next":               self._fetch_nothing,
136             "pause":              self._fetch_nothing,
137             "play":               self._fetch_nothing,
138             "playid":             self._fetch_nothing,
139             "previous":           self._fetch_nothing,
140             "seek":               self._fetch_nothing,
141             "seekid":             self._fetch_nothing,
142             "seekcur":            self._fetch_nothing,
143             "stop":               self._fetch_nothing,
144             # Queue Commands
145             "add":                self._fetch_nothing,
146             "addid":              self._fetch_item,
147             "clear":              self._fetch_nothing,
148             "delete":             self._fetch_nothing,
149             "deleteid":           self._fetch_nothing,
150             "move":               self._fetch_nothing,
151             "moveid":             self._fetch_nothing,
152             "playlist":           self._fetch_playlist,
153             "playlistfind":       self._fetch_songs,
154             "playlistid":         self._fetch_songs,
155             "playlistinfo":       self._fetch_songs,
156             "playlistsearch":     self._fetch_songs,
157             "plchanges":          self._fetch_songs,
158             "plchangesposid":     self._fetch_changes,
159             "prio":               self._fetch_nothing,
160             "prioid":             self._fetch_nothing,
161             "rangeid":            self._fetch_nothing,
162             "shuffle":            self._fetch_nothing,
163             "swap":               self._fetch_nothing,
164             "swapid":             self._fetch_nothing,
165             "addtagid":           self._fetch_nothing,
166             "cleartagid":         self._fetch_nothing,
167             # Stored Playlist Commands
168             "listplaylist":       self._fetch_list,
169             "listplaylistinfo":   self._fetch_songs,
170             "listplaylists":      self._fetch_playlists,
171             "load":               self._fetch_nothing,
172             "playlistadd":        self._fetch_nothing,
173             "playlistclear":      self._fetch_nothing,
174             "playlistdelete":     self._fetch_nothing,
175             "playlistmove":       self._fetch_nothing,
176             "rename":             self._fetch_nothing,
177             "rm":                 self._fetch_nothing,
178             "save":               self._fetch_nothing,
179             # Database Commands
180             "albumart":           self._fetch_composite,
181             "count":              self._fetch_object,
182             "getfingerprint":     self._fetch_object,
183             "find":               self._fetch_songs,
184             "findadd":            self._fetch_nothing,
185             "list":               self._fetch_list,
186             "listall":            self._fetch_database,
187             "listallinfo":        self._fetch_database,
188             "listfiles":          self._fetch_database,
189             "lsinfo":             self._fetch_database,
190             "readcomments":       self._fetch_object,
191             "readpicture":        self._fetch_composite,
192             "search":             self._fetch_songs,
193             "searchadd":          self._fetch_nothing,
194             "searchaddpl":        self._fetch_nothing,
195             "update":             self._fetch_item,
196             "rescan":             self._fetch_item,
197             # Mounts and neighbors
198             "mount":              self._fetch_nothing,
199             "unmount":            self._fetch_nothing,
200             "listmounts":         self._fetch_mounts,
201             "listneighbors":      self._fetch_neighbors,
202             # Sticker Commands
203             "sticker get":        self._fetch_item,
204             "sticker set":        self._fetch_nothing,
205             "sticker delete":     self._fetch_nothing,
206             "sticker list":       self._fetch_list,
207             "sticker find":       self._fetch_songs,
208             # Connection Commands
209             "close":              None,
210             "kill":               None,
211             "password":           self._fetch_nothing,
212             "ping":               self._fetch_nothing,
213             "binarylimit":        self._fetch_nothing,
214             "tagtypes":           self._fetch_list,
215             "tagtypes disable":   self._fetch_nothing,
216             "tagtypes enable":    self._fetch_nothing,
217             "tagtypes clear":     self._fetch_nothing,
218             "tagtypes all":       self._fetch_nothing,
219             # Partition Commands
220             "partition":          self._fetch_nothing,
221             "listpartitions":     self._fetch_list,
222             "newpartition":       self._fetch_nothing,
223             "delpartition":       self._fetch_nothing,
224             "moveoutput":         self._fetch_nothing,
225             # Audio Output Commands
226             "disableoutput":      self._fetch_nothing,
227             "enableoutput":       self._fetch_nothing,
228             "toggleoutput":       self._fetch_nothing,
229             "outputs":            self._fetch_outputs,
230             "outputset":          self._fetch_nothing,
231             # Reflection Commands
232             "config":             self._fetch_object,
233             "commands":           self._fetch_list,
234             "notcommands":        self._fetch_list,
235             "urlhandlers":        self._fetch_list,
236             "decoders":           self._fetch_plugins,
237             # Client to Client
238             "subscribe":          self._fetch_nothing,
239             "unsubscribe":        self._fetch_nothing,
240             "channels":           self._fetch_list,
241             "readmessages":       self._fetch_messages,
242             "sendmessage":        self._fetch_nothing,
243         }
244         self.command = None
245         self._command_list = None
246         self.args = None
247         self.pool = pool
248         self.host = (server, port)
249         self.password = password
250         self.timeout = timeout
251         #: current connection
252         self.connection: [None, Connection] = None
253
254     def __repr__(self):
255         args = [str(_) for _ in self.args]
256         args = ','.join(args or [])
257         return f'{self.command}({args})'
258
259     async def __call__(self, command: str, args: list | None):
260         server, port = self.host
261         self.command = command
262         self.args = args or ''
263         self.connection = await self.pool.connect(server, port, timeout=self.timeout)
264         async with self.connection:
265             retval = self._commands[command]
266             await self._write_command(command, args)
267             if callable(retval):
268                 return await retval()
269             return retval
270
271     async def _write_line(self, line):
272         self.connection.write(f"{line!s}\n".encode())
273         await self.connection.drain()
274
275     async def _write_command(self, command, args=None):
276         if args is None:
277             args = []
278         parts = [command]
279         for arg in args:
280             if isinstance(arg, tuple):
281                 parts.append(f'{Range(arg)!s}')
282             else:
283                 parts.append(f'"{escape(str(arg))}"')
284         if '\n' in ' '.join(parts):
285             raise MPDCommandError('new line found in the command!')
286         #log.debug(' '.join(parts))
287         await self._write_line(' '.join(parts))
288
289     async def _read_binary(self, amount):
290         chunk = bytearray()
291         while amount > 0:
292             result = await self.connection.read(amount)
293             if len(result) == 0:
294                 await self.connection.close()
295                 raise ConnectionError(
296                     "Connection lost while reading binary content")
297             chunk.extend(result)
298             amount -= len(result)
299         return bytes(chunk)
300
301     async def _read_line(self, binary=False):
302         line = await self.connection.readline()
303         line = line.decode('utf-8')
304         if not line.endswith('\n'):
305             await self.connection.close()
306             raise MPDConnectionError("Connection lost while reading line")
307         line = line.rstrip('\n')
308         if line.startswith(ERROR_PREFIX):
309             error = line[len(ERROR_PREFIX):].strip()
310             raise MPDCommandError(error)
311         if self._command_list is not None:
312             if line == NEXT:
313                 return None
314             if line == SUCCESS:
315                 raise MPDProtocolError(f"Got unexpected '{SUCCESS}'")
316         elif line == SUCCESS:
317             return None
318         return line
319
320     async def _read_pair(self, separator, binary=False):
321         line = await self._read_line(binary=binary)
322         if line is None:
323             return None
324         pair = line.split(separator, 1)
325         if len(pair) < 2:
326             raise MPDProtocolError(f"Could not parse pair: '{line}'")
327         return pair
328
329     async def _read_pairs(self, separator=": ", binary=False):
330         pair = await self._read_pair(separator, binary=binary)
331         while pair:
332             yield pair
333             pair = await self._read_pair(separator, binary=binary)
334
335     async def _read_list(self):
336         seen = None
337         async for key, value in self._read_pairs():
338             if key != seen:
339                 if seen is not None:
340                     raise MPDProtocolError(
341                         f"Expected key '{seen}', got '{key}'")
342                 seen = key
343             yield value
344
345     async def _read_playlist(self):
346         async for _, value in self._read_pairs(":"):
347             yield value
348
349     async def _read_objects(self, delimiters=None):
350         obj = {}
351         if delimiters is None:
352             delimiters = []
353         async for key, value in self._read_pairs():
354             key = key.lower()
355             if obj:
356                 if key in delimiters:
357                     yield obj
358                     obj = {}
359                 elif key in obj:
360                     if not isinstance(obj[key], list):
361                         obj[key] = [obj[key], value]
362                     else:
363                         obj[key].append(value)
364                     continue
365             obj[key] = value
366         if obj:
367             yield obj
368
369     async def _read_command_list(self):
370         try:
371             for retval in self._command_list:
372                 yield retval()
373         finally:
374             self._command_list = None
375         await self._fetch_nothing()
376
377     async def _fetch_nothing(self):
378         line = await self._read_line()
379         if line is not None:
380             raise ProtocolError(f"Got unexpected return value: '{line}'")
381
382     async def _fetch_item(self):
383         pairs = [_ async for _ in self._read_pairs()]
384         if len(pairs) != 1:
385             return None
386         return pairs[0][1]
387
388     async def _fetch_list(self):
389         return [_ async for _ in self._read_list()]
390
391     async def _fetch_playlist(self):
392         return [_ async for _ in self._read_pairs(':')]
393
394     async def _fetch_object(self):
395         objs = [obj async for obj in self._read_objects()]
396         if not objs:
397             return {}
398         return objs[0]
399
400     async def _fetch_objects(self, delimiters):
401         return [_ async for _ in self._read_objects(delimiters)]
402
403     async def _fetch_changes(self):
404         return await self._fetch_objects(["cpos"])
405
406     async def _fetch_songs(self):
407         return await self._fetch_objects(["file"])
408
409     async def _fetch_playlists(self):
410         return await self._fetch_objects(["playlist"])
411
412     async def _fetch_database(self):
413         return await self._fetch_objects(["file", "directory", "playlist"])
414
415     async def _fetch_outputs(self):
416         return await self._fetch_objects(["outputid"])
417
418     async def _fetch_plugins(self):
419         return await self._fetch_objects(["plugin"])
420
421     async def _fetch_messages(self):
422         return await self._fetch_objects(["channel"])
423
424     async def _fetch_mounts(self):
425         return await self._fetch_objects(["mount"])
426
427     async def _fetch_neighbors(self):
428         return await self._fetch_objects(["neighbor"])
429
430     async def _fetch_composite(self):
431         obj = {}
432         async for key, value in self._read_pairs(binary=True):
433             key = key.lower()
434             obj[key] = value
435             if key == 'binary':
436                 break
437         if not obj:
438             # If the song file was recognized, but there is no picture, the
439             # response is successful, but is otherwise empty.
440             return obj
441         amount = int(obj['binary'])
442         try:
443             obj['data'] = await self._read_binary(amount)
444         except IOError as err:
445             raise ConnectionError(
446                 f'Error reading binary content: {err}') from err
447         data_bytes = len(obj['data'])
448         if data_bytes != amount:  # can we ever get there?
449             raise ConnectionError('Error reading binary content: '
450                                   f'Expects {amount}B, got {data_bytes}')
451         # Fetches trailing new line
452         await self._read_line(binary=True)
453         #ALT: await self.connection.readuntil(b'\n')
454         # Fetches SUCCESS code
455         await self._read_line(binary=True)
456         #ALT: await self.connection.readuntil(b'OK\n')
457         return obj
458
459     async def _fetch_command_list(self):
460         return [_ async for _ in self._read_command_list()]