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
60 # pylint: disable=bad-builtin
62 def __init__(self, daemon):
63 Plugin.__init__(self, daemon)
64 self.daemon_conf = daemon.config
66 self.history = daemon.short_history
71 wrapper = {'track': self._track,
73 'album': self._album,}
74 self.queue_mode = wrapper.get(self.plugin_conf.get('queue_mode'))
77 def _flush_cache(self):
79 Both flushes and instanciates _cache
81 name = self.__class__.__name__
82 if isinstance(self._cache, dict):
83 self.log.info('{0}: Flushing cache!'.format(name))
85 self.log.info('{0}: Initialising cache!'.format(name))
86 self._cache = {'asearch': dict(),
89 def _cleanup_cache(self):
90 """Avoid bloated cache
92 for _, val in self._cache.items():
93 if isinstance(val, dict):
97 def get_history(self, artist):
98 """Constructs list of Track for already played titles for an artist.
100 duration = self.daemon_conf.getint('sima', 'history_duration')
101 tracks_from_db = self.sdb.get_history(duration=duration, artist=artist)
102 # Construct Track() objects list from database history
103 played_tracks = [Track(artist=tr[-1], album=tr[1], title=tr[2],
104 file=tr[3]) for tr in tracks_from_db]
107 def filter_track(self, tracks):
109 Extract one unplayed track from a Track object list.
111 * not already in the queue
114 artist = tracks[0].artist
115 if self.player.playmode.get('random'):
116 black_list = self.player.playlist + self.to_add
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 "%s"', 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'): # pylint: disable=no-member
127 if (trk.album == self.player.current.album or
128 trk.album in [tr.album for tr in black_list]):
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') # pylint: disable=no-member
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 """Check against local player for similar artists (recursive w/ history)
198 if not self.player.playlist:
200 history = list(self.history)
201 if self.player.playmode.get('random'):
202 history = self.player.playlist + history
204 history = self.player.queue + history
205 history = deque(history)
206 last_trk = history.popleft() # remove
210 while depth < self.plugin_conf.getint('depth'): # pylint: disable=no-member
211 if len(history) == 0:
213 trk = history.popleft()
214 if (trk.Artist in extra_arts
215 or trk.Artist == last_trk.Artist):
217 extra_arts.append(trk.Artist)
219 self.log.debug('EXTRA ARTS: %s', '/'.join(map(str, extra_arts)))
220 for artist in extra_arts:
221 self.log.debug('Looking for artist similar '
222 'to "{}" as well'.format(artist))
223 similar = self.ws_similar_artists(artist=artist)
226 ret_extra.extend(self.get_artists_from_player(similar))
228 if last_trk.Artist in ret_extra:
229 ret_extra.remove(last_trk.Artist)
231 self.log.debug('similar artist(s) found: %s',
232 ' / '.join(map(str, MetaContainer(ret_extra))))
235 def get_local_similar_artists(self):
236 """Check against local player for similar artists
238 if not self.player.playlist:
240 tolookfor = self.player.playlist[-1].Artist
241 self.log.info('Looking for artist similar to "{}"'.format(tolookfor))
242 self.log.debug(repr(tolookfor))
243 similar = self.ws_similar_artists(tolookfor)
245 self.log.info('Got nothing from {0}!'.format(self.ws.name))
247 self.log.info('First five similar artist(s): %s...',
248 ' / '.join(map(str, list(similar)[:5])))
249 self.log.info('Looking availability in music library')
250 ret = MetaContainer(self.get_artists_from_player(similar))
252 self.log.debug('regular found in library: %s',
253 ' / '.join(map(str, ret)))
255 self.log.debug('Got nothing similar from library!')
257 if len(self.history) >= 2:
258 if self.plugin_conf.getint('depth') > 1: # pylint: disable=no-member
259 ret_extra = self.get_recursive_similar_artist()
261 # get them reorg to pick up best element
262 ret_extra = self._get_artists_list_reorg(ret_extra)
263 # tries to pickup less artist from extra art
265 ret_extra = MetaContainer(ret_extra)
267 ret_extra = MetaContainer(ret_extra[:max(4, len(ret))//2])
269 self.log.debug('extra found in library: %s',
270 ' / '.join(map(str, ret_extra)))
271 ret = ret | ret_extra
273 self.log.warning('Got nothing from music library.')
275 if self.player.playmode.get('random'):
276 queued_artists = MetaContainer([trk.Artist for trk in self.player.playlist])
278 queued_artists = MetaContainer([trk.Artist for trk in self.player.queue])
279 self.log.trace('Already queued: {}'.format(queued_artists))
280 self.log.trace('Candidate: {}'.format(ret))
281 if ret & queued_artists:
282 self.log.debug('Removing already queued artists: '
283 '{0}'.format('/'.join(map(str, ret & queued_artists))))
284 ret = ret - queued_artists
285 if self.player.current and self.player.current.Artist in ret:
286 self.log.debug('Removing current artist: {0}'.format(self.player.current.Artist))
287 ret = ret - MetaContainer([self.player.current.Artist])
288 # Move around similars items to get in unplayed|not recently played
290 self.log.info('Got {} artists in library'.format(len(ret)))
291 candidates = self._get_artists_list_reorg(list(ret))
293 self.log.info(' / '.join(map(str, candidates)))
296 def _get_album_history(self, artist=None):
297 """Retrieve album history"""
298 duration = self.daemon_conf.getint('sima', 'history_duration')
300 for trk in self.sdb.get_history(artist=artist.name, duration=duration):
301 albums_list.add(trk[1])
304 def find_album(self, artists):
305 """Find albums to queue.
309 target_album_to_add = self.plugin_conf.getint('album_to_add') # pylint: disable=no-member
310 for artist in artists:
311 self.log.info('Looking for an album to add for "%s"...' % artist)
312 albums = self.player.search_albums(artist)
313 # str conversion while Album type is not propagated
314 albums = [str(album) for album in albums]
316 self.log.debug('Albums candidate: %s', ' / '.join(albums))
318 # albums yet in history for this artist
320 albums_yet_in_hist = albums & self._get_album_history(artist=artist)
321 albums_not_in_hist = list(albums - albums_yet_in_hist)
322 # Get to next artist if there are no unplayed albums
323 if not albums_not_in_hist:
324 self.log.info('No unplayed album found for "%s"' % artist)
326 album_to_queue = str()
327 random.shuffle(albums_not_in_hist)
328 for album in albums_not_in_hist:
329 tracks = self.player.find_album(artist, album)
330 # Look if one track of the album is already queued
331 # Good heuristic, at least enough to guess if the whole album is
333 if tracks[0] in self.player.queue:
334 self.log.debug('"%s" already queued, skipping!', tracks[0].album)
336 if tracks[0] in self.player.playlist:
337 if self.player.playmode.get('random'):
338 self.log.debug('"%s" already in playlist, skipping!', tracks[0].album)
340 album_to_queue = album
341 if not album_to_queue:
342 self.log.info('No album found for "%s"', artist)
344 self.log.info('%s album candidate: %s - %s', self.ws.name, artist, album_to_queue)
346 self.to_add.extend(self.player.find_album(artist, album_to_queue))
347 if nb_album_add == target_album_to_add:
350 def find_top(self, artists):
352 find top tracks for artists in artists list.
355 nbtracks_target = self.plugin_conf.getint('track_to_add') # pylint: disable=no-member
356 for artist in artists:
357 if len(self.to_add) == nbtracks_target:
359 self.log.info('Looking for a top track for {0}'.format(artist))
362 titles = [t for t in self.ws.get_toptrack(artist)]
363 except WSError as err:
364 self.log.warning('%s: %s', self.ws.name, err)
366 found = self.player.fuzzy_find_track(artist, trk.title)
367 random.shuffle(found)
369 self.log.debug('%s', found[0])
370 if self.filter_track(found):
374 """Get some tracks for track queue mode
376 artists = self.get_local_similar_artists()
377 nbtracks_target = self.plugin_conf.getint('track_to_add') # pylint: disable=no-member
378 for artist in artists:
379 self.log.debug('Trying to find titles to add for "%r"', artist)
380 found = self.player.find_track(artist)
381 random.shuffle(found)
383 self.log.debug('Found nothing to queue for {0}'.format(artist))
385 # find tracks not in history for artist
386 self.filter_track(found)
387 if len(self.to_add) == nbtracks_target:
390 self.log.debug('Found no tracks to queue!')
392 for track in self.to_add:
393 self.log.info('{1} candidates: {0!s}'.format(track, self.ws.name))
396 """Get albums for album queue mode
398 artists = self.get_local_similar_artists()
399 self.find_album(artists)
402 """Get some tracks for top track queue mode
404 artists = self.get_local_similar_artists()
405 self.find_top(artists)
406 for track in self.to_add:
407 self.log.info('{1} candidates: {0!s}'.format(track, self.ws.name))
409 def callback_need_track(self):
410 self._cleanup_cache()
411 if len(self.player.playlist) == 0:
412 self.log.info('No last track, cannot queue')
414 if not self.player.playlist[-1].artist:
415 self.log.warning('No artist set for the last track in queue')
416 self.log.debug(repr(self.player.current))
419 msg = ' '.join(['{0}: {1:>3d}'.format(k, v) for
420 k, v in sorted(self.ws.stats.items())])
421 self.log.debug('http stats: ' + msg)
422 candidates = self.to_add
424 if self.plugin_conf.get('queue_mode') != 'album':
425 random.shuffle(candidates)
428 def callback_player_database(self):
432 # vim: ai ts=4 sw=4 sts=4 expandtab