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