]> kaliko git repositories - mpd-sima.git/blob - sima/client.py
Add levenstein fuzzy method in SimaStr
[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 monitor(self):
174         curr = self.current
175         try:
176             self._client.send_idle('database', 'playlist', 'player', 'options')
177             select([self._client], [], [], 60)
178             ret = self._client.fetch_idle()
179             if self.__skipped_track(curr):
180                 ret.append('skipped')
181             return ret
182         except (MPDError, IOError) as err:
183             raise PlayerError("Couldn't init idle: %s" % err)
184
185     def remove(self, position=0):
186         self._client.delete(position)
187
188     def add(self, track):
189         """Overriding MPD's add method to accept add signature with a Track
190         object"""
191         self._client.add(track.file)
192
193     @property
194     def state(self):
195         return str(self._client.status().get('state'))
196
197     @property
198     def current(self):
199         return self.currentsong()
200
201     @property
202     def queue(self):
203         plst = self.playlist
204         plst.reverse()
205         return [ trk for trk in plst if int(trk.pos) > int(self.current.pos)]
206
207     @property
208     def playlist(self):
209         """
210         Override deprecated MPD playlist command
211         """
212         return self.playlistinfo()
213
214     def connect(self):
215         host, port, password = self._mpd
216         self.disconnect()
217         try:
218             self._client.connect(host, port)
219
220         # Catch socket errors
221         except IOError as err:
222             raise PlayerError('Could not connect to "%s:%s": %s' %
223                               (host, port, err.strerror))
224
225         # Catch all other possible errors
226         # ConnectionError and ProtocolError are always fatal.  Others may not
227         # be, but we don't know how to handle them here, so treat them as if
228         # they are instead of ignoring them.
229         except MPDError as err:
230             raise PlayerError('Could not connect to "%s:%s": %s' %
231                               (host, port, err))
232
233         if password:
234             try:
235                 self._client.password(password)
236
237             # Catch errors with the password command (e.g., wrong password)
238             except CommandError as err:
239                 raise PlayerError("Could not connect to '%s': "
240                                   "password command failed: %s" %
241                                   (host, err))
242
243             # Catch all other possible errors
244             except (MPDError, IOError) as err:
245                 raise PlayerError("Could not connect to '%s': "
246                                   "error with password command: %s" %
247                                   (host, err))
248         # Controls we have sufficient rights
249         needed_cmds = ['status', 'stats', 'add', 'find', \
250                        'search', 'currentsong', 'ping']
251
252         available_cmd = self._client.commands()
253         for nddcmd in needed_cmds:
254             if nddcmd not in available_cmd:
255                 self.disconnect()
256                 raise PlayerError('Could connect to "%s", '
257                                   'but command "%s" not available' %
258                                   (host, nddcmd))
259
260     def disconnect(self):
261         # Try to tell MPD we're closing the connection first
262         try:
263             self._client.close()
264         # If that fails, don't worry, just ignore it and disconnect
265         except (MPDError, IOError):
266             pass
267         try:
268             self._client.disconnect()
269         # Disconnecting failed, so use a new client object instead
270         # This should never happen.  If it does, something is seriously broken,
271         # and the client object shouldn't be trusted to be re-used.
272         except (MPDError, IOError):
273             self._client = MPDClient()
274
275 # VIM MODLINE
276 # vim: ai ts=4 sw=4 sts=4 expandtab