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 black_list = self.player.queue + self.to_add
116 not_in_hist = list(set(tracks) - set(self.get_history(artist=artist)))
117 if self.plugin_conf.get('queue_mode') != 'top' and not not_in_hist:
118 self.log.debug('All tracks already played for "{}"'.format(artist))
119 random.shuffle(not_in_hist)
121 for trk in [_ for _ in not_in_hist if _ not in black_list]:
122 # Should use albumartist heuristic as well
123 if self.plugin_conf.getboolean('single_album'): # pylint: disable=no-member
124 if (trk.album == self.player.current.album or
125 trk.album in [tr.album for tr in self.to_add]):
126 self.log.debug('Found unplayed track ' +
127 'but from an album already queued: %s', trk)
129 candidate.append(trk)
132 self.to_add.append(random.choice(candidate))
135 def _get_artists_list_reorg(self, alist):
137 Move around items in artists_list in order to play first not recently
141 duration = self.daemon_conf.getint('sima', 'history_duration')
142 for art in self.sdb.get_artists_history(alist, duration=duration):
145 reorg = [art for art in alist if art not in hist]
150 def get_artists_from_player(self, similarities):
152 Look in player library for availability of similar artists in
155 dynamic = self.plugin_conf.getint('max_art') # pylint: disable=no-member
159 similarities.reverse()
160 while (len(results) < dynamic
161 and len(similarities) > 0):
162 art_pop = similarities.pop()
163 res = self.player.search_artist(art_pop)
168 def ws_similar_artists(self, artist):
170 Retrieve similar artists from WebServive.
172 # initialize artists deque list to construct from DB
174 as_artists = self.ws.get_similar(artist=artist)
175 self.log.debug('Requesting {} for {!r}'.format(self.ws.name, artist))
177 [as_art.append(art) for art in as_artists]
178 except WSNotFound as err:
179 self.log.warning('{}: {}'.format(self.ws.name, err))
181 self.log.debug('Trying without MusicBrainzID')
183 return self.ws_similar_artists(Artist(name=artist.name))
184 except WSNotFound as err:
185 self.log.debug('{}: {}'.format(self.ws.name, err))
186 except WSError as err:
187 self.log.warning('{}: {}'.format(self.ws.name, err))
189 self.log.debug('Fetched {} artist(s)'.format(len(as_art)))
192 def get_recursive_similar_artist(self):
193 """Check against local player for similar artists (recursive w/ history)
195 if not self.player.playlist:
197 history = list(self.history)
198 history = self.player.queue + history
199 history = deque(history)
200 last_trk = history.popleft() # remove
204 while depth < self.plugin_conf.getint('depth'): # pylint: disable=no-member
205 if len(history) == 0:
207 trk = history.popleft()
208 if (trk.Artist in extra_arts
209 or trk.Artist == last_trk.Artist):
211 extra_arts.append(trk.Artist)
213 self.log.debug('EXTRA ARTS: %s', '/'.join(map(str, extra_arts)))
214 for artist in extra_arts:
215 self.log.debug('Looking for artist similar '
216 'to "{}" as well'.format(artist))
217 similar = self.ws_similar_artists(artist=artist)
220 ret_extra.extend(self.get_artists_from_player(similar))
222 if last_trk.Artist in ret_extra:
223 ret_extra.remove(last_trk.Artist)
225 self.log.debug('similar artist(s) found: %s',
226 ' / '.join(map(str, MetaContainer(ret_extra))))
229 def get_local_similar_artists(self):
230 """Check against local player for similar artists
232 if not self.player.playlist:
234 tolookfor = self.player.playlist[-1].Artist
235 self.log.info('Looking for artist similar to "{}"'.format(tolookfor))
236 self.log.debug(repr(tolookfor))
237 similar = self.ws_similar_artists(tolookfor)
239 self.log.info('Got nothing from {0}!'.format(self.ws.name))
241 self.log.info('First five similar artist(s): %s...',
242 ' / '.join(map(str, list(similar)[:5])))
243 self.log.info('Looking availability in music library')
244 ret = MetaContainer(self.get_artists_from_player(similar))
246 self.log.debug('regular found in library: %s',
247 ' / '.join(map(str, ret)))
249 self.log.debug('Got nothing similar from library!')
251 if len(self.history) >= 2:
252 if self.plugin_conf.getint('depth') > 1: # pylint: disable=no-member
253 ret_extra = self.get_recursive_similar_artist()
255 # get them reorg to pick up best element
256 ret_extra = self._get_artists_list_reorg(ret_extra)
257 # tries to pickup less artist from extra art
259 ret_extra = MetaContainer(ret_extra)
261 ret_extra = MetaContainer(ret_extra[:max(4, len(ret))//2])
263 self.log.debug('extra found in library: %s',
264 ' / '.join(map(str, ret_extra)))
265 ret = ret | ret_extra
267 self.log.warning('Got nothing from music library.')
269 queued_artists = MetaContainer([trk.Artist for trk in self.player.queue])
270 self.log.trace('Already queued: {}'.format(queued_artists))
271 self.log.trace('Candidate: {}'.format(ret))
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') # pylint: disable=no-member
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: %s', ' / '.join(albums))
309 # albums yet in history for this artist
311 albums_yet_in_hist = albums & self._get_album_history(artist=artist)
312 albums_not_in_hist = list(albums - albums_yet_in_hist)
313 # Get to next artist if there are no unplayed albums
314 if not albums_not_in_hist:
315 self.log.info('No album found for "%s"' % artist)
317 album_to_queue = str()
318 random.shuffle(albums_not_in_hist)
319 for album in albums_not_in_hist:
320 tracks = self.player.find_album(artist, album)
321 # Look if one track of the album is already queued
322 # Good heuristic, at least enough to guess if the whole album is
324 if tracks[0] in self.player.queue:
325 self.log.debug('"%s" already queued, skipping!', tracks[0].album)
327 album_to_queue = album
328 if not album_to_queue:
329 self.log.info('No album found for "%s"', artist)
331 self.log.info('%s album candidate: %s - %s', self.ws.name, artist, album_to_queue)
333 self.to_add.extend(self.player.find_album(artist, album_to_queue))
334 if nb_album_add == target_album_to_add:
337 def find_top(self, artists):
339 find top tracks for artists in artists list.
342 nbtracks_target = self.plugin_conf.getint('track_to_add') # pylint: disable=no-member
343 for artist in artists:
344 if len(self.to_add) == nbtracks_target:
346 self.log.info('Looking for a top track for {0}'.format(artist))
349 titles = [t for t in self.ws.get_toptrack(artist)]
350 except WSError as err:
351 self.log.warning('%s: %s', self.ws.name, err)
353 found = self.player.fuzzy_find_track(artist, trk.title)
354 random.shuffle(found)
356 self.log.debug('%s', found[0])
357 if self.filter_track(found):
361 """Get some tracks for track queue mode
363 artists = self.get_local_similar_artists()
364 nbtracks_target = self.plugin_conf.getint('track_to_add') # pylint: disable=no-member
365 for artist in artists:
366 self.log.debug('Trying to find titles to add for "%r"', artist)
367 found = self.player.find_track(artist)
368 random.shuffle(found)
370 self.log.debug('Found nothing to queue for {0}'.format(artist))
372 # find tracks not in history for artist
373 self.filter_track(found)
374 if len(self.to_add) == nbtracks_target:
377 self.log.debug('Found no tracks to queue!')
379 for track in self.to_add:
380 self.log.info('{1} candidates: {0!s}'.format(track, self.ws.name))
383 """Get albums for album queue mode
385 artists = self.get_local_similar_artists()
386 self.find_album(artists)
389 """Get some tracks for top track queue mode
391 artists = self.get_local_similar_artists()
392 self.find_top(artists)
393 for track in self.to_add:
394 self.log.info('{1} candidates: {0!s}'.format(track, self.ws.name))
396 def callback_need_track(self):
397 self._cleanup_cache()
398 if len(self.player.playlist) == 0:
399 self.log.info('No last track, cannot queue')
401 if not self.player.playlist[-1].artist:
402 self.log.warning('No artist set for the last track in queue')
403 self.log.debug(repr(self.player.current))
406 msg = ' '.join(['{0}: {1:>3d}'.format(k, v) for
407 k, v in sorted(self.ws.stats.items())])
408 self.log.debug('http stats: ' + msg)
409 candidates = self.to_add
411 if self.plugin_conf.get('queue_mode') != 'album':
412 random.shuffle(candidates)
415 def callback_player_database(self):
419 # vim: ai ts=4 sw=4 sts=4 expandtab