]> kaliko git repositories - mpd-sima.git/blob - sima/plugins/lastfm.py
01e8cd98160625bf7905d4479e4376c1ff07a690
[mpd-sima.git] / sima / plugins / lastfm.py
1 # -*- coding: utf-8 -*-
2 """
3 Fetching similar artists from last.fm web services
4 """
5
6 # standart library import
7 import random
8
9 from collections import deque
10 from difflib import get_close_matches
11 from hashlib import md5
12
13 # third parties componants
14
15 # local import
16 from ..utils.leven import levenshtein_ratio
17 from ..lib.plugin import Plugin
18 from ..lib.simafm import SimaFM, XmlFMHTTPError, XmlFMNotFound, XmlFMError
19 from ..lib.simastr import SimaStr
20 from ..lib.track import Track
21
22
23 def cache(func):
24     """Caching decorator"""
25     def wrapper(*args, **kwargs):
26         #pylint: disable=W0212,C0111
27         cls = args[0]
28         similarities = [art + str(match) for art, match in args[1]]
29         hashedlst = md5(''.join(similarities).encode('utf-8')).hexdigest()
30         if hashedlst in cls._cache.get('asearch'):
31             cls.log.debug('cached request')
32             results = cls._cache.get('asearch').get(hashedlst)
33         else:
34             results = func(*args, **kwargs)
35             cls._cache.get('asearch').update({hashedlst:list(results)})
36         random.shuffle(results)
37         return results
38     return wrapper
39
40
41 class Lastfm(Plugin):
42     """last.fm similar artists
43     """
44
45     def __init__(self, daemon):
46         Plugin.__init__(self, daemon)
47         self.daemon_conf = daemon.config
48         self.sdb = daemon.sdb
49         self.player = daemon.player
50         self.history = daemon.short_history
51         ##
52         self.to_add = list()
53         self._cache = None
54         self._flush_cache()
55         wrapper = {
56                 'track': self._track,
57                 'top': self._top,
58                 'album': self._album,
59                 }
60         self.queue_mode = wrapper.get(self.plugin_conf.get('queue_mode'))
61
62     def _flush_cache(self):
63         """
64         Both flushes and instanciates _cache
65         """
66         if isinstance(self._cache, dict):
67             self.log.info('Lastfm: Flushing cache!')
68         else:
69             self.log.info('Lastfm: Initialising cache!')
70         self._cache = {
71                 'artists': None,
72                 'asearch': dict(),
73                 'tsearch': dict(),
74                 }
75         self._cache['artists'] = frozenset(self.player.list('artist'))
76
77     def _cleanup_cache(self):
78         """Avoid bloated cache
79         """
80         for _ , val in self._cache.items():
81             if isinstance(val, dict):
82                 while len(val) > 100:
83                     val.popitem()
84
85     def get_history(self, artist):
86         """Check against history for tracks already in history for a specific
87         artist.
88         """
89         duration = self.daemon_conf.getint('sima', 'history_duration')
90         tracks_from_db = self.sdb.get_history(duration=duration, artist=artist)
91         # Construct Track() objects list from database history
92         played_tracks = [Track(artist=tr[-1], album=tr[1], title=tr[2],
93                                file=tr[3]) for tr in tracks_from_db]
94         return played_tracks
95
96     def filter_track(self, tracks):
97         """
98         Extract one unplayed track from a Track object list.
99             * not in history
100             * not already in the queue
101         """
102         artist = tracks[0].artist
103         black_list = self.player.queue + self.to_add
104         not_in_hist = list(set(tracks) - set(self.get_history(artist=artist)))
105         if not not_in_hist:
106             self.log.debug('All tracks already played for "{}"'.format(artist))
107         random.shuffle(not_in_hist)
108         candidate = [ trk for trk in not_in_hist if trk not in black_list ]
109         if not candidate:
110             self.log.debug('Unable to find title to add' +
111                           ' for "%s".' % artist)
112             return None
113         self.to_add.append(random.choice(candidate))
114
115     def _get_artists_list_reorg(self, alist):
116         """
117         Move around items in artists_list in order to play first not recently
118         played artists
119         """
120         duration = self.daemon_conf.getint('sima', 'history_duration')
121         art_in_hist = list()
122         for trk in self.sdb.get_history(duration=duration,
123                                         artists=alist):
124             if trk[0] not in art_in_hist:
125                 art_in_hist.append(trk[0])
126         art_in_hist.reverse()
127         art_not_in_hist = [ ar for ar in alist if ar not in art_in_hist ]
128         random.shuffle(art_not_in_hist)
129         art_not_in_hist.extend(art_in_hist)
130         self.log.debug('history ordered: {}'.format(
131                        ' / '.join(art_not_in_hist)))
132         return art_not_in_hist
133
134     def _cross_check_artist(self, art):
135         """
136         Controls presence of artists in liste in music library.
137         Crosschecking artist names with SimaStr objects / difflib / levenshtein
138
139         TODO: proceed crosschecking even when an artist matched !!!
140               Not because we found "The Doors" as "The Doors" that there is no
141               remaining entries as "Doors" :/
142               not straight forward, need probably heavy refactoring.
143         """
144         matching_artists = list()
145         artist = SimaStr(art)
146         all_artists = self._cache.get('artists')
147
148         # Check against the actual string in artist list
149         if artist.orig in all_artists:
150             self.log.debug('found exact match for "%s"' % artist)
151             return [artist]
152         # Then proceed with fuzzy matching if got nothing
153         match = get_close_matches(artist.orig, all_artists, 50, 0.73)
154         if not match:
155             return []
156         self.log.debug('found close match for "%s": %s' %
157                        (artist, '/'.join(match)))
158         # Does not perform fuzzy matching on short and single word strings
159         # Only lowercased comparison
160         if ' ' not in artist.orig and len(artist) < 8:
161             for fuzz_art in match:
162                 # Regular string comparison SimaStr().lower is regular string
163                 if artist.lower() == fuzz_art.lower():
164                     matching_artists.append(fuzz_art)
165                     self.log.debug('"%s" matches "%s".' % (fuzz_art, artist))
166             return matching_artists
167         for fuzz_art in match:
168             # Regular string comparison SimaStr().lower is regular string
169             if artist.lower() == fuzz_art.lower():
170                 matching_artists.append(fuzz_art)
171                 self.log.debug('"%s" matches "%s".' % (fuzz_art, artist))
172                 return matching_artists
173             # Proceed with levenshtein and SimaStr
174             leven = levenshtein_ratio(artist.stripped.lower(),
175                     SimaStr(fuzz_art).stripped.lower())
176             # SimaStr string __eq__, not regular string comparison here
177             if artist == fuzz_art:
178                 matching_artists.append(fuzz_art)
179                 self.log.info('"%s" quite probably matches "%s" (SimaStr)' %
180                               (fuzz_art, artist))
181             elif leven >= 0.82:  # PARAM
182                 matching_artists.append(fuzz_art)
183                 self.log.debug('FZZZ: "%s" should match "%s" (lr=%1.3f)' %
184                                (fuzz_art, artist, leven))
185             else:
186                 self.log.debug('FZZZ: "%s" does not match "%s" (lr=%1.3f)' %
187                                (fuzz_art, artist, leven))
188         return matching_artists
189
190     @cache
191     def get_artists_from_player(self, similarities):
192         """
193         Look in player library for availability of similar artists in
194         similarities
195         """
196         dynamic = int(self.plugin_conf.get('dynamic'))
197         if dynamic <= 0:
198             dynamic = 100
199         similarity = int(self.plugin_conf.get('similarity'))
200         results = list()
201         similarities.reverse()
202         while (len(results) < dynamic
203             and len(similarities) > 0):
204             art_pop, match = similarities.pop()
205             if match < similarity:
206                 break
207             results.extend(self._cross_check_artist(art_pop))
208         results and self.log.debug('Similarity: %d%%' % match)
209         return results
210
211     def lfm_similar_artists(self, artist=None):
212         """
213         Retrieve similar artists on last.fm server.
214         """
215         if artist is None:
216             current = self.player.current
217         else:
218             current = artist
219         simafm = SimaFM()
220         # initialize artists deque list to construct from DB
221         as_art = deque()
222         as_artists = simafm.get_similar(artist=current.artist)
223         self.log.debug('Requesting last.fm for "{0.artist}"'.format(current))
224         try:
225             [as_art.append((a, m)) for a, m in as_artists]
226         except XmlFMHTTPError as err:
227             self.log.warning('last.fm http error: %s' % err)
228         except XmlFMNotFound as err:
229             self.log.warning("last.fm: %s" % err)
230         except XmlFMError as err:
231             self.log.warning('last.fm module error: %s' % err)
232         if as_art:
233             self.log.debug('Fetched %d artist(s) from last.fm' % len(as_art))
234         return as_art
235
236     def get_recursive_similar_artist(self):
237         history = deque(self.history)
238         history.popleft()
239         ret_extra = list()
240         depth = 0
241         current = self.player.current
242         extra_arts = list()
243         while depth < int(self.plugin_conf.get('depth')):
244             trk = history.popleft()
245             if trk.artist in [trk.artist for trk in extra_arts]:
246                 continue
247             extra_arts.append(trk)
248             depth += 1
249             if len(history) == 0:
250                 break
251         self.log.info('EXTRA ARTS: {}'.format(
252             '/'.join([trk.artist for trk in extra_arts])))
253         for artist in extra_arts:
254             self.log.debug('Looking for artist similar to "{0.artist}" as well'.format(artist))
255             similar = self.lfm_similar_artists(artist=artist)
256             similar = sorted(similar, key=lambda sim: sim[1], reverse=True)
257             ret_extra.extend(self.get_artists_from_player(similar))
258             if current.artist in ret_extra:
259                 ret_extra.remove(current.artist)
260         return ret_extra
261
262     def get_local_similar_artists(self):
263         """Check against local player for similar artists fetched from last.fm
264         """
265         current = self.player.current
266         self.log.info('Looking for artist similar to "{0.artist}"'.format(current))
267         similar = self.lfm_similar_artists()
268         if not similar:
269             self.log.info('Got nothing from last.fm!')
270             return []
271         similar = sorted(similar, key=lambda sim: sim[1], reverse=True)
272         self.log.info('First five similar artist(s): {}...'.format(
273                       ' / '.join([a for a, m in similar[0:5]])))
274         self.log.info('Looking availability in music library')
275         ret = self.get_artists_from_player(similar)
276         ret_extra = None
277         if len(self.history) >= 2:
278             ret_extra = self.get_recursive_similar_artist()
279         if not ret:
280             self.log.warning('Got nothing from music library.')
281             self.log.warning('Try running in debug mode to guess why...')
282             return []
283         if ret_extra:
284             ret = list(set(ret) | set(ret_extra))
285         self.log.info('Got {} artists in library'.format(len(ret)))
286         self.log.info(' / '.join(ret))
287         # Move around similars items to get in unplayed|not recently played
288         # artist first.
289         return self._get_artists_list_reorg(ret)
290
291     def _track(self):
292         """Get some tracks for track queue mode
293         """
294         artists = self.get_local_similar_artists()
295         nbtracks_target = int(self.plugin_conf.get('track_to_add'))
296         for artist in artists:
297             self.log.debug('Trying to find titles to add for "{}"'.format(
298                            artist))
299             found = self.player.find_track(artist)
300             # find tracks not in history
301             self.filter_track(found)
302             if len(self.to_add) == nbtracks_target:
303                 break
304         if not self.to_add:
305             self.log.debug('Found no unplayed tracks, is your ' +
306                              'history getting too large?')
307             return None
308         for track in self.to_add:
309             self.log.info('last.fm candidate: {0!s}'.format(track))
310
311     def _album(self):
312         """Get albums for album queue mode
313         """
314         artists = self.get_local_similar_artists()
315
316     def _top(self):
317         """Get some tracks for top track queue mode
318         """
319         artists = self.get_local_similar_artists()
320
321     def callback_need_track(self):
322         self._cleanup_cache()
323         if not self.player.current:
324             self.log.info('No currently playing track, cannot queue')
325             return None
326         self.queue_mode()
327         candidates = self.to_add
328         self.to_add = list()
329         return candidates
330
331     def callback_player_database(self):
332         self._flush_cache()
333
334 # VIM MODLINE
335 # vim: ai ts=4 sw=4 sts=4 expandtab