1 # -*- coding: utf-8 -*-
2 # Copyright (c) 2009-2015 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 self.log.debug('Got nothing similar from library!')
249 if len(self.history) >= 2:
250 if self.plugin_conf.getint('depth') > 1:
251 ret_extra = self.get_recursive_similar_artist()
253 # get them reorg to pick up best element
254 ret_extra = self._get_artists_list_reorg(ret_extra)
255 # tries to pickup less artist from extra art
257 ret_extra = MetaContainer(ret_extra)
259 ret_extra = MetaContainer(ret_extra[:max(4, len(ret))//2])
261 self.log.debug('extra found in library: {}'.format(
262 ' / '.join(map(str, ret_extra))))
263 ret = ret | ret_extra
265 self.log.warning('Got nothing from music library.')
268 # * operation on set will not match against aliases
269 # * composite set w/ mbid set and whitout won't match either
270 queued_artists = MetaContainer([trk.Artist for trk in self.player.queue])
271 if ret & queued_artists:
272 self.log.debug('Removing already queued artists: '
273 '{0}'.format('/'.join(map(str, ret & queued_artists))))
274 ret = ret - queued_artists
275 if self.player.current and self.player.current.Artist in ret:
276 self.log.debug('Removing current artist: {0}'.format(self.player.current.Artist))
277 ret = ret - MetaContainer([self.player.current.Artist])
278 # Move around similars items to get in unplayed|not recently played
280 self.log.info('Got {} artists in library'.format(len(ret)))
281 candidates = self._get_artists_list_reorg(list(ret))
283 self.log.info(' / '.join(map(str, candidates)))
286 def _get_album_history(self, artist=None):
287 """Retrieve album history"""
288 duration = self.daemon_conf.getint('sima', 'history_duration')
290 for trk in self.sdb.get_history(artist=artist.name, duration=duration):
291 albums_list.add(trk[1])
294 def find_album(self, artists):
295 """Find albums to queue.
299 target_album_to_add = self.plugin_conf.getint('album_to_add')
300 for artist in artists:
301 self.log.info('Looking for an album to add for "%s"...' % artist)
302 albums = self.player.search_albums(artist)
303 # str conversion while Album type is not propagated
304 albums = [str(album) for album in albums]
306 self.log.debug('Albums candidate: {0:s}'.format(
309 # albums yet in history for this artist
311 albums_yet_in_hist = albums & self._get_album_history(artist=artist)
312 albums_not_in_hist = list(albums - albums_yet_in_hist)
313 # Get to next artist if there are no unplayed albums
314 if not albums_not_in_hist:
315 self.log.info('No album found for "%s"' % artist)
317 album_to_queue = str()
318 random.shuffle(albums_not_in_hist)
319 for album in albums_not_in_hist:
320 tracks = self.player.find_album(artist, album)
321 # Look if one track of the album is already queued
322 # Good heuristic, at least enough to guess if the whole album is
324 if tracks[0] in self.player.queue:
325 self.log.debug('"%s" already queued, skipping!' %
328 album_to_queue = album
329 if not album_to_queue:
330 self.log.info('No album found for "%s"' % artist)
332 self.log.info('{2} album candidate: {0} - {1}'.format(
333 artist, album_to_queue, self.ws.name))
335 self.to_add.extend(self.player.find_album(artist, album_to_queue))
336 if nb_album_add == target_album_to_add:
339 def find_top(self, artists):
341 find top tracks for artists in artists list.
344 nbtracks_target = self.plugin_conf.getint('track_to_add')
345 for artist in artists:
346 if len(self.to_add) == nbtracks_target:
348 self.log.info('Looking for a top track for {0}'.format(artist))
351 titles = [t for t in self.ws.get_toptrack(artist)]
352 except WSError as err:
353 self.log.warning('{0}: {1}'.format(self.ws.name, err))
355 found = self.player.fuzzy_find_track(artist, trk.title)
356 random.shuffle(found)
358 self.log.debug('{0}'.format(found[0]))
359 if self.filter_track(found):
363 """Get some tracks for track queue mode
365 artists = self.get_local_similar_artists()
366 nbtracks_target = self.plugin_conf.getint('track_to_add')
367 for artist in artists:
368 self.log.debug('Trying to find titles to add for "{}"'.format(
370 found = self.player.find_track(artist)
371 random.shuffle(found)
373 self.log.debug('Found nothing to queue for {0}'.format(artist))
375 # find tracks not in history for artist
376 self.filter_track(found)
377 if len(self.to_add) == nbtracks_target:
380 self.log.debug('Found no tracks to queue!')
382 for track in self.to_add:
383 self.log.info('{1} candidates: {0!s}'.format(track, self.ws.name))
386 """Get albums for album queue mode
388 artists = self.get_local_similar_artists()
389 self.find_album(artists)
392 """Get some tracks for top track queue mode
394 artists = self.get_local_similar_artists()
395 self.find_top(artists)
396 for track in self.to_add:
397 self.log.info('{1} candidates: {0!s}'.format(track, self.ws.name))
399 def callback_need_track(self):
400 self._cleanup_cache()
401 if len(self.player.playlist) == 0:
402 self.log.info('No last track, cannot queue')
404 if not self.player.playlist[-1].artist:
405 self.log.warning('No artist set for the last track in queue')
406 self.log.debug(repr(self.player.current))
409 msg = ' '.join(['{0}: {1:>3d}'.format(k, v) for
410 k, v in sorted(self.ws.stats.items())])
411 self.log.debug('http stats: ' + msg)
412 candidates = self.to_add
414 if self.plugin_conf.get('queue_mode') != 'album':
415 random.shuffle(candidates)
418 def callback_player_database(self):
422 # vim: ai ts=4 sw=4 sts=4 expandtab