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