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