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 if ret & queued_artists:
273 self.log.debug('Removing already queued artists: '
274 '{0}'.format('/'.join(map(str, ret & queued_artists))))
275 ret = ret - queued_artists
276 if self.player.current and self.player.current.Artist in ret:
277 self.log.debug('Removing current artist: {0}'.format(self.player.current.Artist))
278 ret = ret - MetaContainer([self.player.current.Artist])
279 # Move around similars items to get in unplayed|not recently played
281 self.log.info('Got {} artists in library'.format(len(ret)))
282 candidates = self._get_artists_list_reorg(list(ret))
284 self.log.info(' / '.join(map(str, candidates)))
287 def _get_album_history(self, artist=None):
288 """Retrieve album history"""
289 duration = self.daemon_conf.getint('sima', 'history_duration')
291 for trk in self.sdb.get_history(artist=artist.name, duration=duration):
292 albums_list.add(trk[1])
295 def find_album(self, artists):
296 """Find albums to queue.
300 target_album_to_add = self.plugin_conf.getint('album_to_add')
301 for artist in artists:
302 self.log.info('Looking for an album to add for "%s"...' % artist)
303 albums = self.player.search_albums(artist)
304 # str conversion while Album type is not propagated
305 albums = [str(album) for album in albums]
307 self.log.debug('Albums candidate: {0:s}'.format(
310 # albums yet in history for this artist
312 albums_yet_in_hist = albums & self._get_album_history(artist=artist)
313 albums_not_in_hist = list(albums - albums_yet_in_hist)
314 # Get to next artist if there are no unplayed albums
315 if not albums_not_in_hist:
316 self.log.info('No album found for "%s"' % artist)
318 album_to_queue = str()
319 random.shuffle(albums_not_in_hist)
320 for album in albums_not_in_hist:
321 tracks = self.player.find_album(artist, album)
322 # Look if one track of the album is already queued
323 # Good heuristic, at least enough to guess if the whole album is
325 if tracks[0] in self.player.queue:
326 self.log.debug('"%s" already queued, skipping!' %
329 album_to_queue = album
330 if not album_to_queue:
331 self.log.info('No album found for "%s"' % artist)
333 self.log.info('{2} album candidate: {0} - {1}'.format(
334 artist, album_to_queue, self.ws.name))
336 self.to_add.extend(self.player.find_album(artist, album_to_queue))
337 if nb_album_add == target_album_to_add:
340 def find_top(self, artists):
342 find top tracks for artists in artists list.
345 nbtracks_target = self.plugin_conf.getint('track_to_add')
346 for artist in artists:
347 if len(self.to_add) == nbtracks_target:
349 self.log.info('Looking for a top track for {0}'.format(artist))
352 titles = [t for t in self.ws.get_toptrack(artist)]
353 except WSError as err:
354 self.log.warning('{0}: {1}'.format(self.ws.name, err))
356 found = self.player.fuzzy_find_track(artist, trk.title)
357 random.shuffle(found)
359 self.log.debug('{0}'.format(found[0]))
360 if self.filter_track(found):
364 """Get some tracks for track queue mode
366 artists = self.get_local_similar_artists()
367 nbtracks_target = self.plugin_conf.getint('track_to_add')
368 for artist in artists:
369 self.log.debug('Trying to find titles to add for "{}"'.format(
371 found = self.player.find_track(artist)
372 random.shuffle(found)
374 self.log.debug('Found nothing to queue for {0}'.format(artist))
376 # find tracks not in history for artist
377 self.filter_track(found)
378 if len(self.to_add) == nbtracks_target:
381 self.log.debug('Found no tracks to queue!')
383 for track in self.to_add:
384 self.log.info('{1} candidates: {0!s}'.format(track, self.ws.name))
387 """Get albums for album queue mode
389 artists = self.get_local_similar_artists()
390 self.find_album(artists)
393 """Get some tracks for top track queue mode
395 artists = self.get_local_similar_artists()
396 self.find_top(artists)
397 for track in self.to_add:
398 self.log.info('{1} candidates: {0!s}'.format(track, self.ws.name))
400 def callback_need_track(self):
401 self._cleanup_cache()
402 if len(self.player.playlist) == 0:
403 self.log.info('No last track, cannot queue')
405 if not self.player.playlist[-1].artist:
406 self.log.warning('No artist set for the last track in queue')
407 self.log.debug(repr(self.player.current))
410 msg = ' '.join(['{0}: {1:>3d}'.format(k, v) for
411 k, v in sorted(self.ws.stats.items())])
412 self.log.debug('http stats: ' + msg)
413 candidates = self.to_add
415 if self.plugin_conf.get('queue_mode') != 'album':
416 random.shuffle(candidates)
419 def callback_player_database(self):
423 # vim: ai ts=4 sw=4 sts=4 expandtab