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