1 # -*- coding: utf-8 -*-
2 # Copyright (c) 2009-2020 kaliko <kaliko@azylum.org>
3 # Copyright (c) 2019 sacha <sachahony@gmail.com>
5 # This file is part of sima
7 # sima is free software: you can redistribute it and/or modify
8 # it under the terms of the GNU General Public License as published by
9 # the Free Software Foundation, either version 3 of the License, or
10 # (at your option) any later version.
12 # sima is distributed in the hope that it will be useful,
13 # but WITHOUT ANY WARRANTY; without even the implied warranty of
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15 # GNU General Public License for more details.
17 # You should have received a copy of the GNU General Public License
18 # along with sima. If not, see <http://www.gnu.org/licenses/>.
22 Fetching similar artists from last.fm web services
25 # standard library import
28 from collections import deque
29 from hashlib import md5
31 # third parties components
34 from .plugin import Plugin
35 from .track import Track
36 from .meta import Artist, MetaContainer
37 from ..utils.utils import WSError, WSNotFound, WSTimeout
40 """Caching decorator"""
41 def wrapper(*args, **kwargs):
42 #pylint: disable=W0212,C0111
44 similarities = [art.name for art in args[1]]
45 hashedlst = md5(''.join(similarities).encode('utf-8')).hexdigest()
46 if hashedlst in cls._cache.get('asearch'):
47 cls.log.debug('cached request')
48 results = cls._cache.get('asearch').get(hashedlst)
50 results = func(*args, **kwargs)
51 cls.log.debug('caching request')
52 cls._cache.get('asearch').update({hashedlst:list(results)})
53 random.shuffle(results)
58 class WebService(Plugin):
59 """similar artists webservice
61 # pylint: disable=bad-builtin
63 def __init__(self, daemon):
64 Plugin.__init__(self, daemon)
65 self.daemon_conf = daemon.config
67 self.history = daemon.short_history
72 wrapper = {'track': self._track,
75 self.queue_mode = wrapper.get(self.plugin_conf.get('queue_mode'))
79 def _flush_cache(self):
81 Both flushes and instanciates _cache
83 name = self.__class__.__name__
84 if isinstance(self._cache, dict):
85 self.log.info('%s: Flushing cache!', name)
87 self.log.info('%s: Initialising cache!', name)
88 self._cache = {'asearch': dict(),
91 def _cleanup_cache(self):
92 """Avoid bloated cache
94 for _, val in self._cache.items():
95 if isinstance(val, dict):
99 def get_history(self, artist):
100 """Constructs list of Track for already played titles for an artist.
102 duration = self.daemon_conf.getint('sima', 'history_duration')
103 tracks_from_db = self.sdb.get_history(duration=duration, artist=artist)
104 # Construct Track() objects list from database history
105 played_tracks = [Track(artist=tr[-1], album=tr[1], title=tr[2],
106 file=tr[3]) for tr in tracks_from_db]
109 def filter_track(self, tracks):
111 Extract one unplayed track from a Track object list.
113 * not already in the queue
115 Then add to candidates in self.to_add
117 artist = tracks[0].artist
118 # In random play mode use complete playlist to filter
119 if self.player.playmode.get('random'):
120 black_list = self.player.playlist + self.to_add
122 black_list = self.player.queue + self.to_add
123 not_in_hist = list(set(tracks) - set(self.get_history(artist=artist)))
124 if self.plugin_conf.get('queue_mode') != 'top' and not not_in_hist:
125 self.log.debug('All tracks already played for "%s"', artist)
126 random.shuffle(not_in_hist)
128 for trk in [_ for _ in not_in_hist if _ not in black_list]:
129 # Should use albumartist heuristic as well
130 if self.plugin_conf.getboolean('single_album'): # pylint: disable=no-member
131 if (trk.album == self.player.current.album or
132 trk.album in [tr.album for tr in black_list]):
133 self.log.debug('Found unplayed track ' +
134 'but from an album already queued: %s', trk)
136 candidate.append(trk)
139 self.to_add.append(random.choice(candidate))
142 def _get_artists_list_reorg(self, alist):
144 Move around items in artists_list in order to play first not recently
148 duration = self.daemon_conf.getint('sima', 'history_duration')
149 for art in self.sdb.get_artists_history(alist, duration=duration):
152 reorg = [art for art in alist if art not in hist]
157 def get_artists_from_player(self, similarities):
159 Look in player library for availability of similar artists in
162 dynamic = self.plugin_conf.getint('max_art') # pylint: disable=no-member
166 similarities.reverse()
167 while (len(results) < dynamic
168 and len(similarities) > 0):
169 art_pop = similarities.pop()
170 res = self.player.search_artist(art_pop)
175 def ws_similar_artists(self, artist):
177 Retrieve similar artists from WebServive.
179 # initialize artists deque list to construct from DB
181 as_artists = self.ws.get_similar(artist=artist)
182 self.log.debug('Requesting %s for %r', self.ws.name, artist)
184 [as_art.append(art) for art in as_artists]
185 except WSNotFound as err:
186 self.log.warning('%s: %s', self.ws.name, err)
188 self.log.debug('Trying without MusicBrainzID')
190 return self.ws_similar_artists(Artist(name=artist.name))
191 except WSNotFound as err:
192 self.log.debug('%s: %s', self.ws.name, err)
193 except WSTimeout as err:
194 self.log.warning('%s: %s', self.ws.name, err)
195 if self.ws_retry < 3:
197 self.log.warning('%s: retrying', self.ws.name)
198 as_art = self.ws_similar_artists(artist)
200 self.log.warning('%s: stop retrying', self.ws.name)
202 except WSError as err:
203 self.log.warning('%s: %s', self.ws.name, err)
205 self.log.debug('Fetched %d artist(s)', len(as_art))
208 def get_recursive_similar_artist(self):
209 """Check against local player for similar artists (recursive w/ history)
211 if not self.player.playlist:
213 history = list(self.history)
214 # In random play mode use complete playlist to filter
215 if self.player.playmode.get('random'):
216 history = self.player.playlist + history
218 history = self.player.queue + history
219 history = deque(history)
220 last_trk = history.popleft() # remove
224 while depth < self.plugin_conf.getint('depth'): # pylint: disable=no-member
225 if len(history) == 0:
227 trk = history.popleft()
228 if (trk.Artist in extra_arts
229 or trk.Artist == last_trk.Artist):
231 extra_arts.append(trk.Artist)
233 self.log.debug('EXTRA ARTS: %s', '/'.join(map(str, extra_arts)))
234 for artist in extra_arts:
235 self.log.debug('Looking for artist similar '
236 'to "%s" as well', artist)
237 similar = self.ws_similar_artists(artist=artist)
240 ret_extra.extend(self.get_artists_from_player(similar))
242 if last_trk.Artist in ret_extra:
243 ret_extra.remove(last_trk.Artist)
245 self.log.debug('similar artist(s) found: %s',
246 ' / '.join(map(str, MetaContainer(ret_extra))))
249 def get_local_similar_artists(self):
250 """Check against local player for similar artists
252 if not self.player.playlist:
254 tolookfor = self.player.playlist[-1].Artist
255 self.log.info('Looking for artist similar to "%s"', tolookfor)
256 self.log.debug(repr(tolookfor))
257 similar = self.ws_similar_artists(tolookfor)
259 self.log.info('Got nothing from %s!', self.ws.name)
261 self.log.info('First five similar artist(s): %s...',
262 ' / '.join(map(str, list(similar)[:5])))
263 self.log.info('Looking availability in music library')
264 ret = MetaContainer(self.get_artists_from_player(similar))
266 self.log.debug('regular found in library: %s',
267 ' / '.join(map(str, ret)))
269 self.log.debug('Got nothing similar from library!')
271 if len(self.history) >= 2:
272 if self.plugin_conf.getint('depth') > 1: # pylint: disable=no-member
273 ret_extra = self.get_recursive_similar_artist()
275 # get them reorg to pick up best element
276 ret_extra = self._get_artists_list_reorg(ret_extra)
277 # tries to pickup less artist from extra art
279 ret_extra = MetaContainer(ret_extra)
281 ret_extra = MetaContainer(ret_extra[:max(4, len(ret))//2])
283 self.log.debug('extra found in library: %s',
284 ' / '.join(map(str, ret_extra)))
285 ret = ret | ret_extra
287 self.log.warning('Got nothing from music library.')
289 # In random play mode use complete playlist to filter
290 if self.player.playmode.get('random'):
291 queued_artists = MetaContainer([trk.Artist for trk in self.player.playlist])
293 queued_artists = MetaContainer([trk.Artist for trk in self.player.queue])
294 self.log.trace('Already queued: %s', queued_artists)
295 self.log.trace('Candidate: %s', ret)
296 if ret & queued_artists:
297 self.log.debug('Removing already queued artists: '
298 '%s', '/'.join(map(str, ret & queued_artists)))
299 ret = ret - queued_artists
300 current = self.player.current
301 if current and current.Artist in ret:
302 self.log.debug('Removing current artist: %s', current.Artist)
303 ret = ret - MetaContainer([current.Artist])
304 # Move around similars items to get in unplayed|not recently played
306 self.log.info('Got %d artists in library', len(ret))
307 candidates = self._get_artists_list_reorg(list(ret))
309 self.log.info(' / '.join(map(str, candidates)))
312 def _get_album_history(self, artist):
313 """Retrieve album history"""
315 for trk in self.get_history(artist=artist.name):
318 albums_list.add(trk.album)
321 def find_album(self, artists):
322 """Find albums to queue.
326 target_album_to_add = self.plugin_conf.getint('album_to_add') # pylint: disable=no-member
327 for artist in artists:
328 self.log.info('Looking for an album to add for "%s"...' % artist)
329 albums = self.player.search_albums(artist)
332 self.log.debug('Albums candidate: %s', albums)
333 albums_hist = self._get_album_history(artist)
334 albums_not_in_hist = [a for a in albums if a.name not in albums_hist]
335 # Get to next artist if there are no unplayed albums
336 if not albums_not_in_hist:
337 self.log.info('No unplayed album found for "%s"' % artist)
340 random.shuffle(albums_not_in_hist)
341 for album in albums_not_in_hist:
342 # Controls the album found is not already queued
343 if album in {t.album for t in self.player.queue}:
344 self.log.debug('"%s" already queued, skipping!', album)
346 # In random play mode use complete playlist to filter
347 if self.player.playmode.get('random'):
348 if album in {t.album for t in self.player.playlist}:
349 self.log.debug('"%s" already in playlist, skipping!', album)
351 album_to_queue = album
352 if not album_to_queue:
353 self.log.info('No album found for "%s"', artist)
355 self.log.info('%s album candidate: %s - %s', self.ws.name,
356 artist, album_to_queue)
358 candidates = self.player.find_tracks(album)
359 if self.plugin_conf.getboolean('shuffle_album'):
360 random.shuffle(candidates)
361 # this allows to select a maximum number of track from the album
362 # a value of 0 (default) means keep all
363 nbtracks = self.plugin_conf.getint('track_to_add_from_album')
365 candidates = candidates[0:nbtracks]
366 self.to_add.extend(candidates)
367 if nb_album_add == target_album_to_add:
370 def find_top(self, artists):
372 find top tracks for artists in artists list.
375 nbtracks_target = self.plugin_conf.getint('track_to_add') # pylint: disable=no-member
376 for artist in artists:
377 if len(self.to_add) == nbtracks_target:
379 self.log.info('Looking for a top track for %s', artist)
382 titles = [t for t in self.ws.get_toptrack(artist)]
383 except WSError as err:
384 self.log.warning('%s: %s', self.ws.name, err)
387 found = self.player.search_track(artist, trk.title)
389 random.shuffle(found)
390 if self.filter_track(found):
394 """Get some tracks for track queue mode
396 artists = self.get_local_similar_artists()
397 nbtracks_target = self.plugin_conf.getint('track_to_add') # pylint: disable=no-member
398 for artist in artists:
399 self.log.debug('Trying to find titles to add for "%r"', artist)
400 found = self.player.find_tracks(artist)
401 random.shuffle(found)
403 self.log.debug('Found nothing to queue for %s', artist)
405 # find tracks not in history for artist
406 self.filter_track(found)
407 if len(self.to_add) == nbtracks_target:
410 self.log.debug('Found no tracks to queue!')
412 for track in self.to_add:
413 self.log.info('%s candidates: %s', track, self.ws.name)
416 """Get albums for album queue mode
418 artists = self.get_local_similar_artists()
419 self.find_album(artists)
422 """Get some tracks for top track queue mode
424 artists = self.get_local_similar_artists()
425 self.find_top(artists)
426 for track in self.to_add:
427 self.log.info('%s candidates: %s', self.ws.name, track)
429 def callback_need_track(self):
430 self._cleanup_cache()
431 if len(self.player.playlist) == 0:
432 self.log.info('No last track, cannot queue')
434 if not self.player.playlist[-1].artist:
435 self.log.warning('No artist set for the last track in queue')
436 self.log.debug(repr(self.player.current))
439 msg = ' '.join(['{0}: {1:>3d}'.format(k, v) for
440 k, v in sorted(self.ws.stats.items())])
441 self.log.debug('http stats: ' + msg)
442 candidates = self.to_add
444 if self.plugin_conf.get('queue_mode') != 'album':
445 random.shuffle(candidates)
448 def callback_player_database(self):
452 # vim: ai ts=4 sw=4 sts=4 expandtab