1 # -*- coding: utf-8 -*-
2 # Copyright (c) 2009-2014 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.info('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))
221 self.log.debug('similar artist(s) fond: {}...'.format(
222 ' / '.join(map(str, ret_extra))))
223 if last_trk.Artist in ret_extra:
224 ret_extra.remove(last_trk.Artist)
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 if len(self.history) >= 2:
245 if self.plugin_conf.getint('depth') > 1:
246 ret_extra = self.get_recursive_similar_artist()
248 # get them reorg to pick up best element
249 ret_extra = self._get_artists_list_reorg(ret_extra)
250 # pickup half the number of ret artist
251 ret_extra = MetaContainer(ret_extra[:len(ret)//2])
252 self.log.debug('Using extra: {}'.format(
253 ' / '.join(map(str, ret_extra))))
254 ret = ret | ret_extra
256 self.log.warning('Got nothing from music library.')
259 # * operation on set will not match against aliases
260 # * composite set w/ mbid set and whitout won't match either
261 queued_artists = MetaContainer([trk.Artist for trk in self.player.queue])
262 if ret & queued_artists:
263 self.log.debug('Removing already queued artists: '
264 '{0}'.format('/'.join(map(str, ret & queued_artists))))
265 ret = ret - queued_artists
266 if self.player.current and self.player.current.Artist in ret:
267 self.log.debug('Removing current artist: {0}'.format(self.player.current.Artist))
268 ret = ret - MetaContainer([self.player.current.Artist])
269 # Move around similars items to get in unplayed|not recently played
271 self.log.info('Got {} artists in library'.format(len(ret)))
272 candidates = self._get_artists_list_reorg(list(ret))
274 self.log.info(' / '.join(map(str, candidates)))
277 def _get_album_history(self, artist=None):
278 """Retrieve album history"""
279 duration = self.daemon_conf.getint('sima', 'history_duration')
281 for trk in self.sdb.get_history(artist=artist.name, duration=duration):
282 albums_list.add(trk[1])
285 def find_album(self, artists):
286 """Find albums to queue.
290 target_album_to_add = self.plugin_conf.getint('album_to_add')
291 for artist in artists:
292 self.log.info('Looking for an album to add for "%s"...' % artist)
293 albums = self.player.search_albums(artist)
294 # str conversion while Album type is not propagated
295 albums = [str(album) for album in albums]
297 self.log.debug('Albums candidate: {0:s}'.format(
300 # albums yet in history for this artist
302 albums_yet_in_hist = albums & self._get_album_history(artist=artist)
303 albums_not_in_hist = list(albums - albums_yet_in_hist)
304 # Get to next artist if there are no unplayed albums
305 if not albums_not_in_hist:
306 self.log.info('No album found for "%s"' % artist)
308 album_to_queue = str()
309 random.shuffle(albums_not_in_hist)
310 for album in albums_not_in_hist:
311 tracks = self.player.find_album(artist, album)
312 # Look if one track of the album is already queued
313 # Good heuristic, at least enough to guess if the whole album is
315 if tracks[0] in self.player.queue:
316 self.log.debug('"%s" already queued, skipping!' %
319 album_to_queue = album
320 if not album_to_queue:
321 self.log.info('No album found for "%s"' % artist)
323 self.log.info('{2} album candidate: {0} - {1}'.format(
324 artist, album_to_queue, self.ws.name))
326 self.to_add.extend(self.player.find_album(artist, album_to_queue))
327 if nb_album_add == target_album_to_add:
330 def find_top(self, artists):
332 find top tracks for artists in artists list.
335 nbtracks_target = self.plugin_conf.getint('track_to_add')
336 for artist in artists:
337 artist = Artist(name=artist)
338 if len(self.to_add) == nbtracks_target:
340 self.log.info('Looking for a top track for {0}'.format(artist))
343 titles = [t for t in self.ws.get_toptrack(artist)]
344 except WSError as err:
345 self.log.warning('{0}: {1}'.format(self.ws.name, err))
347 found = self.player.fuzzy_find_track(artist, trk.title)
348 random.shuffle(found)
350 self.log.debug('{0}'.format(found[0]))
351 if self.filter_track(found):
355 """Get some tracks for track queue mode
357 artists = self.get_local_similar_artists()
358 nbtracks_target = self.plugin_conf.getint('track_to_add')
359 for artist in artists:
360 self.log.debug('Trying to find titles to add for "{}"'.format(
362 found = self.player.find_track(artist)
363 random.shuffle(found)
365 self.log.debug('Found nothing to queue for {0}'.format(artist))
367 # find tracks not in history for artist
368 self.filter_track(found)
369 if len(self.to_add) == nbtracks_target:
372 self.log.debug('Found no tracks to queue!')
374 for track in self.to_add:
375 self.log.info('{1} candidates: {0!s}'.format(track, self.ws.name))
378 """Get albums for album queue mode
380 artists = self.get_local_similar_artists()
381 self.find_album(artists)
384 """Get some tracks for top track queue mode
386 artists = self.get_local_similar_artists()
387 self.find_top(artists)
388 for track in self.to_add:
389 self.log.info('{1} candidates: {0!s}'.format(track, self.ws.name))
391 def callback_need_track(self):
392 self._cleanup_cache()
393 if len(self.player.playlist) == 0:
394 self.log.info('No last track, cannot queue')
396 if not self.player.playlist[-1].artist:
397 self.log.warning('No artist set for the last track in queue')
398 self.log.debug(repr(self.player.current))
401 msg = ' '.join(['{0}: {1:>3d}'.format(k, v) for
402 k, v in sorted(self.ws.stats.items())])
403 self.log.debug('http stats: ' + msg)
404 candidates = self.to_add
406 if self.plugin_conf.get('queue_mode') != 'album':
407 random.shuffle(candidates)
410 def callback_player_database(self):
414 # vim: ai ts=4 sw=4 sts=4 expandtab