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, Album, MetaContainer
37 from ..utils.utils import WSError, WSNotFound
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,
74 'album': self._album,}
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))
87 self._cache = {'asearch': dict(),
90 def _cleanup_cache(self):
91 """Avoid bloated cache
93 for _, val in self._cache.items():
94 if isinstance(val, dict):
98 def get_history(self, artist):
99 """Constructs list of Track for already played titles for an artist.
101 duration = self.daemon_conf.getint('sima', 'history_duration')
102 tracks_from_db = self.sdb.get_history(duration=duration, artist=artist)
103 # Construct Track() objects list from database history
104 played_tracks = [Track(artist=tr[-1], album=tr[1], title=tr[2],
105 file=tr[3]) for tr in tracks_from_db]
108 def filter_track(self, tracks):
110 Extract one unplayed track from a Track object list.
112 * not already in the queue
115 artist = tracks[0].artist
116 # In random play mode use complete playlist to filter
117 if self.player.playmode.get('random'):
118 black_list = self.player.playlist + self.to_add
120 black_list = self.player.queue + self.to_add
121 not_in_hist = list(set(tracks) - set(self.get_history(artist=artist)))
122 if self.plugin_conf.get('queue_mode') != 'top' and not not_in_hist:
123 self.log.debug('All tracks already played for "%s"', artist)
124 random.shuffle(not_in_hist)
126 for trk in [_ for _ in not_in_hist if _ not in black_list]:
127 # Should use albumartist heuristic as well
128 if self.plugin_conf.getboolean('single_album'): # pylint: disable=no-member
129 if (trk.album == self.player.current.album or
130 trk.album in [tr.album for tr in black_list]):
131 self.log.debug('Found unplayed track ' +
132 'but from an album already queued: %s', trk)
134 candidate.append(trk)
137 self.to_add.append(random.choice(candidate))
140 def _get_artists_list_reorg(self, alist):
142 Move around items in artists_list in order to play first not recently
146 duration = self.daemon_conf.getint('sima', 'history_duration')
147 for art in self.sdb.get_artists_history(alist, duration=duration):
150 reorg = [art for art in alist if art not in hist]
155 def get_artists_from_player(self, similarities):
157 Look in player library for availability of similar artists in
160 dynamic = self.plugin_conf.getint('max_art') # pylint: disable=no-member
164 similarities.reverse()
165 while (len(results) < dynamic
166 and len(similarities) > 0):
167 art_pop = similarities.pop()
168 res = self.player.search_artist(art_pop)
173 def ws_similar_artists(self, artist):
175 Retrieve similar artists from WebServive.
177 # initialize artists deque list to construct from DB
179 as_artists = self.ws.get_similar(artist=artist)
180 self.log.debug('Requesting {} for {!r}'.format(self.ws.name, artist))
182 [as_art.append(art) for art in as_artists]
183 except WSNotFound as err:
184 self.log.warning('{}: {}'.format(self.ws.name, err))
186 self.log.debug('Trying without MusicBrainzID')
188 return self.ws_similar_artists(Artist(name=artist.name))
189 except WSNotFound as err:
190 self.log.debug('{}: {}'.format(self.ws.name, err))
191 except WSError as err:
192 self.log.warning('{}: {}'.format(self.ws.name, err))
194 self.log.debug('Fetched {} artist(s)'.format(len(as_art)))
197 def get_recursive_similar_artist(self):
198 """Check against local player for similar artists (recursive w/ history)
200 if not self.player.playlist:
202 history = list(self.history)
203 # In random play mode use complete playlist to filter
204 if self.player.playmode.get('random'):
205 history = self.player.playlist + history
207 history = self.player.queue + history
208 history = deque(history)
209 last_trk = history.popleft() # remove
213 while depth < self.plugin_conf.getint('depth'): # pylint: disable=no-member
214 if len(history) == 0:
216 trk = history.popleft()
217 if (trk.Artist in extra_arts
218 or trk.Artist == last_trk.Artist):
220 extra_arts.append(trk.Artist)
222 self.log.debug('EXTRA ARTS: %s', '/'.join(map(str, extra_arts)))
223 for artist in extra_arts:
224 self.log.debug('Looking for artist similar '
225 'to "{}" as well'.format(artist))
226 similar = self.ws_similar_artists(artist=artist)
229 ret_extra.extend(self.get_artists_from_player(similar))
231 if last_trk.Artist in ret_extra:
232 ret_extra.remove(last_trk.Artist)
234 self.log.debug('similar artist(s) found: %s',
235 ' / '.join(map(str, MetaContainer(ret_extra))))
238 def get_local_similar_artists(self):
239 """Check against local player for similar artists
241 if not self.player.playlist:
243 tolookfor = self.player.playlist[-1].Artist
244 self.log.info('Looking for artist similar to "%s"', tolookfor)
245 self.log.debug(repr(tolookfor))
246 similar = self.ws_similar_artists(tolookfor)
248 self.log.info('Got nothing from %s!', self.ws.name)
250 self.log.info('First five similar artist(s): %s...',
251 ' / '.join(map(str, list(similar)[:5])))
252 self.log.info('Looking availability in music library')
253 ret = MetaContainer(self.get_artists_from_player(similar))
255 self.log.debug('regular found in library: %s',
256 ' / '.join(map(str, ret)))
258 self.log.debug('Got nothing similar from library!')
260 if len(self.history) >= 2:
261 if self.plugin_conf.getint('depth') > 1: # pylint: disable=no-member
262 ret_extra = self.get_recursive_similar_artist()
264 # get them reorg to pick up best element
265 ret_extra = self._get_artists_list_reorg(ret_extra)
266 # tries to pickup less artist from extra art
268 ret_extra = MetaContainer(ret_extra)
270 ret_extra = MetaContainer(ret_extra[:max(4, len(ret))//2])
272 self.log.debug('extra found in library: %s',
273 ' / '.join(map(str, ret_extra)))
274 ret = ret | ret_extra
276 self.log.warning('Got nothing from music library.')
278 # In random play mode use complete playlist to filter
279 if self.player.playmode.get('random'):
280 queued_artists = MetaContainer([trk.Artist for trk in self.player.playlist])
282 queued_artists = MetaContainer([trk.Artist for trk in self.player.queue])
283 self.log.trace('Already queued: %s', queued_artists)
284 self.log.trace('Candidate: %s', ret)
285 if ret & queued_artists:
286 self.log.debug('Removing already queued artists: '
287 '%s', '/'.join(map(str, ret & queued_artists)))
288 ret = ret - queued_artists
289 current = self.player.current
290 if current and current.Artist in ret:
291 self.log.debug('Removing current artist: %s', current.Artist)
292 ret = ret - MetaContainer([current.Artist])
293 # Move around similars items to get in unplayed|not recently played
295 self.log.info('Got {} artists in library'.format(len(ret)))
296 candidates = self._get_artists_list_reorg(list(ret))
298 self.log.info(' / '.join(map(str, candidates)))
301 def _get_album_history(self, artist=None):
302 """Retrieve album history"""
303 duration = self.daemon_conf.getint('sima', 'history_duration')
305 for trk in self.sdb.get_history(artist=artist.name, duration=duration):
306 albums_list.add(trk[1])
309 def find_album(self, artists):
310 """Find albums to queue.
314 target_album_to_add = self.plugin_conf.getint('album_to_add') # pylint: disable=no-member
315 for artist in artists:
316 self.log.info('Looking for an album to add for "%s"...' % artist)
317 albums = self.player.search_albums(artist)
318 # str conversion while Album type is not propagated
319 albums = [str(album) for album in albums]
322 self.log.debug('Albums candidate: %s', ' / '.join(albums))
323 # albums yet in history for this artist
325 albums_yet_in_hist = albums & self._get_album_history(artist=artist)
326 albums_not_in_hist = list(albums - albums_yet_in_hist)
327 # Get to next artist if there are no unplayed albums
328 if not albums_not_in_hist:
329 self.log.info('No unplayed album found for "%s"' % artist)
331 album_to_queue = str()
332 random.shuffle(albums_not_in_hist)
333 for album in albums_not_in_hist:
334 # Controls the album found is not already queued
335 if album in {t.album for t in self.player.queue}:
336 self.log.debug('"%s" already queued, skipping!', album)
338 # In random play mode use complete playlist to filter
339 if self.player.playmode.get('random'):
340 if album in {t.album for t in self.player.playlist}:
341 self.log.debug('"%s" already in playlist, skipping!', album)
343 album_to_queue = album
344 if not album_to_queue:
345 self.log.info('No album found for "%s"', artist)
347 self.log.info('%s album candidate: %s - %s', self.ws.name,
348 artist, album_to_queue)
350 candidates = self.player.find_tracks(Album(name=album_to_queue,
352 if self.plugin_conf.getboolean('shuffle_album'):
353 random.shuffle(candidates)
354 # this allows to select a maximum number of track from the album
355 # a value of 0 (default) means keep all
356 nbtracks = self.plugin_conf.getint('track_to_add_from_album')
358 candidates = candidates[0:nbtracks]
359 self.to_add.extend(candidates)
360 if nb_album_add == target_album_to_add:
363 def find_top(self, artists):
365 find top tracks for artists in artists list.
368 nbtracks_target = self.plugin_conf.getint('track_to_add') # pylint: disable=no-member
369 for artist in artists:
370 if len(self.to_add) == nbtracks_target:
372 self.log.info('Looking for a top track for {0}'.format(artist))
375 titles = [t for t in self.ws.get_toptrack(artist)]
376 except WSError as err:
377 self.log.warning('%s: %s', self.ws.name, err)
379 found = self.player.search_track(artist, trk.title)
380 random.shuffle(found)
382 self.log.debug('%s', found[0])
383 if self.filter_track(found):
387 """Get some tracks for track queue mode
389 artists = self.get_local_similar_artists()
390 nbtracks_target = self.plugin_conf.getint('track_to_add') # pylint: disable=no-member
391 for artist in artists:
392 self.log.debug('Trying to find titles to add for "%r"', artist)
393 found = self.player.find_tracks(artist)
394 random.shuffle(found)
396 self.log.debug('Found nothing to queue for %s', artist)
398 # find tracks not in history for artist
399 self.filter_track(found)
400 if len(self.to_add) == nbtracks_target:
403 self.log.debug('Found no tracks to queue!')
405 for track in self.to_add:
406 self.log.info('%s candidates: %s', track, self.ws.name)
409 """Get albums for album queue mode
411 artists = self.get_local_similar_artists()
412 self.find_album(artists)
415 """Get some tracks for top track queue mode
417 artists = self.get_local_similar_artists()
418 self.find_top(artists)
419 for track in self.to_add:
420 self.log.info('{1} candidates: {0!s}'.format(track, self.ws.name))
422 def callback_need_track(self):
423 self._cleanup_cache()
424 if len(self.player.playlist) == 0:
425 self.log.info('No last track, cannot queue')
427 if not self.player.playlist[-1].artist:
428 self.log.warning('No artist set for the last track in queue')
429 self.log.debug(repr(self.player.current))
432 msg = ' '.join(['{0}: {1:>3d}'.format(k, v) for
433 k, v in sorted(self.ws.stats.items())])
434 self.log.debug('http stats: ' + msg)
435 candidates = self.to_add
437 if self.plugin_conf.get('queue_mode') != 'album':
438 random.shuffle(candidates)
441 def callback_player_database(self):
445 # vim: ai ts=4 sw=4 sts=4 expandtab