]> kaliko git repositories - mpd-sima.git/blob - sima/plugins/internal/lastfm.py
Fixed a minor cache issue
[mpd-sima.git] / sima / plugins / internal / 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 itertools import dropwhile
11 from hashlib import md5
12
13 # third parties componants
14
15 # local import
16 from ...lib.plugin import Plugin
17 from ...lib.simafm import SimaFM, XmlFMHTTPError, XmlFMNotFound, XmlFMError
18 from ...lib.track import Track
19
20
21 def cache(func):
22     """Caching decorator"""
23     def wrapper(*args, **kwargs):
24         #pylint: disable=W0212,C0111
25         cls = args[0]
26         similarities = [art + str(match) for art, match in args[1]]
27         hashedlst = md5(''.join(similarities).encode('utf-8')).hexdigest()
28         if hashedlst in cls._cache.get('asearch'):
29             cls.log.debug('cached request')
30             results = cls._cache.get('asearch').get(hashedlst)
31         else:
32             results = func(*args, **kwargs)
33             cls.log.debug('caching request')
34             cls._cache.get('asearch').update({hashedlst:list(results)})
35         random.shuffle(results)
36         return results
37     return wrapper
38
39
40 def blacklist(artist=False, album=False, track=False):
41     #pylint: disable=C0111,W0212
42     field = (artist, album, track)
43     def decorated(func):
44         def wrapper(*args, **kwargs):
45             cls = args[0]
46             boolgen = (bl for bl in field)
47             bl_fun = (cls._Plugin__daemon.sdb.get_bl_artist,
48                       cls._Plugin__daemon.sdb.get_bl_album,
49                       cls._Plugin__daemon.sdb.get_bl_track,)
50             #bl_getter = next(fn for fn, bl in zip(bl_fun, boolgen) if bl is True)
51             bl_getter = next(dropwhile(lambda _: not next(boolgen), bl_fun))
52             cls.log.debug('using {0} as bl filter'.format(bl_getter.__name__))
53             if artist:
54                 results = func(*args, **kwargs)
55                 for elem in results:
56                     if bl_getter(elem, add_not=True):
57                         cls.log.info('Blacklisted: {0}'.format(elem))
58                         results.remove(elem)
59                 return results
60             if track:
61                 for elem in args[1]:
62                     if bl_getter(elem, add_not=True):
63                         cls.log.info('Blacklisted: {0}'.format(elem))
64                         args[1].remove(elem)
65                 return func(*args, **kwargs)
66         return wrapper
67     return decorated
68
69
70 class Lastfm(Plugin):
71     """last.fm similar artists
72     """
73
74     def __init__(self, daemon):
75         Plugin.__init__(self, daemon)
76         self.daemon_conf = daemon.config
77         self.sdb = daemon.sdb
78         self.history = daemon.short_history
79         ##
80         self.to_add = list()
81         self._cache = None
82         self._flush_cache()
83         wrapper = {
84                 'track': self._track,
85                 'top': self._top,
86                 'album': self._album,
87                 }
88         self.queue_mode = wrapper.get(self.plugin_conf.get('queue_mode'))
89
90     def _flush_cache(self):
91         """
92         Both flushes and instanciates _cache
93         """
94         if isinstance(self._cache, dict):
95             self.log.info('Lastfm: Flushing cache!')
96         else:
97             self.log.info('Lastfm: Initialising cache!')
98         self._cache = {
99                 'artists': None,
100                 'asearch': dict(),
101                 'tsearch': dict(),
102                 }
103         self._cache['artists'] = frozenset(self.player.list('artist'))
104
105     def _cleanup_cache(self):
106         """Avoid bloated cache
107         """
108         for _ , val in self._cache.items():
109             if isinstance(val, dict):
110                 while len(val) > 150:
111                     val.popitem()
112
113     def get_history(self, artist):
114         """Constructs list of Track for already played titles for an artist.
115         """
116         duration = self.daemon_conf.getint('sima', 'history_duration')
117         tracks_from_db = self.sdb.get_history(duration=duration, artist=artist)
118         # Construct Track() objects list from database history
119         played_tracks = [Track(artist=tr[-1], album=tr[1], title=tr[2],
120                                file=tr[3]) for tr in tracks_from_db]
121         return played_tracks
122
123     def filter_track(self, tracks):
124         """
125         Extract one unplayed track from a Track object list.
126             * not in history
127             * not already in the queue
128             * not blacklisted
129         """
130         artist = tracks[0].artist
131         black_list = self.player.queue + self.to_add
132         not_in_hist = list(set(tracks) - set(self.get_history(artist=artist)))
133         if not not_in_hist:
134             self.log.debug('All tracks already played for "{}"'.format(artist))
135         random.shuffle(not_in_hist)
136         #candidate = [ trk for trk in not_in_hist if trk not in black_list
137                       #if not self.sdb.get_bl_track(trk, add_not=True)]
138         candidate = []
139         for trk in [_ for _ in not_in_hist if _ not in black_list]:
140             if self.sdb.get_bl_track(trk, add_not=True):
141                 self.log.info('Blacklisted: {0}: '.format(trk))
142                 continue
143             if self.sdb.get_bl_album(trk, add_not=True):
144                 self.log.info('Blacklisted album: {0}: '.format(trk))
145                 continue
146             # Should use albumartist heuristic as well
147             if self.plugin_conf.getboolean('single_album'):
148                 if (trk.album == self.player.current.album or
149                     trk.album in [trk.alb for trk in self.to_add]):
150                     self.log.debug('Found unplayed track ' +
151                                'but from an album already queued: %s' % (trk))
152                     continue
153             candidate.append(trk)
154         if not candidate:
155             self.log.debug('Unable to find title to add' +
156                            ' for "%s".' % artist)
157             return None
158         self.to_add.append(random.choice(candidate))
159
160     def _get_artists_list_reorg(self, alist):
161         """
162         Move around items in artists_list in order to play first not recently
163         played artists
164         """
165         # TODO: move to utils as a decorator
166         duration = self.daemon_conf.getint('sima', 'history_duration')
167         art_in_hist = list()
168         for trk in self.sdb.get_history(duration=duration,
169                                         artists=alist):
170             if trk[0] not in art_in_hist:
171                 art_in_hist.append(trk[0])
172         art_in_hist.reverse()
173         art_not_in_hist = [ ar for ar in alist if ar not in art_in_hist ]
174         random.shuffle(art_not_in_hist)
175         art_not_in_hist.extend(art_in_hist)
176         self.log.debug('history ordered: {}'.format(
177                        ' / '.join(art_not_in_hist)))
178         return art_not_in_hist
179
180     @blacklist(artist=True)
181     @cache
182     def get_artists_from_player(self, similarities):
183         """
184         Look in player library for availability of similar artists in
185         similarities
186         """
187         dynamic = int(self.plugin_conf.get('dynamic'))
188         if dynamic <= 0:
189             dynamic = 100
190         similarity = int(self.plugin_conf.get('similarity'))
191         results = list()
192         similarities.reverse()
193         while (len(results) < dynamic
194             and len(similarities) > 0):
195             art_pop, match = similarities.pop()
196             if match < similarity:
197                 break
198             results.extend(self.player.fuzzy_find(art_pop))
199         results and self.log.debug('Similarity: %d%%' % match) # pylint: disable=w0106
200         return results
201
202     def lfm_similar_artists(self, artist=None):
203         """
204         Retrieve similar artists on last.fm server.
205         """
206         if artist is None:
207             current = self.player.current
208         else:
209             current = artist
210         simafm = SimaFM()
211         # initialize artists deque list to construct from DB
212         as_art = deque()
213         as_artists = simafm.get_similar(artist=current.artist)
214         self.log.debug('Requesting last.fm for "{0.artist}"'.format(current))
215         try:
216             [as_art.append((a, m)) for a, m in as_artists]
217         except XmlFMHTTPError as err:
218             self.log.warning('last.fm http error: %s' % err)
219         except XmlFMNotFound as err:
220             self.log.warning("last.fm: %s" % err)
221         except XmlFMError as err:
222             self.log.warning('last.fm module error: %s' % err)
223         if as_art:
224             self.log.debug('Fetched %d artist(s) from last.fm' % len(as_art))
225         return as_art
226
227     def get_recursive_similar_artist(self):
228         ret_extra = list()
229         history = deque(self.history)
230         history.popleft()
231         depth = 0
232         current = self.player.current
233         extra_arts = list()
234         while depth < int(self.plugin_conf.get('depth')):
235             if len(history) == 0:
236                 break
237             trk = history.popleft()
238             if (trk.artist in [trk.artist for trk in extra_arts]
239                 or trk.artist == current.artist):
240                 continue
241             extra_arts.append(trk)
242             depth += 1
243         self.log.info('EXTRA ARTS: {}'.format(
244             '/'.join([trk.artist for trk in extra_arts])))
245         for artist in extra_arts:
246             self.log.debug('Looking for artist similar to "{0.artist}" as well'.format(artist))
247             similar = self.lfm_similar_artists(artist=artist)
248             if not similar:
249                 return ret_extra
250             similar = sorted(similar, key=lambda sim: sim[1], reverse=True)
251             ret_extra.extend(self.get_artists_from_player(similar))
252             if current.artist in ret_extra:
253                 ret_extra.remove(current.artist)
254         return ret_extra
255
256     def get_local_similar_artists(self):
257         """Check against local player for similar artists fetched from last.fm
258         """
259         current = self.player.current
260         self.log.info('Looking for artist similar to "{0.artist}"'.format(current))
261         similar = self.lfm_similar_artists()
262         if not similar:
263             self.log.info('Got nothing from last.fm!')
264             return []
265         similar = sorted(similar, key=lambda sim: sim[1], reverse=True)
266         self.log.info('First five similar artist(s): {}...'.format(
267                       ' / '.join([a for a, m in similar[0:5]])))
268         self.log.info('Looking availability in music library')
269         ret = self.get_artists_from_player(similar)
270         ret_extra = None
271         if len(self.history) >= 2:
272             ret_extra = self.get_recursive_similar_artist()
273         if not ret:
274             self.log.warning('Got nothing from music library.')
275             self.log.warning('Try running in debug mode to guess why...')
276             return []
277         if ret_extra:
278             ret = list(set(ret) | set(ret_extra))
279         self.log.info('Got {} artists in library'.format(len(ret)))
280         self.log.info(' / '.join(ret))
281         # Move around similars items to get in unplayed|not recently played
282         # artist first.
283         return self._get_artists_list_reorg(ret)
284
285     def _detects_var_artists_album(self, album, artist):
286         """Detects either an album is a "Various Artists" or a
287         single artist release."""
288         art_first_track = None
289         for track in self.player.find_album(artist, album):
290             if not art_first_track:  # set artist for the first track
291                 art_first_track = track.artist
292             alb_art = track.albumartist
293             #  Special heuristic used when AlbumArtist is available
294             if (alb_art):
295                 if artist == alb_art:
296                     # When album artist field is similar to the artist we're
297                     # looking an album for, the album is considered good to
298                     # queue
299                     return False
300                 else:
301                     self.log.debug(track)
302                     self.log.debug('album art says "%s", looking for "%s",'
303                                    ' not queueing this album' %
304                                    (alb_art, artist))
305                     return True
306         return False
307
308     def _get_album_history(self, artist=None):
309         """Retrieve album history"""
310         duration = self.daemon_conf.getint('sima', 'history_duration')
311         albums_list = set()
312         for trk in self.sdb.get_history(artist=artist, duration=duration):
313             albums_list.add(trk[1])
314         return albums_list
315
316     def find_album(self, artists):
317         """Find albums to queue.
318         """
319         self.to_add = list()
320         nb_album_add = 0
321         target_album_to_add = int(self.plugin_conf.get('album_to_add'))
322         for artist in artists:
323             self.log.info('Looking for an album to add for "%s"...' % artist)
324             albums = set(self.player.find_albums(artist))
325             # albums yet in history for this artist
326             albums_yet_in_hist = albums & self._get_album_history(artist=artist)
327             albums_not_in_hist = list(albums - albums_yet_in_hist)
328             # Get to next artist if there are no unplayed albums
329             if not albums_not_in_hist:
330                 self.log.info('No album found for "%s"' % artist)
331                 continue
332             album_to_queue = str()
333             random.shuffle(albums_not_in_hist)
334             for album in albums_not_in_hist:
335                 tracks = self.player.find('album', album)
336                 if self._detects_var_artists_album(album, artist):
337                     continue
338                 if tracks and self.sdb.get_bl_album(tracks[0], add_not=True):
339                     self.log.info('Blacklisted album: "%s"' % album)
340                     self.log.debug('using track: "%s"' % tracks[0])
341                     continue
342                 # Look if one track of the album is already queued
343                 # Good heuristic, at least enough to guess if the whole album is
344                 # already queued.
345                 if tracks[0] in self.player.queue:
346                     self.log.debug('"%s" already queued, skipping!' %
347                             tracks[0].album)
348                     continue
349                 album_to_queue = album
350             if not album_to_queue:
351                 self.log.info('No album found for "%s"' % artist)
352                 continue
353             self.log.info('last.fm album candidate: {0} - {1}'.format(
354                            artist, album_to_queue))
355             nb_album_add += 1
356             self.to_add.extend(self.player.find_album(artist, album_to_queue))
357             if nb_album_add == target_album_to_add:
358                 return True
359
360     def _track(self):
361         """Get some tracks for track queue mode
362         """
363         artists = self.get_local_similar_artists()
364         nbtracks_target = int(self.plugin_conf.get('track_to_add'))
365         for artist in artists:
366             self.log.debug('Trying to find titles to add for "{}"'.format(
367                            artist))
368             found = self.player.find_track(artist)
369             # find tracks not in history for artist
370             self.filter_track(found)
371             if len(self.to_add) == nbtracks_target:
372                 break
373         if not self.to_add:
374             self.log.debug('Found no tracks to queue, is your ' +
375                             'history getting too large?')
376             return None
377         for track in self.to_add:
378             self.log.info('last.fm candidate: {0!s}'.format(track))
379
380     def _album(self):
381         """Get albums for album queue mode
382         """
383         artists = self.get_local_similar_artists()
384         self.find_album(artists)
385
386     def _top(self):
387         """Get some tracks for top track queue mode
388         """
389         #artists = self.get_local_similar_artists()
390         pass
391
392     def callback_need_track(self):
393         self._cleanup_cache()
394         if not self.player.current:
395             self.log.info('Not currently playing track, cannot queue')
396             return None
397         self.queue_mode()
398         candidates = self.to_add
399         self.to_add = list()
400         if self.plugin_conf.get('queue_mode') != 'album':
401             random.shuffle(candidates)
402         return candidates
403
404     def callback_player_database(self):
405         self._flush_cache()
406
407 # VIM MODLINE
408 # vim: ai ts=4 sw=4 sts=4 expandtab