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
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'))
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('%s: Flushing cache!', name)
86 self.log.info('%s: Initialising cache!', 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
114 Then add to candidates in self.to_add
116 artist = tracks[0].artist
117 # In random play mode use complete playlist to filter
118 if self.player.playmode.get('random'):
119 black_list = self.player.playlist + self.to_add
121 black_list = self.player.queue + self.to_add
122 not_in_hist = list(set(tracks) - set(self.get_history(artist=artist)))
123 if self.plugin_conf.get('queue_mode') != 'top' and not not_in_hist:
124 self.log.debug('All tracks already played for "%s"', artist)
125 random.shuffle(not_in_hist)
127 for trk in [_ for _ in not_in_hist if _ not in black_list]:
128 # Should use albumartist heuristic as well
129 if self.plugin_conf.getboolean('single_album'): # pylint: disable=no-member
130 if (trk.album == self.player.current.album or
131 trk.album in [tr.album for tr in black_list]):
132 self.log.debug('Found unplayed track ' +
133 'but from an album already queued: %s', trk)
135 candidate.append(trk)
138 self.to_add.append(random.choice(candidate))
141 def _get_artists_list_reorg(self, alist):
143 Move around items in artists_list in order to play first not recently
147 duration = self.daemon_conf.getint('sima', 'history_duration')
148 for art in self.sdb.get_artists_history(alist, duration=duration):
151 reorg = [art for art in alist if art not in hist]
156 def get_artists_from_player(self, similarities):
158 Look in player library for availability of similar artists in
161 dynamic = self.plugin_conf.getint('max_art') # pylint: disable=no-member
165 similarities.reverse()
166 while (len(results) < dynamic
167 and len(similarities) > 0):
168 art_pop = similarities.pop()
169 res = self.player.search_artist(art_pop)
174 def ws_similar_artists(self, artist):
176 Retrieve similar artists from WebServive.
178 # initialize artists deque list to construct from DB
180 as_artists = self.ws.get_similar(artist=artist)
181 self.log.debug('Requesting %s for %r', self.ws.name, artist)
183 [as_art.append(art) for art in as_artists]
184 except WSNotFound as err:
185 self.log.warning('%s: %s', self.ws.name, err)
187 self.log.debug('Trying without MusicBrainzID')
189 return self.ws_similar_artists(Artist(name=artist.name))
190 except WSNotFound as err:
191 self.log.debug('%s: %s', self.ws.name, err)
192 except WSError as err:
193 self.log.warning('%s: %s', self.ws.name, err)
195 self.log.debug('Fetched %d artist(s)', len(as_art))
198 def get_recursive_similar_artist(self):
199 """Check against local player for similar artists (recursive w/ history)
201 if not self.player.playlist:
203 history = list(self.history)
204 # In random play mode use complete playlist to filter
205 if self.player.playmode.get('random'):
206 history = self.player.playlist + history
208 history = self.player.queue + history
209 history = deque(history)
210 last_trk = history.popleft() # remove
214 while depth < self.plugin_conf.getint('depth'): # pylint: disable=no-member
215 if len(history) == 0:
217 trk = history.popleft()
218 if (trk.Artist in extra_arts
219 or trk.Artist == last_trk.Artist):
221 extra_arts.append(trk.Artist)
223 self.log.debug('EXTRA ARTS: %s', '/'.join(map(str, extra_arts)))
224 for artist in extra_arts:
225 self.log.debug('Looking for artist similar '
226 'to "%s" as well', artist)
227 similar = self.ws_similar_artists(artist=artist)
230 ret_extra.extend(self.get_artists_from_player(similar))
232 if last_trk.Artist in ret_extra:
233 ret_extra.remove(last_trk.Artist)
235 self.log.debug('similar artist(s) found: %s',
236 ' / '.join(map(str, MetaContainer(ret_extra))))
239 def get_local_similar_artists(self):
240 """Check against local player for similar artists
242 if not self.player.playlist:
244 tolookfor = self.player.playlist[-1].Artist
245 self.log.info('Looking for artist similar to "%s"', tolookfor)
246 self.log.debug(repr(tolookfor))
247 similar = self.ws_similar_artists(tolookfor)
249 self.log.info('Got nothing from %s!', self.ws.name)
251 self.log.info('First five similar artist(s): %s...',
252 ' / '.join(map(str, list(similar)[:5])))
253 self.log.info('Looking availability in music library')
254 ret = MetaContainer(self.get_artists_from_player(similar))
256 self.log.debug('regular found in library: %s',
257 ' / '.join(map(str, ret)))
259 self.log.debug('Got nothing similar from library!')
261 if len(self.history) >= 2:
262 if self.plugin_conf.getint('depth') > 1: # pylint: disable=no-member
263 ret_extra = self.get_recursive_similar_artist()
265 # get them reorg to pick up best element
266 ret_extra = self._get_artists_list_reorg(ret_extra)
267 # tries to pickup less artist from extra art
269 ret_extra = MetaContainer(ret_extra)
271 ret_extra = MetaContainer(ret_extra[:max(4, len(ret))//2])
273 self.log.debug('extra found in library: %s',
274 ' / '.join(map(str, ret_extra)))
275 ret = ret | ret_extra
277 self.log.warning('Got nothing from music library.')
279 # In random play mode use complete playlist to filter
280 if self.player.playmode.get('random'):
281 queued_artists = MetaContainer([trk.Artist for trk in self.player.playlist])
283 queued_artists = MetaContainer([trk.Artist for trk in self.player.queue])
284 self.log.trace('Already queued: %s', queued_artists)
285 self.log.trace('Candidate: %s', ret)
286 if ret & queued_artists:
287 self.log.debug('Removing already queued artists: '
288 '%s', '/'.join(map(str, ret & queued_artists)))
289 ret = ret - queued_artists
290 current = self.player.current
291 if current and current.Artist in ret:
292 self.log.debug('Removing current artist: %s', current.Artist)
293 ret = ret - MetaContainer([current.Artist])
294 # Move around similars items to get in unplayed|not recently played
296 self.log.info('Got %d artists in library', len(ret))
297 candidates = self._get_artists_list_reorg(list(ret))
299 self.log.info(' / '.join(map(str, candidates)))
302 def _get_album_history(self, artist):
303 """Retrieve album history"""
304 duration = self.daemon_conf.getint('sima', 'history_duration')
306 for trk in self.sdb.get_history(artist=artist.name, duration=duration):
307 albums_list.add(trk[1])
310 def find_album(self, artists):
311 """Find albums to queue.
315 target_album_to_add = self.plugin_conf.getint('album_to_add') # pylint: disable=no-member
316 for artist in artists:
317 self.log.info('Looking for an album to add for "%s"...' % artist)
318 albums = self.player.search_albums(artist)
321 self.log.debug('Albums candidate: %s', albums)
322 albums_hist = self._get_album_history(artist)
323 albums_not_in_hist = [a for a in albums if a.name not in albums_hist]
324 # Get to next artist if there are no unplayed albums
325 if not albums_not_in_hist:
326 self.log.info('No unplayed album found for "%s"' % artist)
329 random.shuffle(albums_not_in_hist)
330 for album in albums_not_in_hist:
331 # Controls the album found is not already queued
332 if album in {t.album for t in self.player.queue}:
333 self.log.debug('"%s" already queued, skipping!', album)
335 # In random play mode use complete playlist to filter
336 if self.player.playmode.get('random'):
337 if album in {t.album for t in self.player.playlist}:
338 self.log.debug('"%s" already in playlist, skipping!', album)
340 album_to_queue = album
341 if not album_to_queue:
342 self.log.info('No album found for "%s"', artist)
344 self.log.info('%s album candidate: %s - %s', self.ws.name,
345 artist, album_to_queue)
347 candidates = self.player.find_tracks(album)
348 if self.plugin_conf.getboolean('shuffle_album'):
349 random.shuffle(candidates)
350 # this allows to select a maximum number of track from the album
351 # a value of 0 (default) means keep all
352 nbtracks = self.plugin_conf.getint('track_to_add_from_album')
354 candidates = candidates[0:nbtracks]
355 self.to_add.extend(candidates)
356 if nb_album_add == target_album_to_add:
359 def find_top(self, artists):
361 find top tracks for artists in artists list.
364 nbtracks_target = self.plugin_conf.getint('track_to_add') # pylint: disable=no-member
365 for artist in artists:
366 if len(self.to_add) == nbtracks_target:
368 self.log.info('Looking for a top track for %s', artist)
371 titles = [t for t in self.ws.get_toptrack(artist)]
372 except WSError as err:
373 self.log.warning('%s: %s', self.ws.name, err)
375 found = self.player.search_track(artist, trk.title)
377 random.shuffle(found)
378 if self.filter_track(found):
382 """Get some tracks for track queue mode
384 artists = self.get_local_similar_artists()
385 nbtracks_target = self.plugin_conf.getint('track_to_add') # pylint: disable=no-member
386 for artist in artists:
387 self.log.debug('Trying to find titles to add for "%r"', artist)
388 found = self.player.find_tracks(artist)
389 random.shuffle(found)
391 self.log.debug('Found nothing to queue for %s', artist)
393 # find tracks not in history for artist
394 self.filter_track(found)
395 if len(self.to_add) == nbtracks_target:
398 self.log.debug('Found no tracks to queue!')
400 for track in self.to_add:
401 self.log.info('%s candidates: %s', track, self.ws.name)
404 """Get albums for album queue mode
406 artists = self.get_local_similar_artists()
407 self.find_album(artists)
410 """Get some tracks for top track queue mode
412 artists = self.get_local_similar_artists()
413 self.find_top(artists)
414 for track in self.to_add:
415 self.log.info('%s candidates: %s', self.ws.name, track)
417 def callback_need_track(self):
418 self._cleanup_cache()
419 if len(self.player.playlist) == 0:
420 self.log.info('No last track, cannot queue')
422 if not self.player.playlist[-1].artist:
423 self.log.warning('No artist set for the last track in queue')
424 self.log.debug(repr(self.player.current))
427 msg = ' '.join(['{0}: {1:>3d}'.format(k, v) for
428 k, v in sorted(self.ws.stats.items())])
429 self.log.debug('http stats: ' + msg)
430 candidates = self.to_add
432 if self.plugin_conf.get('queue_mode') != 'album':
433 random.shuffle(candidates)
436 def callback_player_database(self):
440 # vim: ai ts=4 sw=4 sts=4 expandtab