]> kaliko git repositories - mpd-sima.git/blob - sima/client.py
Add lastfm/album mode
[mpd-sima.git] / sima / client.py
1 # -* coding: utf-8 -*-
2 """MPD client for Sima
3
4 This client is built above python-musicpd a fork of python-mpd
5 """
6 #  pylint: disable=C0111
7
8 # standart library import
9
10 from difflib import get_close_matches
11 from select import select
12
13 # third parties componants
14 try:
15     from musicpd import (MPDClient, MPDError, CommandError)
16 except ImportError as err:
17     from sys import exit as sexit
18     print('ERROR: missing python-musicpd?\n{0}'.format(err))
19     sexit(1)
20
21 # local import
22 from .lib.player import Player
23 from .lib.track import Track
24 from .lib.simastr import SimaStr
25
26
27 class PlayerError(Exception):
28     """Fatal error in poller."""
29
30 class PlayerCommandError(PlayerError):
31     """Command error"""
32
33 PlayerUnHandledError = MPDError  # pylint: disable=C0103
34
35 class PlayerClient(Player):
36     """MPC Client
37     From python-musicpd:
38         _fetch_nothing  …
39         _fetch_item     single str
40         _fetch_object   single dict
41         _fetch_list     list of str
42         _fetch_playlist list of str
43         _fetch_changes  list of dict
44         _fetch_database list of dict
45         _fetch_songs    list of dict, especially tracks
46         _fetch_plugins,
47     TODO: handle exception in command not going through _client_wrapper() (ie.
48           find_aa, remove…)
49     """
50     def __init__(self, host="localhost", port="6600", password=None):
51         super().__init__()
52         self._comm = self._args = None
53         self._mpd = host, port, password
54         self._client = MPDClient()
55         self._client.iterate = True
56
57     def __getattr__(self, attr):
58         command = attr
59         wrapper = self._execute
60         return lambda *args: wrapper(command, args)
61
62     def _execute(self, command, args):
63         self._write_command(command, args)
64         return self._client_wrapper()
65
66     def _write_command(self, command, args=[]):
67         self._comm = command
68         self._args = list()
69         for arg in args:
70             self._args.append(arg)
71
72     def _client_wrapper(self):
73         func = self._client.__getattr__(self._comm)
74         try:
75             ans = func(*self._args)
76         # WARNING: MPDError is an ancestor class of # CommandError
77         except CommandError as err:
78             raise PlayerCommandError('MPD command error: %s' % err)
79         except (MPDError, IOError) as err:
80             raise PlayerError(err)
81         return self._track_format(ans)
82
83     def _track_format(self, ans):
84         """
85         unicode_obj = ["idle", "listplaylist", "list", "sticker list",
86                 "commands", "notcommands", "tagtypes", "urlhandlers",]
87         """
88         # TODO: ain't working for "sticker find" and "sticker list"
89         tracks_listing = ["playlistfind", "playlistid", "playlistinfo",
90                 "playlistsearch", "plchanges", "listplaylistinfo", "find",
91                 "search", "sticker find",]
92         track_obj = ['currentsong']
93         if self._comm in tracks_listing + track_obj:
94             #  pylint: disable=w0142
95             if isinstance(ans, list):
96                 return [Track(**track) for track in ans]
97             elif isinstance(ans, dict):
98                 return Track(**ans)
99         return ans
100
101     def __skipped_track(self, old_curr):
102         if (self.state == 'stop'
103             or not hasattr(old_curr, 'id')
104             or not hasattr(self.current, 'id')):
105             return False
106         return (self.current.id != old_curr.id)  # pylint: disable=no-member
107
108     def find_track(self, artist, title=None):
109         #return getattr(self, 'find')('artist', artist, 'title', title)
110         if title:
111             return self.find('artist', artist, 'title', title)
112         return self.find('artist', artist)
113
114     def fuzzy_find(self, art):
115         """
116         Controls presence of artist in music library.
117         Crosschecking artist names with SimaStr objects / difflib / levenshtein
118
119         TODO: proceed crosschecking even when an artist matched !!!
120               Not because we found "The Doors" as "The Doors" that there is no
121               remaining entries as "Doors" :/
122               not straight forward, need probably heavy refactoring.
123         """
124         matching_artists = list()
125         artist = SimaStr(art)
126         all_artists = self.list('artist')
127
128         # Check against the actual string in artist list
129         if artist.orig in all_artists:
130             self.log.debug('found exact match for "%s"' % artist)
131             return [artist]
132         # Then proceed with fuzzy matching if got nothing
133         match = get_close_matches(artist.orig, all_artists, 50, 0.73)
134         if not match:
135             return []
136         self.log.debug('found close match for "%s": %s' %
137                        (artist, '/'.join(match)))
138         # Does not perform fuzzy matching on short and single word strings
139         # Only lowercased comparison
140         if ' ' not in artist.orig and len(artist) < 8:
141             for fuzz_art in match:
142                 # Regular string comparison SimaStr().lower is regular string
143                 if artist.lower() == fuzz_art.lower():
144                     matching_artists.append(fuzz_art)
145                     self.log.debug('"%s" matches "%s".' % (fuzz_art, artist))
146             return matching_artists
147         for fuzz_art in match:
148             # Regular string comparison SimaStr().lower is regular string
149             if artist.lower() == fuzz_art.lower():
150                 matching_artists.append(fuzz_art)
151                 self.log.debug('"%s" matches "%s".' % (fuzz_art, artist))
152                 return matching_artists
153             # SimaStr string __eq__ (not regular string comparison here)
154             if artist == fuzz_art:
155                 matching_artists.append(fuzz_art)
156                 self.log.info('"%s" quite probably matches "%s" (SimaStr)' %
157                               (fuzz_art, artist))
158             else:
159                 self.log.debug('FZZZ: "%s" does not match "%s"' %
160                                (fuzz_art, artist))
161         return matching_artists
162
163     def find_album(self, artist, album):
164         """
165         Special wrapper around album search:
166         Album lookup is made through AlbumArtist/Album instead of Artist/Album
167         """
168         alb_art_search = self.find('albumartist', artist, 'album', album)
169         if alb_art_search:
170             return alb_art_search
171         return self.find('artist', artist, 'album', album)
172
173     def find_albums(self, artist):
174         """
175         Special wrapper around album search:
176         Album lookup is made through AlbumArtist/Album instead of Artist/Album
177         """
178         alb_art_search = self.list('album', 'albumartist', artist,)
179         if alb_art_search:
180             return alb_art_search
181         return self.list('album', 'artist', artist)
182
183     def monitor(self):
184         curr = self.current
185         try:
186             self._client.send_idle('database', 'playlist', 'player', 'options')
187             select([self._client], [], [], 60)
188             ret = self._client.fetch_idle()
189             if self.__skipped_track(curr):
190                 ret.append('skipped')
191             return ret
192         except (MPDError, IOError) as err:
193             raise PlayerError("Couldn't init idle: %s" % err)
194
195     def remove(self, position=0):
196         self._client.delete(position)
197
198     def add(self, track):
199         """Overriding MPD's add method to accept add signature with a Track
200         object"""
201         self._client.add(track.file)
202
203     @property
204     def state(self):
205         return str(self._client.status().get('state'))
206
207     @property
208     def current(self):
209         return self.currentsong()
210
211     @property
212     def queue(self):
213         plst = self.playlist
214         plst.reverse()
215         return [ trk for trk in plst if int(trk.pos) > int(self.current.pos)]
216
217     @property
218     def playlist(self):
219         """
220         Override deprecated MPD playlist command
221         """
222         return self.playlistinfo()
223
224     def connect(self):
225         host, port, password = self._mpd
226         self.disconnect()
227         try:
228             self._client.connect(host, port)
229
230         # Catch socket errors
231         except IOError as err:
232             raise PlayerError('Could not connect to "%s:%s": %s' %
233                               (host, port, err.strerror))
234
235         # Catch all other possible errors
236         # ConnectionError and ProtocolError are always fatal.  Others may not
237         # be, but we don't know how to handle them here, so treat them as if
238         # they are instead of ignoring them.
239         except MPDError as err:
240             raise PlayerError('Could not connect to "%s:%s": %s' %
241                               (host, port, err))
242
243         if password:
244             try:
245                 self._client.password(password)
246
247             # Catch errors with the password command (e.g., wrong password)
248             except CommandError as err:
249                 raise PlayerError("Could not connect to '%s': "
250                                   "password command failed: %s" %
251                                   (host, err))
252
253             # Catch all other possible errors
254             except (MPDError, IOError) as err:
255                 raise PlayerError("Could not connect to '%s': "
256                                   "error with password command: %s" %
257                                   (host, err))
258         # Controls we have sufficient rights
259         needed_cmds = ['status', 'stats', 'add', 'find', \
260                        'search', 'currentsong', 'ping']
261
262         available_cmd = self._client.commands()
263         for nddcmd in needed_cmds:
264             if nddcmd not in available_cmd:
265                 self.disconnect()
266                 raise PlayerError('Could connect to "%s", '
267                                   'but command "%s" not available' %
268                                   (host, nddcmd))
269
270     def disconnect(self):
271         # Try to tell MPD we're closing the connection first
272         try:
273             self._client.close()
274         # If that fails, don't worry, just ignore it and disconnect
275         except (MPDError, IOError):
276             pass
277         try:
278             self._client.disconnect()
279         # Disconnecting failed, so use a new client object instead
280         # This should never happen.  If it does, something is seriously broken,
281         # and the client object shouldn't be trusted to be re-used.
282         except (MPDError, IOError):
283             self._client = MPDClient()
284
285 # VIM MODLINE
286 # vim: ai ts=4 sw=4 sts=4 expandtab