From: kaliko Date: Sun, 9 Feb 2014 16:56:32 +0000 (+0100) Subject: Refactored lastfm/echonest webservices X-Git-Tag: mpd-sima/0.12.0pr4~28 X-Git-Url: https://git.kaliko.me/?a=commitdiff_plain;h=00f3a52f35f709dd4c471cb6ad87dbd09cfd4aaf;p=mpd-sima.git Refactored lastfm/echonest webservices lastfm/echonest now inherit from the same WebService object removed similarity option, renamed "dynamic" as "max_art" --- diff --git a/README b/README index 373ef5d..00360ca 100644 --- a/README +++ b/README @@ -1,7 +1,9 @@ Design for python >= 3.3 -Requires python-musicpd: +Requires: python-musicpd >= 0.4.0 [0], + requests >= 2.2.0 [1] - http://media.kaliko.me/src/musicpd/ + [0] http://media.kaliko.me/src/musicpd/ + [1] http://docs.python-requests.org/ diff --git a/sima/lib/simaecho.py b/sima/lib/simaecho.py index 716a43e..3e6f281 100644 --- a/sima/lib/simaecho.py +++ b/sima/lib/simaecho.py @@ -34,6 +34,7 @@ from requests import get, Request, Timeout, ConnectionError from sima import ECH from sima.lib.meta import Artist +from sima.utils.utils import WSError, WSNotFound, WSTimeout, WSHTTPError from sima.utils.utils import getws, Throttle, Cache, purge_cache if len(ECH.get('apikey')) == 23: # simple hack allowing imp.reload getws(ECH) @@ -43,18 +44,6 @@ WAIT_BETWEEN_REQUESTS = timedelta(0, 1) SOCKET_TIMEOUT = 4 -class EchoError(Exception): - pass - -class EchoNotFound(EchoError): - pass - -class EchoTimeout(EchoError): - pass - -class EchoHTTPError(EchoError): - pass - class SimaEch(): """ """ @@ -62,6 +51,7 @@ class SimaEch(): cache = {} timestamp = datetime.utcnow() ratelimit = None + name = 'EchoNest' def __init__(self, cache=True): self.artist = None @@ -79,10 +69,10 @@ class SimaEch(): try: self._fetch_ech(payload) except Timeout: - raise EchoTimeout('Failed to reach server within {0}s'.format( + raise WSTimeout('Failed to reach server within {0}s'.format( SOCKET_TIMEOUT)) except ConnectionError as err: - raise EchoError(err) + raise WSError(err) @Throttle(WAIT_BETWEEN_REQUESTS) def _fetch_ech(self, payload): @@ -91,7 +81,7 @@ class SimaEch(): timeout=SOCKET_TIMEOUT) self.__class__.ratelimit = req.headers.get('x-ratelimit-remaining', None) if req.status_code is not 200: - raise EchoHTTPError(req.status_code) + raise WSHTTPError(req.status_code) self.current_element = req.json() self._controls_answer() if self.caching: @@ -106,8 +96,8 @@ class SimaEch(): if code is 0: return True if code is 5: - raise EchoNotFound('Artist not found: "{0}"'.format(self.artist)) - raise EchoError(status.get('message')) + raise WSNotFound('Artist not found: "{0}"'.format(self.artist)) + raise WSError(status.get('message')) def _forge_payload(self, artist): """ diff --git a/sima/lib/simafm.py b/sima/lib/simafm.py index d01567e..04ee37b 100644 --- a/sima/lib/simafm.py +++ b/sima/lib/simafm.py @@ -31,6 +31,7 @@ from requests import get, Request, Timeout, ConnectionError from sima import LFM from sima.lib.meta import Artist +from sima.utils.utils import WSError, WSNotFound, WSTimeout, WSHTTPError from sima.utils.utils import getws, Throttle, Cache, purge_cache if len(LFM.get('apikey')) == 43: # simple hack allowing imp.reload getws(LFM) @@ -40,27 +41,14 @@ WAIT_BETWEEN_REQUESTS = timedelta(0, 1) SOCKET_TIMEOUT = 4 -class WSError(Exception): - pass - -class WSNotFound(WSError): - pass - -class WSTimeout(WSError): - pass - -class WSHTTPError(WSError): - pass - - - class SimaFM(): """ """ root_url = 'http://{host}/{version}/'.format(**LFM) cache = {} timestamp = datetime.utcnow() - #ratelimit = None + name = 'Last.fm' + ratelimit = None def __init__(self, cache=True): self.artist = None @@ -137,9 +125,7 @@ class SimaFM(): # Construct URL self._fetch(payload) for art in self.current_element.get('similarartists').get('artist'): - match = 100 * float(art.get('match')) - yield Artist(mbid=art.get('mbid', None), - name=art.get('name')), match + yield Artist(name=art.get('name'), mbid=art.get('mbid', None)) # VIM MODLINE diff --git a/sima/lib/webservice.py b/sima/lib/webservice.py new file mode 100644 index 0000000..0937bde --- /dev/null +++ b/sima/lib/webservice.py @@ -0,0 +1,354 @@ +# -*- coding: utf-8 -*- +""" +Fetching similar artists from last.fm web services +""" + +# standard library import +import random + +from collections import deque +from hashlib import md5 + +# third parties components + +# local import +from .plugin import Plugin +from .track import Track +from .meta import Artist +from ..utils.utils import WSError + +def cache(func): + """Caching decorator""" + def wrapper(*args, **kwargs): + #pylint: disable=W0212,C0111 + cls = args[0] + similarities = [art for art in args[1]] + hashedlst = md5(''.join(similarities).encode('utf-8')).hexdigest() + if hashedlst in cls._cache.get('asearch'): + cls.log.debug('cached request') + results = cls._cache.get('asearch').get(hashedlst) + else: + results = func(*args, **kwargs) + cls.log.debug('caching request') + cls._cache.get('asearch').update({hashedlst:list(results)}) + random.shuffle(results) + return results + return wrapper + + +class WebService(Plugin): + """similar artists webservice + """ + + def __init__(self, daemon): + Plugin.__init__(self, daemon) + self.daemon_conf = daemon.config + self.sdb = daemon.sdb + self.history = daemon.short_history + ## + self.to_add = list() + self._cache = None + self._flush_cache() + wrapper = { + 'track': self._track, + 'top': self._top, + 'album': self._album, + } + self.queue_mode = wrapper.get(self.plugin_conf.get('queue_mode')) + self.ws = None + + def _flush_cache(self): + """ + Both flushes and instanciates _cache + """ + name = self.__class__.__name__ + if isinstance(self._cache, dict): + self.log.info('{0}: Flushing cache!'.format(name)) + else: + self.log.info('{0}: Initialising cache!'.format(name)) + self._cache = { + 'asearch': dict(), + 'tsearch': dict(), + } + + def _cleanup_cache(self): + """Avoid bloated cache + """ + for _ , val in self._cache.items(): + if isinstance(val, dict): + while len(val) > 150: + val.popitem() + + def get_history(self, artist): + """Constructs list of Track for already played titles for an artist. + """ + duration = self.daemon_conf.getint('sima', 'history_duration') + tracks_from_db = self.sdb.get_history(duration=duration, artist=artist) + # Construct Track() objects list from database history + played_tracks = [Track(artist=tr[-1], album=tr[1], title=tr[2], + file=tr[3]) for tr in tracks_from_db] + return played_tracks + + def filter_track(self, tracks): + """ + Extract one unplayed track from a Track object list. + * not in history + * not already in the queue + * not blacklisted + """ + artist = tracks[0].artist + black_list = self.player.queue + self.to_add + not_in_hist = list(set(tracks) - set(self.get_history(artist=artist))) + if not not_in_hist: + self.log.debug('All tracks already played for "{}"'.format(artist)) + random.shuffle(not_in_hist) + #candidate = [ trk for trk in not_in_hist if trk not in black_list + #if not self.sdb.get_bl_track(trk, add_not=True)] + candidate = [] + for trk in [_ for _ in not_in_hist if _ not in black_list]: + if self.sdb.get_bl_track(trk, add_not=True): + self.log.info('Blacklisted: {0}: '.format(trk)) + continue + if self.sdb.get_bl_album(trk, add_not=True): + self.log.info('Blacklisted album: {0}: '.format(trk)) + continue + # Should use albumartist heuristic as well + if self.plugin_conf.getboolean('single_album'): + if (trk.album == self.player.current.album or + trk.album in [tr.album for tr in self.to_add]): + self.log.debug('Found unplayed track ' + + 'but from an album already queued: %s' % (trk)) + continue + candidate.append(trk) + if not candidate: + self.log.debug('Unable to find title to add' + + ' for "%s".' % artist) + return None + self.to_add.append(random.choice(candidate)) + + def _get_artists_list_reorg(self, alist): + """ + Move around items in artists_list in order to play first not recently + played artists + """ + # TODO: move to utils as a decorator + duration = self.daemon_conf.getint('sima', 'history_duration') + art_in_hist = list() + for trk in self.sdb.get_history(duration=duration, + artists=alist): + if trk[0] not in art_in_hist: + art_in_hist.append(trk[0]) + art_in_hist.reverse() + art_not_in_hist = [ ar for ar in alist if ar not in art_in_hist ] + random.shuffle(art_not_in_hist) + art_not_in_hist.extend(art_in_hist) + self.log.debug('history ordered: {}'.format( + ' / '.join(art_not_in_hist))) + return art_not_in_hist + + @cache + def get_artists_from_player(self, similarities): + """ + Look in player library for availability of similar artists in + similarities + """ + dynamic = self.plugin_conf.getint('max_art') + if dynamic <= 0: + dynamic = 100 + results = list() + similarities.reverse() + while (len(results) < dynamic + and len(similarities) > 0): + art_pop = similarities.pop() + results.extend(self.player.fuzzy_find_artist(art_pop)) + return results + + def lfm_similar_artists(self, artist=None): + """ + Retrieve similar artists from WebServive. + """ + if artist is None: + curr = self.player.current.__dict__ + name = curr.get('artist') + mbid = curr.get('musicbrainz_artistid', None) + current = Artist(name=name, mbid=mbid) + else: + current = artist + # initialize artists deque list to construct from DB + as_art = deque() + as_artists = self.ws().get_similar(artist=current) + self.log.debug('Requesting {1} for "{0}"'.format(current, + self.ws.name)) + try: + # TODO: let's propagate Artist type + [as_art.append(str(art)) for art in as_artists] + except WSError as err: + self.log.warning('{0}: {1}'.format(self.ws.name, err)) + if as_art: + self.log.debug('Fetched {0} artist(s)'.format(len(as_art))) + if self.ws.ratelimit: + self.log.info('{0.name} ratelimit: {0.ratelimit}'.format(self.ws)) + return as_art + + def get_recursive_similar_artist(self): + ret_extra = list() + history = deque(self.history) + history.popleft() + depth = 0 + current = self.player.current + extra_arts = list() + while depth < self.plugin_conf.getint('depth'): + if len(history) == 0: + break + trk = history.popleft() + if (trk.artist in [trk.artist for trk in extra_arts] + or trk.artist == current.artist): + continue + extra_arts.append(trk) + depth += 1 + self.log.info('EXTRA ARTS: {}'.format( + '/'.join([trk.artist for trk in extra_arts]))) + for artist in extra_arts: + self.log.debug('Looking for artist similar to "{0.artist}" as well'.format(artist)) + similar = self.lfm_similar_artists(artist=artist) + if not similar: + return ret_extra + ret_extra.extend(self.get_artists_from_player(similar)) + if current.artist in ret_extra: + ret_extra.remove(current.artist) + return ret_extra + + def get_local_similar_artists(self): + """Check against local player for similar artists + """ + current = self.player.current + self.log.info('Looking for artist similar to "{0.artist}"'.format(current)) + similar = self.lfm_similar_artists() + if not similar: + self.log.info('Got nothing from {0}!'.format(self.ws.name)) + return [] + self.log.info('First five similar artist(s): {}...'.format( + ' / '.join([a for a in list(similar)[0:5]]))) + self.log.info('Looking availability in music library') + ret = self.get_artists_from_player(similar) + ret_extra = None + if len(self.history) >= 2: + if self.plugin_conf.getint('depth') > 1: + ret_extra = self.get_recursive_similar_artist() + if ret_extra: + ret = list(set(ret) | set(ret_extra)) + if not ret: + self.log.warning('Got nothing from music library.') + self.log.warning('Try running in debug mode to guess why...') + return [] + self.log.info('Got {} artists in library'.format(len(ret))) + self.log.info(' / '.join(ret)) + # Move around similars items to get in unplayed|not recently played + # artist first. + return self._get_artists_list_reorg(ret) + + def _get_album_history(self, artist=None): + """Retrieve album history""" + duration = self.daemon_conf.getint('sima', 'history_duration') + albums_list = set() + for trk in self.sdb.get_history(artist=artist, duration=duration): + albums_list.add(trk[1]) + return albums_list + + def find_album(self, artists): + """Find albums to queue. + """ + self.to_add = list() + nb_album_add = 0 + target_album_to_add = self.plugin_conf.getint('album_to_add') + for artist in artists: + self.log.info('Looking for an album to add for "%s"...' % artist) + albums = self.player.find_albums(artist) + # str conversion while Album type is not propagated + albums = [ str(album) for album in albums] + if albums: + self.log.debug('Albums candidate: {0:s}'.format(' / '.join(albums))) + else: continue + # albums yet in history for this artist + albums = set(albums) + albums_yet_in_hist = albums & self._get_album_history(artist=artist) + albums_not_in_hist = list(albums - albums_yet_in_hist) + # Get to next artist if there are no unplayed albums + if not albums_not_in_hist: + self.log.info('No album found for "%s"' % artist) + continue + album_to_queue = str() + random.shuffle(albums_not_in_hist) + for album in albums_not_in_hist: + tracks = self.player.find_album(artist, album) + # Look if one track of the album is already queued + # Good heuristic, at least enough to guess if the whole album is + # already queued. + if tracks[0] in self.player.queue: + self.log.debug('"%s" already queued, skipping!' % + tracks[0].album) + continue + album_to_queue = album + if not album_to_queue: + self.log.info('No album found for "%s"' % artist) + continue + self.log.info('{2} album candidate: {0} - {1}'.format( + artist, album_to_queue, self.ws.name)) + nb_album_add += 1 + self.to_add.extend(self.player.find_album(artist, album_to_queue)) + if nb_album_add == target_album_to_add: + return True + + def _track(self): + """Get some tracks for track queue mode + """ + artists = self.get_local_similar_artists() + nbtracks_target = self.plugin_conf.getint('track_to_add') + for artist in artists: + self.log.debug('Trying to find titles to add for "{}"'.format( + artist)) + found = self.player.find_track(artist) + # find tracks not in history for artist + self.filter_track(found) + if len(self.to_add) == nbtracks_target: + break + if not self.to_add: + self.log.debug('Found no tracks to queue, is your ' + + 'history getting too large?') + return None + for track in self.to_add: + self.log.info('{1} candidates: {0!s}'.format(track, self.ws.name)) + + def _album(self): + """Get albums for album queue mode + """ + artists = self.get_local_similar_artists() + self.find_album(artists) + + def _top(self): + """Get some tracks for top track queue mode + """ + #artists = self.get_local_similar_artists() + pass + + def callback_need_track(self): + self._cleanup_cache() + if not self.player.current: + self.log.info('No current track, cannot queue') + return None + if not self.player.current.artist: + self.log.warning('No artist set for the current track') + self.log.debug(repr(self.player.current)) + return None + self.queue_mode() + candidates = self.to_add + self.to_add = list() + if self.plugin_conf.get('queue_mode') != 'album': + random.shuffle(candidates) + return candidates + + def callback_player_database(self): + self._flush_cache() + +# VIM MODLINE +# vim: ai ts=4 sw=4 sts=4 expandtab diff --git a/sima/plugins/internal/echonest.py b/sima/plugins/internal/echonest.py index a5c1ede..74dd2b8 100644 --- a/sima/plugins/internal/echonest.py +++ b/sima/plugins/internal/echonest.py @@ -4,350 +4,21 @@ Fetching similar artists from echonest web services """ # standard library import -import random - -from collections import deque -from hashlib import md5 # third parties components # local import -from ...lib.plugin import Plugin -from ...lib.simaecho import SimaEch, EchoError -from ...lib.track import Track -from ...lib.meta import Artist - - -def cache(func): - """Caching decorator""" - def wrapper(*args, **kwargs): - #pylint: disable=W0212,C0111 - cls = args[0] - similarities = [art for art in args[1]] - hashedlst = md5(''.join(similarities).encode('utf-8')).hexdigest() - if hashedlst in cls._cache.get('asearch'): - cls.log.debug('cached request') - results = cls._cache.get('asearch').get(hashedlst) - else: - results = func(*args, **kwargs) - cls.log.debug('caching request') - cls._cache.get('asearch').update({hashedlst:list(results)}) - random.shuffle(results) - return results - return wrapper +from ...lib.simaecho import SimaEch +from ...lib.webservice import WebService -class EchoNest(Plugin): - """Echonest autoqueue plugin http://the.echonest.com/ +class EchoNest(WebService): + """last.fm similar artists """ def __init__(self, daemon): - Plugin.__init__(self, daemon) - self.daemon_conf = daemon.config - self.sdb = daemon.sdb - self.history = daemon.short_history - ## - self.to_add = list() - self._cache = None - self._flush_cache() - wrapper = { - 'track': self._track, - 'top': self._top, - 'album': self._album, - } - self.queue_mode = wrapper.get(self.plugin_conf.get('queue_mode')) - - def _flush_cache(self): - """ - Both flushes and instanciates _cache - """ - name = self.__class__.__name__ - if isinstance(self._cache, dict): - self.log.info('{0}: Flushing cache!'.format(name)) - else: - self.log.info('{0}: Initialising cache!'.format(name)) - self._cache = { - 'asearch': dict(), - 'tsearch': dict(), - } - - def _cleanup_cache(self): - """Avoid bloated cache - """ - for _ , val in self._cache.items(): - if isinstance(val, dict): - while len(val) > 150: - val.popitem() - - def get_history(self, artist): - """Constructs list of Track for already played titles for an artist. - """ - duration = self.daemon_conf.getint('sima', 'history_duration') - tracks_from_db = self.sdb.get_history(duration=duration, artist=artist) - # Construct Track() objects list from database history - played_tracks = [Track(artist=tr[-1], album=tr[1], title=tr[2], - file=tr[3]) for tr in tracks_from_db] - return played_tracks - - def filter_track(self, tracks): - """ - Extract one unplayed track from a Track object list. - * not in history - * not already in the queue - * not blacklisted - """ - artist = tracks[0].artist - black_list = self.player.queue + self.to_add - not_in_hist = list(set(tracks) - set(self.get_history(artist=artist))) - if not not_in_hist: - self.log.debug('All tracks already played for "{}"'.format(artist)) - random.shuffle(not_in_hist) - #candidate = [ trk for trk in not_in_hist if trk not in black_list - #if not self.sdb.get_bl_track(trk, add_not=True)] - candidate = [] - for trk in [_ for _ in not_in_hist if _ not in black_list]: - if self.sdb.get_bl_track(trk, add_not=True): - self.log.info('Blacklisted: {0}: '.format(trk)) - continue - if self.sdb.get_bl_album(trk, add_not=True): - self.log.info('Blacklisted album: {0}: '.format(trk)) - continue - # Should use albumartist heuristic as well - if self.plugin_conf.getboolean('single_album'): - if (trk.album == self.player.current.album or - trk.album in [tr.album for tr in self.to_add]): - self.log.debug('Found unplayed track ' + - 'but from an album already queued: %s' % (trk)) - continue - candidate.append(trk) - if not candidate: - self.log.debug('Unable to find title to add' + - ' for "%s".' % artist) - return None - self.to_add.append(random.choice(candidate)) - - def _get_artists_list_reorg(self, alist): - """ - Move around items in artists_list in order to play first not recently - played artists - """ - # TODO: move to utils as a decorator - duration = self.daemon_conf.getint('sima', 'history_duration') - art_in_hist = list() - for trk in self.sdb.get_history(duration=duration, - artists=alist): - if trk[0] not in art_in_hist: - art_in_hist.append(trk[0]) - art_in_hist.reverse() - art_not_in_hist = [ ar for ar in alist if ar not in art_in_hist ] - random.shuffle(art_not_in_hist) - art_not_in_hist.extend(art_in_hist) - self.log.debug('history ordered: {}'.format( - ' / '.join(art_not_in_hist))) - return art_not_in_hist - - @cache - def get_artists_from_player(self, similarities): - """ - Look in player library for availability of similar artists in - similarities - """ - dynamic = self.plugin_conf.getint('dynamic') - if dynamic <= 0: - dynamic = 100 - similarity = self.plugin_conf.getint('similarity') - results = list() - while (len(results) < dynamic - and len(similarities) > 0): - art_pop = similarities.pop() - results.extend(self.player.fuzzy_find_artist(art_pop)) - return results - - def lfm_similar_artists(self, artist=None): - """ - Retrieve similar artists on echonest server. - """ - if artist is None: - curr = self.player.current.__dict__ - name = curr.get('artist') - mbid = curr.get('musicbrainz_artistid', None) - current = Artist(name=name, mbid=mbid) - else: - current = artist - simaech = SimaEch() - # initialize artists deque list to construct from DB - as_art = deque() - as_artists = simaech.get_similar(artist=current) - self.log.debug('Requesting EchoNest for "{0}"'.format(current)) - try: - # TODO: let's propagate Artist type - [as_art.append(str(art)) for art in as_artists] - except EchoError as err: - self.log.warning('EchoNest: {0}'.format(err)) - if as_art: - self.log.debug('Fetched {0} artist(s)'.format(len(as_art))) - self.log.debug('x-ratelimit-remaining: {}'.format(SimaEch.ratelimit)) - return as_art - - def get_recursive_similar_artist(self): - ret_extra = list() - history = deque(self.history) - history.popleft() - depth = 0 - current = self.player.current - extra_arts = list() - while depth < self.plugin_conf.getint('depth'): - if len(history) == 0: - break - trk = history.popleft() - if (trk.artist in [trk.artist for trk in extra_arts] - or trk.artist == current.artist): - continue - extra_arts.append(trk) - depth += 1 - self.log.info('EXTRA ARTS: {}'.format( - '/'.join([trk.artist for trk in extra_arts]))) - for artist in extra_arts: - self.log.debug('Looking for artist similar to "{0.artist}" as well'.format(artist)) - similar = self.lfm_similar_artists(artist=artist) - if not similar: - return ret_extra - ret_extra.extend(self.get_artists_from_player(similar)) - if current.artist in ret_extra: - ret_extra.remove(current.artist) - return ret_extra - - def get_local_similar_artists(self): - """Check against local player for similar artists fetched from echonest - """ - current = self.player.current - self.log.info('Looking for artist similar to "{0.artist}"'.format(current)) - similar = list(self.lfm_similar_artists()) - if not similar: - self.log.info('Got nothing from echonest!') - return [] - self.log.info('First five similar artist(s): {}...'.format( - ' / '.join([a for a in similar[0:5]]))) - self.log.info('Looking availability in music library') - ret = self.get_artists_from_player(similar) - ret_extra = None - if len(self.history) >= 2: - if self.plugin_conf.getint('depth') > 1: - ret_extra = self.get_recursive_similar_artist() - if ret_extra: - ret = list(set(ret) | set(ret_extra)) - if not ret: - self.log.warning('Got nothing from music library.') - self.log.warning('Try running in debug mode to guess why...') - return [] - self.log.info('Got {} artists in library'.format(len(ret))) - self.log.info(' / '.join(ret)) - # Move around similars items to get in unplayed|not recently played - # artist first. - return self._get_artists_list_reorg(ret) - - def _get_album_history(self, artist=None): - """Retrieve album history""" - duration = self.daemon_conf.getint('sima', 'history_duration') - albums_list = set() - for trk in self.sdb.get_history(artist=artist, duration=duration): - albums_list.add(trk[1]) - return albums_list - - def find_album(self, artists): - """Find albums to queue. - """ - self.to_add = list() - nb_album_add = 0 - target_album_to_add = self.plugin_conf.getint('album_to_add') - for artist in artists: - self.log.info('Looking for an album to add for "%s"...' % artist) - albums = self.player.find_albums(artist) - # str conversion while Album type is not propagated - albums = [ str(album) for album in albums] - if albums: - self.log.debug('Albums candidate: {0:s}'.format(' / '.join(albums))) - else: continue - # albums yet in history for this artist - albums = set(albums) - albums_yet_in_hist = albums & self._get_album_history(artist=artist) - albums_not_in_hist = list(albums - albums_yet_in_hist) - # Get to next artist if there are no unplayed albums - if not albums_not_in_hist: - self.log.info('No album found for "%s"' % artist) - continue - album_to_queue = str() - random.shuffle(albums_not_in_hist) - for album in albums_not_in_hist: - tracks = self.player.find_album(artist, album) - # Look if one track of the album is already queued - # Good heuristic, at least enough to guess if the whole album is - # already queued. - if tracks[0] in self.player.queue: - self.log.debug('"%s" already queued, skipping!' % - tracks[0].album) - continue - album_to_queue = album - if not album_to_queue: - self.log.info('No album found for "%s"' % artist) - continue - self.log.info('echonest album candidates: {0} - {1}'.format( - artist, album_to_queue)) - nb_album_add += 1 - self.to_add.extend(self.player.find_album(artist, album_to_queue)) - if nb_album_add == target_album_to_add: - return True - - def _track(self): - """Get some tracks for track queue mode - """ - artists = self.get_local_similar_artists() - nbtracks_target = self.plugin_conf.getint('track_to_add') - for artist in artists: - self.log.debug('Trying to find titles to add for "{}"'.format( - artist)) - found = self.player.find_track(artist) - # find tracks not in history for artist - self.filter_track(found) - if len(self.to_add) == nbtracks_target: - break - if not self.to_add: - self.log.debug('Found no tracks to queue, is your ' + - 'history getting too large?') - return None - for track in self.to_add: - self.log.info('echonest candidates: {0!s}'.format(track)) - - def _album(self): - """Get albums for album queue mode - """ - artists = self.get_local_similar_artists() - self.find_album(artists) - - def _top(self): - """Get some tracks for top track queue mode - """ - #artists = self.get_local_similar_artists() - pass - - def callback_need_track(self): - self._cleanup_cache() - if not self.player.current: - self.log.info('No current track, cannot queue') - return None - if not self.player.current.artist: - self.log.warning('No artist set for the current track') - self.log.debug(repr(self.player.current)) - return None - self.queue_mode() - candidates = self.to_add - self.to_add = list() - if self.plugin_conf.get('queue_mode') != 'album': - random.shuffle(candidates) - return candidates - - def callback_player_database(self): - self._flush_cache() + WebService.__init__(self, daemon) + self.ws = SimaEch # VIM MODLINE # vim: ai ts=4 sw=4 sts=4 expandtab diff --git a/sima/plugins/internal/lastfm.py b/sima/plugins/internal/lastfm.py index 9ca859f..b4f56c2 100644 --- a/sima/plugins/internal/lastfm.py +++ b/sima/plugins/internal/lastfm.py @@ -4,355 +4,22 @@ Fetching similar artists from last.fm web services """ # standard library import -import random - -from collections import deque -from hashlib import md5 # third parties components # local import -from ...lib.plugin import Plugin -from ...lib.simafm import SimaFM, WSError -from ...lib.track import Track -from ...lib.meta import Artist - - -def cache(func): - """Caching decorator""" - def wrapper(*args, **kwargs): - #pylint: disable=W0212,C0111 - cls = args[0] - similarities = [art for art, _ in args[1]] - hashedlst = md5(''.join(similarities).encode('utf-8')).hexdigest() - if hashedlst in cls._cache.get('asearch'): - cls.log.debug('cached request') - results = cls._cache.get('asearch').get(hashedlst) - else: - results = func(*args, **kwargs) - cls.log.debug('caching request') - cls._cache.get('asearch').update({hashedlst:list(results)}) - random.shuffle(results) - return results - return wrapper +from ...lib.simafm import SimaFM +from ...lib.webservice import WebService -class Lastfm(Plugin): +class Lastfm(WebService): """last.fm similar artists """ def __init__(self, daemon): - Plugin.__init__(self, daemon) - self.daemon_conf = daemon.config - self.sdb = daemon.sdb - self.history = daemon.short_history - ## - self.to_add = list() - self._cache = None - self._flush_cache() - wrapper = { - 'track': self._track, - 'top': self._top, - 'album': self._album, - } - self.queue_mode = wrapper.get(self.plugin_conf.get('queue_mode')) - - def _flush_cache(self): - """ - Both flushes and instanciates _cache - """ - name = self.__class__.__name__ - if isinstance(self._cache, dict): - self.log.info('{0}: Flushing cache!'.format(name)) - else: - self.log.info('{0}: Initialising cache!'.format(name)) - self._cache = { - 'asearch': dict(), - 'tsearch': dict(), - } - - def _cleanup_cache(self): - """Avoid bloated cache - """ - for _ , val in self._cache.items(): - if isinstance(val, dict): - while len(val) > 150: - val.popitem() - - def get_history(self, artist): - """Constructs list of Track for already played titles for an artist. - """ - duration = self.daemon_conf.getint('sima', 'history_duration') - tracks_from_db = self.sdb.get_history(duration=duration, artist=artist) - # Construct Track() objects list from database history - played_tracks = [Track(artist=tr[-1], album=tr[1], title=tr[2], - file=tr[3]) for tr in tracks_from_db] - return played_tracks - - def filter_track(self, tracks): - """ - Extract one unplayed track from a Track object list. - * not in history - * not already in the queue - * not blacklisted - """ - artist = tracks[0].artist - black_list = self.player.queue + self.to_add - not_in_hist = list(set(tracks) - set(self.get_history(artist=artist))) - if not not_in_hist: - self.log.debug('All tracks already played for "{}"'.format(artist)) - random.shuffle(not_in_hist) - #candidate = [ trk for trk in not_in_hist if trk not in black_list - #if not self.sdb.get_bl_track(trk, add_not=True)] - candidate = [] - for trk in [_ for _ in not_in_hist if _ not in black_list]: - if self.sdb.get_bl_track(trk, add_not=True): - self.log.info('Blacklisted: {0}: '.format(trk)) - continue - if self.sdb.get_bl_album(trk, add_not=True): - self.log.info('Blacklisted album: {0}: '.format(trk)) - continue - # Should use albumartist heuristic as well - if self.plugin_conf.getboolean('single_album'): - if (trk.album == self.player.current.album or - trk.album in [tr.album for tr in self.to_add]): - self.log.debug('Found unplayed track ' + - 'but from an album already queued: %s' % (trk)) - continue - candidate.append(trk) - if not candidate: - self.log.debug('Unable to find title to add' + - ' for "%s".' % artist) - return None - self.to_add.append(random.choice(candidate)) - - def _get_artists_list_reorg(self, alist): - """ - Move around items in artists_list in order to play first not recently - played artists - """ - # TODO: move to utils as a decorator - duration = self.daemon_conf.getint('sima', 'history_duration') - art_in_hist = list() - for trk in self.sdb.get_history(duration=duration, - artists=alist): - if trk[0] not in art_in_hist: - art_in_hist.append(trk[0]) - art_in_hist.reverse() - art_not_in_hist = [ ar for ar in alist if ar not in art_in_hist ] - random.shuffle(art_not_in_hist) - art_not_in_hist.extend(art_in_hist) - self.log.debug('history ordered: {}'.format( - ' / '.join(art_not_in_hist))) - return art_not_in_hist - - @cache - def get_artists_from_player(self, similarities): - """ - Look in player library for availability of similar artists in - similarities - """ - dynamic = self.plugin_conf.getint('dynamic') - if dynamic <= 0: - dynamic = 100 - similarity = self.plugin_conf.getint('similarity') - results = list() - similarities.reverse() - while (len(results) < dynamic - and len(similarities) > 0): - art_pop, match = similarities.pop() - if match < similarity: - break - results.extend(self.player.fuzzy_find_artist(art_pop)) - results and self.log.debug('Similarity: %d%%' % match) # pylint: disable=w0106 - return results - - def lfm_similar_artists(self, artist=None): - """ - Retrieve similar artists on last.fm server. - """ - if artist is None: - curr = self.player.current.__dict__ - name = curr.get('artist') - mbid = curr.get('musicbrainz_artistid', None) - current = Artist(name=name, mbid=mbid) - else: - current = artist - simafm = SimaFM() - # initialize artists deque list to construct from DB - as_art = deque() - as_artists = simafm.get_similar(artist=current) - self.log.debug('Requesting last.fm for "{0}"'.format(current)) - try: - # TODO: let's propagate Artist type - [as_art.append((str(a), m)) for a, m in as_artists] - except WSError as err: - self.log.warning('Last.fm: {0}'.format(err)) - if as_art: - self.log.debug('Fetched {0} artist(s)'.format(len(as_art))) - return as_art - - def get_recursive_similar_artist(self): - ret_extra = list() - history = deque(self.history) - history.popleft() - depth = 0 - current = self.player.current - extra_arts = list() - while depth < self.plugin_conf.getint('depth'): - if len(history) == 0: - break - trk = history.popleft() - if (trk.artist in [trk.artist for trk in extra_arts] - or trk.artist == current.artist): - continue - extra_arts.append(trk) - depth += 1 - self.log.info('EXTRA ARTS: {}'.format( - '/'.join([trk.artist for trk in extra_arts]))) - for artist in extra_arts: - self.log.debug('Looking for artist similar to "{0.artist}" as well'.format(artist)) - similar = self.lfm_similar_artists(artist=artist) - if not similar: - return ret_extra - similar = sorted(similar, key=lambda sim: sim[1], reverse=True) - ret_extra.extend(self.get_artists_from_player(similar)) - if current.artist in ret_extra: - ret_extra.remove(current.artist) - return ret_extra - - def get_local_similar_artists(self): - """Check against local player for similar artists fetched from last.fm - """ - current = self.player.current - self.log.info('Looking for artist similar to "{0.artist}"'.format(current)) - similar = self.lfm_similar_artists() - if not similar: - self.log.info('Got nothing from last.fm!') - return [] - similar = sorted(similar, key=lambda sim: sim[1], reverse=True) - self.log.info('First five similar artist(s): {}...'.format( - ' / '.join([a for a, _ in similar[0:5]]))) - self.log.info('Looking availability in music library') - ret = self.get_artists_from_player(similar) - ret_extra = None - if len(self.history) >= 2: - if self.plugin_conf.getint('depth') > 1: - ret_extra = self.get_recursive_similar_artist() - if ret_extra: - ret = list(set(ret) | set(ret_extra)) - if not ret: - self.log.warning('Got nothing from music library.') - self.log.warning('Try running in debug mode to guess why...') - return [] - self.log.info('Got {} artists in library'.format(len(ret))) - self.log.info(' / '.join(ret)) - # Move around similars items to get in unplayed|not recently played - # artist first. - return self._get_artists_list_reorg(ret) - - def _get_album_history(self, artist=None): - """Retrieve album history""" - duration = self.daemon_conf.getint('sima', 'history_duration') - albums_list = set() - for trk in self.sdb.get_history(artist=artist, duration=duration): - albums_list.add(trk[1]) - return albums_list - - def find_album(self, artists): - """Find albums to queue. - """ - self.to_add = list() - nb_album_add = 0 - target_album_to_add = self.plugin_conf.getint('album_to_add') - for artist in artists: - self.log.info('Looking for an album to add for "%s"...' % artist) - albums = self.player.find_albums(artist) - # str conversion while Album type is not propagated - albums = [ str(album) for album in albums] - if albums: - self.log.debug('Albums candidate: {0:s}'.format(' / '.join(albums))) - else: continue - # albums yet in history for this artist - albums = set(albums) - albums_yet_in_hist = albums & self._get_album_history(artist=artist) - albums_not_in_hist = list(albums - albums_yet_in_hist) - # Get to next artist if there are no unplayed albums - if not albums_not_in_hist: - self.log.info('No album found for "%s"' % artist) - continue - album_to_queue = str() - random.shuffle(albums_not_in_hist) - for album in albums_not_in_hist: - tracks = self.player.find_album(artist, album) - # Look if one track of the album is already queued - # Good heuristic, at least enough to guess if the whole album is - # already queued. - if tracks[0] in self.player.queue: - self.log.debug('"%s" already queued, skipping!' % - tracks[0].album) - continue - album_to_queue = album - if not album_to_queue: - self.log.info('No album found for "%s"' % artist) - continue - self.log.info('last.fm album candidate: {0} - {1}'.format( - artist, album_to_queue)) - nb_album_add += 1 - self.to_add.extend(self.player.find_album(artist, album_to_queue)) - if nb_album_add == target_album_to_add: - return True - - def _track(self): - """Get some tracks for track queue mode - """ - artists = self.get_local_similar_artists() - nbtracks_target = self.plugin_conf.getint('track_to_add') - for artist in artists: - self.log.debug('Trying to find titles to add for "{}"'.format( - artist)) - found = self.player.find_track(artist) - # find tracks not in history for artist - self.filter_track(found) - if len(self.to_add) == nbtracks_target: - break - if not self.to_add: - self.log.debug('Found no tracks to queue, is your ' + - 'history getting too large?') - return None - for track in self.to_add: - self.log.info('last.fm candidates: {0!s}'.format(track)) - - def _album(self): - """Get albums for album queue mode - """ - artists = self.get_local_similar_artists() - self.find_album(artists) - - def _top(self): - """Get some tracks for top track queue mode - """ - #artists = self.get_local_similar_artists() - pass - - def callback_need_track(self): - self._cleanup_cache() - if not self.player.current: - self.log.info('No current track, cannot queue') - return None - if not self.player.current.artist: - self.log.warning('No artist set for the current track') - self.log.debug(repr(self.player.current)) - return None - self.queue_mode() - candidates = self.to_add - self.to_add = list() - if self.plugin_conf.get('queue_mode') != 'album': - random.shuffle(candidates) - return candidates + WebService.__init__(self, daemon) + self.ws = SimaFM - def callback_player_database(self): - self._flush_cache() # VIM MODLINE # vim: ai ts=4 sw=4 sts=4 expandtab diff --git a/sima/utils/config.py b/sima/utils/config.py index dbc29d6..99fb322 100644 --- a/sima/utils/config.py +++ b/sima/utils/config.py @@ -62,16 +62,15 @@ DEFAULT_CONF = { }, 'echonest': { 'queue_mode': "track", #TODO control values - 'dynamic': "15", + 'max_art': "15", 'single_album': "false", 'track_to_add': "1", 'album_to_add': "1", 'depth': "1", }, 'lastfm': { - 'dynamic': "10", - 'similarity': "15", 'queue_mode': "track", #TODO control values + 'max_art': "10", 'single_album': "false", 'track_to_add': "1", 'album_to_add': "1", diff --git a/sima/utils/utils.py b/sima/utils/utils.py index 93bac25..d0edcd4 100644 --- a/sima/utils/utils.py +++ b/sima/utils/utils.py @@ -185,5 +185,21 @@ class Cache(): def get(self): return self.elem + +# http client exceptions (for webservices) + +class WSError(Exception): + pass + +class WSNotFound(WSError): + pass + +class WSTimeout(WSError): + pass + +class WSHTTPError(WSError): + pass + + # VIM MODLINE # vim: ai ts=4 sw=4 sts=4 expandtab