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):
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:
182 self.log.warning('{}: {}'.format(self.ws.name, err))
184 self.log.debug('Trying without MusicBrainzID')
186 return self.ws_similar_artists(Artist(name=artist.name))
187 except WSNotFound as err:
188 self.log.debug('{}: {}'.format(self.ws.name, err))
189 except WSError as err:
190 self.log.warning('{}: {}'.format(self.ws.name, err))
192 self.log.debug('Fetched {} artist(s)'.format(len(as_art)))
195 def get_recursive_similar_artist(self):
196 if not self.player.playlist:
198 history = list(self.history)
199 history = self.player.queue + history
200 history = deque(history)
201 last_trk = history.popleft() # remove
205 while depth < self.plugin_conf.getint('depth'):
206 if len(history) == 0:
208 trk = history.popleft()
209 if (trk.Artist in extra_arts
210 or trk.Artist == last_trk.Artist):
212 extra_arts.append(trk.Artist)
214 self.log.debug('EXTRA ARTS: {}'.format(
215 '/'.join(map(str, extra_arts))))
216 for artist in extra_arts:
217 self.log.debug('Looking for artist similar '
218 'to "{}" as well'.format(artist))
219 similar = self.ws_similar_artists(artist=artist)
222 ret_extra.extend(self.get_artists_from_player(similar))
224 if last_trk.Artist in ret_extra:
225 ret_extra.remove(last_trk.Artist)
227 self.log.debug('similar artist(s) found: {}'.format(
228 ' / '.join(map(str, MetaContainer(ret_extra)))))
231 def get_local_similar_artists(self):
232 """Check against local player for similar artists
234 if not self.player.playlist:
236 tolookfor = self.player.playlist[-1].Artist
237 self.log.info('Looking for artist similar to "{}"'.format(tolookfor))
238 self.log.debug(repr(tolookfor))
239 similar = self.ws_similar_artists(tolookfor)
241 self.log.info('Got nothing from {0}!'.format(self.ws.name))
243 self.log.info('First five similar artist(s): {}...'.format(
244 ' / '.join(map(str, list(similar)[:5]))))
245 self.log.info('Looking availability in music library')
246 ret = MetaContainer(self.get_artists_from_player(similar))
248 self.log.debug('regular found in library: {}'.format(
249 ' / '.join(map(str, ret))))
251 self.log.debug('Got nothing similar from library!')
253 if len(self.history) >= 2:
254 if self.plugin_conf.getint('depth') > 1:
255 ret_extra = self.get_recursive_similar_artist()
257 # get them reorg to pick up best element
258 ret_extra = self._get_artists_list_reorg(ret_extra)
259 # tries to pickup less artist from extra art
261 ret_extra = MetaContainer(ret_extra)
263 ret_extra = MetaContainer(ret_extra[:max(4, len(ret))//2])
265 self.log.debug('extra found in library: {}'.format(
266 ' / '.join(map(str, ret_extra))))
267 ret = ret | ret_extra
269 self.log.warning('Got nothing from music library.')
271 queued_artists = MetaContainer([trk.Artist for trk in self.player.queue])
272 self.log.trace('Already queued: {}'.format(queued_artists))
273 self.log.trace('Candidate: {}'.format(ret))
274 if ret & queued_artists:
275 self.log.debug('Removing already queued artists: '
276 '{0}'.format('/'.join(map(str, ret & queued_artists))))
277 ret = ret - queued_artists
278 if self.player.current and self.player.current.Artist in ret:
279 self.log.debug('Removing current artist: {0}'.format(self.player.current.Artist))
280 ret = ret - MetaContainer([self.player.current.Artist])
281 # Move around similars items to get in unplayed|not recently played
283 self.log.info('Got {} artists in library'.format(len(ret)))
284 candidates = self._get_artists_list_reorg(list(ret))
286 self.log.info(' / '.join(map(str, candidates)))
289 def _get_album_history(self, artist=None):
290 """Retrieve album history"""
291 duration = self.daemon_conf.getint('sima', 'history_duration')
293 for trk in self.sdb.get_history(artist=artist.name, duration=duration):
294 albums_list.add(trk[1])
297 def find_album(self, artists):
298 """Find albums to queue.
302 target_album_to_add = self.plugin_conf.getint('album_to_add')
303 for artist in artists:
304 self.log.info('Looking for an album to add for "%s"...' % artist)
305 albums = self.player.search_albums(artist)
306 # str conversion while Album type is not propagated
307 albums = [str(album) for album in albums]
309 self.log.debug('Albums candidate: {0:s}'.format(
312 # albums yet in history for this artist
314 albums_yet_in_hist = albums & self._get_album_history(artist=artist)
315 albums_not_in_hist = list(albums - albums_yet_in_hist)
316 # Get to next artist if there are no unplayed albums
317 if not albums_not_in_hist:
318 self.log.info('No album found for "%s"' % artist)
320 album_to_queue = str()
321 random.shuffle(albums_not_in_hist)
322 for album in albums_not_in_hist:
323 tracks = self.player.find_album(artist, album)
324 # Look if one track of the album is already queued
325 # Good heuristic, at least enough to guess if the whole album is
327 if tracks[0] in self.player.queue:
328 self.log.debug('"%s" already queued, skipping!' %
331 album_to_queue = album
332 if not album_to_queue:
333 self.log.info('No album found for "%s"' % artist)
335 self.log.info('{2} album candidate: {0} - {1}'.format(
336 artist, album_to_queue, self.ws.name))
338 self.to_add.extend(self.player.find_album(artist, album_to_queue))
339 if nb_album_add == target_album_to_add:
342 def find_top(self, artists):
344 find top tracks for artists in artists list.
347 nbtracks_target = self.plugin_conf.getint('track_to_add')
348 for artist in artists:
349 if len(self.to_add) == nbtracks_target:
351 self.log.info('Looking for a top track for {0}'.format(artist))
354 titles = [t for t in self.ws.get_toptrack(artist)]
355 except WSError as err:
356 self.log.warning('{0}: {1}'.format(self.ws.name, err))
358 found = self.player.fuzzy_find_track(artist, trk.title)
359 random.shuffle(found)
361 self.log.debug('{0}'.format(found[0]))
362 if self.filter_track(found):
366 """Get some tracks for track queue mode
368 artists = self.get_local_similar_artists()
369 nbtracks_target = self.plugin_conf.getint('track_to_add')
370 for artist in artists:
371 self.log.debug('Trying to find titles to add for "{!r}"'.format(
373 found = self.player.find_track(artist)
374 random.shuffle(found)
376 self.log.debug('Found nothing to queue for {0}'.format(artist))
378 # find tracks not in history for artist
379 self.filter_track(found)
380 if len(self.to_add) == nbtracks_target:
383 self.log.debug('Found no tracks to queue!')
385 for track in self.to_add:
386 self.log.info('{1} candidates: {0!s}'.format(track, self.ws.name))
389 """Get albums for album queue mode
391 artists = self.get_local_similar_artists()
392 self.find_album(artists)
395 """Get some tracks for top track queue mode
397 artists = self.get_local_similar_artists()
398 self.find_top(artists)
399 for track in self.to_add:
400 self.log.info('{1} candidates: {0!s}'.format(track, self.ws.name))
402 def callback_need_track(self):
403 self._cleanup_cache()
404 if len(self.player.playlist) == 0:
405 self.log.info('No last track, cannot queue')
407 if not self.player.playlist[-1].artist:
408 self.log.warning('No artist set for the last track in queue')
409 self.log.debug(repr(self.player.current))
412 msg = ' '.join(['{0}: {1:>3d}'.format(k, v) for
413 k, v in sorted(self.ws.stats.items())])
414 self.log.debug('http stats: ' + msg)
415 candidates = self.to_add
417 if self.plugin_conf.get('queue_mode') != 'album':
418 random.shuffle(candidates)
421 def callback_player_database(self):
425 # vim: ai ts=4 sw=4 sts=4 expandtab