1 # -*- coding: utf-8 -*-
2 # Copyright (c) 2009-2014 Jack Kaliko <kaliko@azylum.org>
4 # This file is part of sima
6 # sima is free software: you can redistribute it and/or modify
7 # it under the terms of the GNU General Public License as published by
8 # the Free Software Foundation, either version 3 of the License, or
9 # (at your option) any later version.
11 # sima is distributed in the hope that it will be useful,
12 # but WITHOUT ANY WARRANTY; without even the implied warranty of
13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 # GNU General Public License for more details.
16 # You should have received a copy of the GNU General Public License
17 # along with sima. If not, see <http://www.gnu.org/licenses/>.
21 Fetching similar artists from last.fm web services
24 # standard library import
27 from collections import deque
28 from hashlib import md5
30 # third parties components
33 from .plugin import Plugin
34 from .track import Track
35 from .meta import Artist, MetaContainer
36 from ..utils.utils import WSError, WSNotFound
39 """Caching decorator"""
40 def wrapper(*args, **kwargs):
41 #pylint: disable=W0212,C0111
43 similarities = [art.name for art in args[1]]
44 hashedlst = md5(''.join(similarities).encode('utf-8')).hexdigest()
45 if hashedlst in cls._cache.get('asearch'):
46 cls.log.debug('cached request')
47 results = cls._cache.get('asearch').get(hashedlst)
49 results = func(*args, **kwargs)
50 cls.log.debug('caching request')
51 cls._cache.get('asearch').update({hashedlst:list(results)})
52 random.shuffle(results)
57 class WebService(Plugin):
58 """similar artists webservice
61 def __init__(self, daemon):
62 Plugin.__init__(self, daemon)
63 self.daemon_conf = daemon.config
65 self.history = daemon.short_history
75 self.queue_mode = wrapper.get(self.plugin_conf.get('queue_mode'))
78 def _flush_cache(self):
80 Both flushes and instanciates _cache
82 name = self.__class__.__name__
83 if isinstance(self._cache, dict):
84 self.log.info('{0}: Flushing cache!'.format(name))
86 self.log.info('{0}: Initialising cache!'.format(name))
92 def _cleanup_cache(self):
93 """Avoid bloated cache
95 for _, val in self._cache.items():
96 if isinstance(val, dict):
100 def get_history(self, artist):
101 """Constructs list of Track for already played titles for an artist.
103 duration = self.daemon_conf.getint('sima', 'history_duration')
104 tracks_from_db = self.sdb.get_history(duration=duration, artist=artist)
105 # Construct Track() objects list from database history
106 played_tracks = [Track(artist=tr[-1], album=tr[1], title=tr[2],
107 file=tr[3]) for tr in tracks_from_db]
110 def filter_track(self, tracks):
112 Extract one unplayed track from a Track object list.
114 * not already in the queue
117 artist = tracks[0].artist
118 black_list = self.player.queue + self.to_add
119 not_in_hist = list(set(tracks) - set(self.get_history(artist=artist)))
120 if self.plugin_conf.get('queue_mode') != 'top' and not not_in_hist:
121 self.log.debug('All tracks already played for "{}"'.format(artist))
122 random.shuffle(not_in_hist)
124 for trk in [_ for _ in not_in_hist if _ not in black_list]:
125 # Should use albumartist heuristic as well
126 if self.plugin_conf.getboolean('single_album'):
127 if (trk.album == self.player.current.album or
128 trk.album in [tr.album for tr in self.to_add]):
129 self.log.debug('Found unplayed track ' +
130 'but from an album already queued: %s' % (trk))
132 candidate.append(trk)
135 self.to_add.append(random.choice(candidate))
138 def _get_artists_list_reorg(self, alist):
140 Move around items in artists_list in order to play first not recently
144 duration = self.daemon_conf.getint('sima', 'history_duration')
145 for art in self.sdb.get_artists_history(alist, duration=duration):
148 reorg = [art for art in alist if art not in hist]
153 def get_artists_from_player(self, similarities):
155 Look in player library for availability of similar artists in
158 dynamic = self.plugin_conf.getint('max_art')
162 similarities.reverse()
163 while (len(results) < dynamic
164 and len(similarities) > 0):
165 art_pop = similarities.pop()
166 res = self.player.search_artist(art_pop)
171 def ws_similar_artists(self, artist=None):
173 Retrieve similar artists from WebServive.
175 # initialize artists deque list to construct from DB
177 as_artists = self.ws.get_similar(artist=artist)
178 self.log.debug('Requesting {} for {!r}'.format(self.ws.name, artist))
180 [as_art.append(art) for art in as_artists]
181 except WSNotFound as err:
183 return self.ws_similar_artists(Artist(name=artist.name))
184 self.log.warning('{}: {}'.format(self.ws.name, err))
185 except WSError as err:
186 self.log.warning('{}: {}'.format(self.ws.name, err))
188 self.log.debug('Fetched {} artist(s)'.format(len(as_art)))
191 def get_recursive_similar_artist(self):
192 if not self.player.playlist:
194 history = list(self.history)
195 history = self.player.queue + history
196 history = deque(history)
197 last_trk = history.popleft() # remove
201 while depth < self.plugin_conf.getint('depth'):
202 if len(history) == 0:
204 trk = history.popleft()
205 if (trk.Artist in extra_arts
206 or trk.Artist == last_trk.Artist):
208 extra_arts.append(trk.Artist)
210 self.log.debug('EXTRA ARTS: {}'.format(
211 '/'.join(map(str, extra_arts))))
212 for artist in extra_arts:
213 self.log.debug('Looking for artist similar '
214 'to "{}" as well'.format(artist))
215 similar = self.ws_similar_artists(artist=artist)
218 ret_extra.extend(self.get_artists_from_player(similar))
220 if last_trk.Artist in ret_extra:
221 ret_extra.remove(last_trk.Artist)
223 self.log.debug('similar artist(s) found: {}'.format(
224 ' / '.join(map(str, MetaContainer(ret_extra)))))
227 def get_local_similar_artists(self):
228 """Check against local player for similar artists
230 if not self.player.playlist:
232 tolookfor = self.player.playlist[-1].Artist
233 self.log.info('Looking for artist similar to "{}"'.format(tolookfor))
234 self.log.debug(repr(tolookfor))
235 similar = self.ws_similar_artists(tolookfor)
237 self.log.info('Got nothing from {0}!'.format(self.ws.name))
239 self.log.info('First five similar artist(s): {}...'.format(
240 ' / '.join(map(str, list(similar)[:5]))))
241 self.log.info('Looking availability in music library')
242 ret = MetaContainer(self.get_artists_from_player(similar))
244 self.log.debug('regular found in library: {}'.format(
245 ' / '.join(map(str, ret))))
247 if len(self.history) >= 2:
248 if self.plugin_conf.getint('depth') > 1:
249 ret_extra = self.get_recursive_similar_artist()
251 # get them reorg to pick up best element
252 ret_extra = self._get_artists_list_reorg(ret_extra)
253 # pickup half the number of ret artist
254 ret_extra = MetaContainer(ret_extra[:max(4, len(ret))//2])
256 self.log.debug('extra found in library: {}'.format(
257 ' / '.join(map(str, ret_extra))))
258 ret = ret | ret_extra
260 self.log.warning('Got nothing from music library.')
263 # * operation on set will not match against aliases
264 # * composite set w/ mbid set and whitout won't match either
265 queued_artists = MetaContainer([trk.Artist for trk in self.player.queue])
266 if ret & queued_artists:
267 self.log.debug('Removing already queued artists: '
268 '{0}'.format('/'.join(map(str, ret & queued_artists))))
269 ret = ret - queued_artists
270 if self.player.current and self.player.current.Artist in ret:
271 self.log.debug('Removing current artist: {0}'.format(self.player.current.Artist))
272 ret = ret - MetaContainer([self.player.current.Artist])
273 # Move around similars items to get in unplayed|not recently played
275 self.log.info('Got {} artists in library'.format(len(ret)))
276 candidates = self._get_artists_list_reorg(list(ret))
278 self.log.info(' / '.join(map(str, candidates)))
281 def _get_album_history(self, artist=None):
282 """Retrieve album history"""
283 duration = self.daemon_conf.getint('sima', 'history_duration')
285 for trk in self.sdb.get_history(artist=artist.name, duration=duration):
286 albums_list.add(trk[1])
289 def find_album(self, artists):
290 """Find albums to queue.
294 target_album_to_add = self.plugin_conf.getint('album_to_add')
295 for artist in artists:
296 self.log.info('Looking for an album to add for "%s"...' % artist)
297 albums = self.player.search_albums(artist)
298 # str conversion while Album type is not propagated
299 albums = [str(album) for album in albums]
301 self.log.debug('Albums candidate: {0:s}'.format(
304 # albums yet in history for this artist
306 albums_yet_in_hist = albums & self._get_album_history(artist=artist)
307 albums_not_in_hist = list(albums - albums_yet_in_hist)
308 # Get to next artist if there are no unplayed albums
309 if not albums_not_in_hist:
310 self.log.info('No album found for "%s"' % artist)
312 album_to_queue = str()
313 random.shuffle(albums_not_in_hist)
314 for album in albums_not_in_hist:
315 tracks = self.player.find_album(artist, album)
316 # Look if one track of the album is already queued
317 # Good heuristic, at least enough to guess if the whole album is
319 if tracks[0] in self.player.queue:
320 self.log.debug('"%s" already queued, skipping!' %
323 album_to_queue = album
324 if not album_to_queue:
325 self.log.info('No album found for "%s"' % artist)
327 self.log.info('{2} album candidate: {0} - {1}'.format(
328 artist, album_to_queue, self.ws.name))
330 self.to_add.extend(self.player.find_album(artist, album_to_queue))
331 if nb_album_add == target_album_to_add:
334 def find_top(self, artists):
336 find top tracks for artists in artists list.
339 nbtracks_target = self.plugin_conf.getint('track_to_add')
340 for artist in artists:
341 artist = Artist(name=artist)
342 if len(self.to_add) == nbtracks_target:
344 self.log.info('Looking for a top track for {0}'.format(artist))
347 titles = [t for t in self.ws.get_toptrack(artist)]
348 except WSError as err:
349 self.log.warning('{0}: {1}'.format(self.ws.name, err))
351 found = self.player.fuzzy_find_track(artist, trk.title)
352 random.shuffle(found)
354 self.log.debug('{0}'.format(found[0]))
355 if self.filter_track(found):
359 """Get some tracks for track queue mode
361 artists = self.get_local_similar_artists()
362 nbtracks_target = self.plugin_conf.getint('track_to_add')
363 for artist in artists:
364 self.log.debug('Trying to find titles to add for "{}"'.format(
366 found = self.player.find_track(artist)
367 random.shuffle(found)
369 self.log.debug('Found nothing to queue for {0}'.format(artist))
371 # find tracks not in history for artist
372 self.filter_track(found)
373 if len(self.to_add) == nbtracks_target:
376 self.log.debug('Found no tracks to queue!')
378 for track in self.to_add:
379 self.log.info('{1} candidates: {0!s}'.format(track, self.ws.name))
382 """Get albums for album queue mode
384 artists = self.get_local_similar_artists()
385 self.find_album(artists)
388 """Get some tracks for top track queue mode
390 artists = self.get_local_similar_artists()
391 self.find_top(artists)
392 for track in self.to_add:
393 self.log.info('{1} candidates: {0!s}'.format(track, self.ws.name))
395 def callback_need_track(self):
396 self._cleanup_cache()
397 if len(self.player.playlist) == 0:
398 self.log.info('No last track, cannot queue')
400 if not self.player.playlist[-1].artist:
401 self.log.warning('No artist set for the last track in queue')
402 self.log.debug(repr(self.player.current))
405 msg = ' '.join(['{0}: {1:>3d}'.format(k, v) for
406 k, v in sorted(self.ws.stats.items())])
407 self.log.debug('http stats: ' + msg)
408 candidates = self.to_add
410 if self.plugin_conf.get('queue_mode') != 'album':
411 random.shuffle(candidates)
414 def callback_player_database(self):
418 # vim: ai ts=4 sw=4 sts=4 expandtab