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
70 wrapper = {'track': self._track,
72 'album': self._album,}
73 self.queue_mode = wrapper.get(self.plugin_conf.get('queue_mode'))
76 def _flush_cache(self):
78 Both flushes and instanciates _cache
80 name = self.__class__.__name__
81 if isinstance(self._cache, dict):
82 self.log.info('{0}: Flushing cache!'.format(name))
84 self.log.info('{0}: Initialising cache!'.format(name))
85 self._cache = {'asearch': dict(),
88 def _cleanup_cache(self):
89 """Avoid bloated cache
91 for _, val in self._cache.items():
92 if isinstance(val, dict):
96 def get_history(self, artist):
97 """Constructs list of Track for already played titles for an artist.
99 duration = self.daemon_conf.getint('sima', 'history_duration')
100 tracks_from_db = self.sdb.get_history(duration=duration, artist=artist)
101 # Construct Track() objects list from database history
102 played_tracks = [Track(artist=tr[-1], album=tr[1], title=tr[2],
103 file=tr[3]) for tr in tracks_from_db]
106 def filter_track(self, tracks):
108 Extract one unplayed track from a Track object list.
110 * not already in the queue
113 artist = tracks[0].artist
114 black_list = self.player.queue + self.to_add
115 not_in_hist = list(set(tracks) - set(self.get_history(artist=artist)))
116 if self.plugin_conf.get('queue_mode') != 'top' and not not_in_hist:
117 self.log.debug('All tracks already played for "{}"'.format(artist))
118 random.shuffle(not_in_hist)
120 for trk in [_ for _ in not_in_hist if _ not in black_list]:
121 # Should use albumartist heuristic as well
122 if self.plugin_conf.getboolean('single_album'): # pylint: disable=no-member
123 if (trk.album == self.player.current.album or
124 trk.album in [tr.album for tr in self.to_add]):
125 self.log.debug('Found unplayed track ' +
126 'but from an album already queued: %s', trk)
128 candidate.append(trk)
131 self.to_add.append(random.choice(candidate))
134 def _get_artists_list_reorg(self, alist):
136 Move around items in artists_list in order to play first not recently
140 duration = self.daemon_conf.getint('sima', 'history_duration')
141 for art in self.sdb.get_artists_history(alist, duration=duration):
144 reorg = [art for art in alist if art not in hist]
149 def get_artists_from_player(self, similarities):
151 Look in player library for availability of similar artists in
154 dynamic = self.plugin_conf.getint('max_art') # pylint: disable=no-member
158 similarities.reverse()
159 while (len(results) < dynamic
160 and len(similarities) > 0):
161 art_pop = similarities.pop()
162 res = self.player.search_artist(art_pop)
167 def ws_similar_artists(self, artist):
169 Retrieve similar artists from WebServive.
171 # initialize artists deque list to construct from DB
173 as_artists = self.ws.get_similar(artist=artist)
174 self.log.debug('Requesting {} for {!r}'.format(self.ws.name, artist))
176 [as_art.append(art) for art in as_artists]
177 except WSNotFound as err:
178 self.log.warning('{}: {}'.format(self.ws.name, err))
180 self.log.debug('Trying without MusicBrainzID')
182 return self.ws_similar_artists(Artist(name=artist.name))
183 except WSNotFound as err:
184 self.log.debug('{}: {}'.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'): # pylint: disable=no-member
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: %s', '/'.join(map(str, extra_arts)))
211 for artist in extra_arts:
212 self.log.debug('Looking for artist similar '
213 'to "{}" as well'.format(artist))
214 similar = self.ws_similar_artists(artist=artist)
217 ret_extra.extend(self.get_artists_from_player(similar))
219 if last_trk.Artist in ret_extra:
220 ret_extra.remove(last_trk.Artist)
222 self.log.debug('similar artist(s) found: %s',
223 ' / '.join(map(str, MetaContainer(ret_extra))))
226 def get_local_similar_artists(self):
227 """Check against local player for similar artists
229 if not self.player.playlist:
231 tolookfor = self.player.playlist[-1].Artist
232 self.log.info('Looking for artist similar to "{}"'.format(tolookfor))
233 self.log.debug(repr(tolookfor))
234 similar = self.ws_similar_artists(tolookfor)
236 self.log.info('Got nothing from {0}!'.format(self.ws.name))
238 self.log.info('First five similar artist(s): %s...',
239 ' / '.join(map(str, list(similar)[:5])))
240 self.log.info('Looking availability in music library')
241 ret = MetaContainer(self.get_artists_from_player(similar))
243 self.log.debug('regular found in library: %s',
244 ' / '.join(map(str, ret)))
246 self.log.debug('Got nothing similar from library!')
248 if len(self.history) >= 2:
249 if self.plugin_conf.getint('depth') > 1: # pylint: disable=no-member
250 ret_extra = self.get_recursive_similar_artist()
252 # get them reorg to pick up best element
253 ret_extra = self._get_artists_list_reorg(ret_extra)
254 # tries to pickup less artist from extra art
256 ret_extra = MetaContainer(ret_extra)
258 ret_extra = MetaContainer(ret_extra[:max(4, len(ret))//2])
260 self.log.debug('extra found in library: %s',
261 ' / '.join(map(str, ret_extra)))
262 ret = ret | ret_extra
264 self.log.warning('Got nothing from music library.')
266 queued_artists = MetaContainer([trk.Artist for trk in self.player.queue])
267 self.log.trace('Already queued: {}'.format(queued_artists))
268 self.log.trace('Candidate: {}'.format(ret))
269 if ret & queued_artists:
270 self.log.debug('Removing already queued artists: '
271 '{0}'.format('/'.join(map(str, ret & queued_artists))))
272 ret = ret - queued_artists
273 if self.player.current and self.player.current.Artist in ret:
274 self.log.debug('Removing current artist: {0}'.format(self.player.current.Artist))
275 ret = ret - MetaContainer([self.player.current.Artist])
276 # Move around similars items to get in unplayed|not recently played
278 self.log.info('Got {} artists in library'.format(len(ret)))
279 candidates = self._get_artists_list_reorg(list(ret))
281 self.log.info(' / '.join(map(str, candidates)))
284 def _get_album_history(self, artist=None):
285 """Retrieve album history"""
286 duration = self.daemon_conf.getint('sima', 'history_duration')
288 for trk in self.sdb.get_history(artist=artist.name, duration=duration):
289 albums_list.add(trk[1])
292 def find_album(self, artists):
293 """Find albums to queue.
297 target_album_to_add = self.plugin_conf.getint('album_to_add') # pylint: disable=no-member
298 for artist in artists:
299 self.log.info('Looking for an album to add for "%s"...' % artist)
300 albums = self.player.search_albums(artist)
301 # str conversion while Album type is not propagated
302 albums = [str(album) for album in albums]
304 self.log.debug('Albums candidate: %s', ' / '.join(albums))
306 # albums yet in history for this artist
308 albums_yet_in_hist = albums & self._get_album_history(artist=artist)
309 albums_not_in_hist = list(albums - albums_yet_in_hist)
310 # Get to next artist if there are no unplayed albums
311 if not albums_not_in_hist:
312 self.log.info('No album found for "%s"' % artist)
314 album_to_queue = str()
315 random.shuffle(albums_not_in_hist)
316 for album in albums_not_in_hist:
317 tracks = self.player.find_album(artist, album)
318 # Look if one track of the album is already queued
319 # Good heuristic, at least enough to guess if the whole album is
321 if tracks[0] in self.player.queue:
322 self.log.debug('"%s" already queued, skipping!', tracks[0].album)
324 album_to_queue = album
325 if not album_to_queue:
326 self.log.info('No album found for "%s"', artist)
328 self.log.info('%s album candidate: %s - %s', self.ws.name, artist, album_to_queue)
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') # pylint: disable=no-member
340 for artist in artists:
341 if len(self.to_add) == nbtracks_target:
343 self.log.info('Looking for a top track for {0}'.format(artist))
346 titles = [t for t in self.ws.get_toptrack(artist)]
347 except WSError as err:
348 self.log.warning('%s: %s', self.ws.name, err)
350 found = self.player.fuzzy_find_track(artist, trk.title)
351 random.shuffle(found)
353 self.log.debug('%s', found[0])
354 if self.filter_track(found):
358 """Get some tracks for track queue mode
360 artists = self.get_local_similar_artists()
361 nbtracks_target = self.plugin_conf.getint('track_to_add') # pylint: disable=no-member
362 for artist in artists:
363 self.log.debug('Trying to find titles to add for "%r"', artist)
364 found = self.player.find_track(artist)
365 random.shuffle(found)
367 self.log.debug('Found nothing to queue for {0}'.format(artist))
369 # find tracks not in history for artist
370 self.filter_track(found)
371 if len(self.to_add) == nbtracks_target:
374 self.log.debug('Found no tracks to queue!')
376 for track in self.to_add:
377 self.log.info('{1} candidates: {0!s}'.format(track, self.ws.name))
380 """Get albums for album queue mode
382 artists = self.get_local_similar_artists()
383 self.find_album(artists)
386 """Get some tracks for top track queue mode
388 artists = self.get_local_similar_artists()
389 self.find_top(artists)
390 for track in self.to_add:
391 self.log.info('{1} candidates: {0!s}'.format(track, self.ws.name))
393 def callback_need_track(self):
394 self._cleanup_cache()
395 if len(self.player.playlist) == 0:
396 self.log.info('No last track, cannot queue')
398 if not self.player.playlist[-1].artist:
399 self.log.warning('No artist set for the last track in queue')
400 self.log.debug(repr(self.player.current))
403 msg = ' '.join(['{0}: {1:>3d}'.format(k, v) for
404 k, v in sorted(self.ws.stats.items())])
405 self.log.debug('http stats: ' + msg)
406 candidates = self.to_add
408 if self.plugin_conf.get('queue_mode') != 'album':
409 random.shuffle(candidates)
412 def callback_player_database(self):
416 # vim: ai ts=4 sw=4 sts=4 expandtab