]> kaliko git repositories - mpd-sima.git/blob - sima/lib/webserv.py
ca6b1f095b9b2d6499c9a384ca73218a0f348338
[mpd-sima.git] / sima / lib / webserv.py
1 # -*- coding: utf-8 -*-
2 # Copyright (c) 2009-2020 kaliko <kaliko@azylum.org>
3 # Copyright (c) 2019 sacha <sachahony@gmail.com>
4 #
5 #  This file is part of sima
6 #
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.
11 #
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.
16 #
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/>.
19 #
20 #
21 """
22 Fetching similar artists from last.fm web services
23 """
24
25 # standard library import
26 import random
27
28 from collections import deque
29 from hashlib import md5
30
31 # third parties components
32
33 # local import
34 from .plugin import AdvancedPlugin
35 from .meta import Artist, MetaContainer
36 from ..utils.utils import WSError, WSNotFound, WSTimeout
37
38
39 def cache(func):
40     """Caching decorator"""
41     def wrapper(*args, **kwargs):
42         #pylint: disable=W0212,C0111
43         cls = args[0]
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)
49         else:
50             results = func(*args, **kwargs)
51             cls.log.debug('caching request')
52             cls._cache.get('asearch').update({hashedlst: list(results)})
53         random.shuffle(results)
54         return results
55     return wrapper
56
57
58 class WebService(AdvancedPlugin):
59     """similar artists webservice
60     """
61
62     def __init__(self, daemon):
63         super().__init__(daemon)
64         self.history = daemon.short_history
65         ##
66         self._cache = None
67         self._flush_cache()
68         wrapper = {'track': self._track,
69                    'top': self._top,
70                    'album': self._album}
71         self.queue_mode = wrapper.get(self.plugin_conf.get('queue_mode'))
72         self.ws = None
73         self.ws_retry = 0
74
75     def _flush_cache(self):
76         """
77         Both flushes and instanciates _cache
78         """
79         name = self.__class__.__name__
80         if isinstance(self._cache, dict):
81             self.log.info('%s: Flushing cache!', name)
82         else:
83             self.log.info('%s: Initialising cache!', name)
84         self._cache = {'asearch': dict(),
85                        'tsearch': dict()}
86
87     def _cleanup_cache(self):
88         """Avoid bloated cache
89         """
90         for _, val in self._cache.items():
91             if isinstance(val, dict):
92                 while len(val) > 150:
93                     val.popitem()
94
95     @cache
96     def get_artists_from_player(self, similarities):
97         """
98         Look in player library for availability of similar artists in
99         similarities
100         """
101         dynamic = self.plugin_conf.getint('max_art')
102         if dynamic <= 0:
103             dynamic = 100
104         results = list()
105         similarities.reverse()
106         while (len(results) < dynamic and similarities):
107             art_pop = similarities.pop()
108             res = self.player.search_artist(art_pop)
109             if res:
110                 results.append(res)
111         return results
112
113     def ws_similar_artists(self, artist):
114         """
115         Retrieve similar artists from WebServive.
116         """
117         # initialize artists deque list to construct from DB
118         as_art = deque()
119         as_artists = self.ws.get_similar(artist=artist)
120         self.log.debug('Requesting %s for %r', self.ws.name, artist)
121         try:
122             [as_art.append(art) for art in as_artists]
123         except WSNotFound as err:
124             self.log.warning('%s: %s', self.ws.name, err)
125             if artist.mbid:
126                 self.log.debug('Trying without MusicBrainzID')
127                 try:
128                     return self.ws_similar_artists(Artist(name=artist.name))
129                 except WSNotFound as err:
130                     self.log.debug('%s: %s', self.ws.name, err)
131         except WSTimeout as err:
132             self.log.warning('%s: %s', self.ws.name, err)
133             if self.ws_retry < 3:
134                 self.ws_retry += 1
135                 self.log.warning('%s: retrying', self.ws.name)
136                 as_art = self.ws_similar_artists(artist)
137             else:
138                 self.log.warning('%s: stop retrying', self.ws.name)
139             self.ws_retry = 0
140         except WSError as err:
141             self.log.warning('%s: %s', self.ws.name, err)
142         if as_art:
143             self.log.debug('Fetched %d artist(s)', len(as_art))
144         return as_art
145
146     def get_recursive_similar_artist(self):
147         """Check against local player for similar artists (recursive w/ history)
148         """
149         if not self.player.playlist:
150             return []
151         history = list(self.history)
152         # In random play mode use complete playlist to filter
153         if self.player.playmode.get('random'):
154             history = self.player.playlist + history
155         else:
156             history = self.player.queue + history
157         history = deque(history)
158         last_trk = history.popleft()  # remove
159         extra_arts = list()
160         ret_extra = list()
161         depth = 0
162         while depth < self.plugin_conf.getint('depth'):
163             if not history:
164                 break
165             trk = history.popleft()
166             if (trk.Artist in extra_arts
167                     or trk.Artist == last_trk.Artist):
168                 continue
169             extra_arts.append(trk.Artist)
170             depth += 1
171         self.log.debug('EXTRA ARTS: %s', '/'.join(map(str, extra_arts)))
172         for artist in extra_arts:
173             self.log.debug('Looking for artist similar '
174                            'to "%s" as well', artist)
175             similar = self.ws_similar_artists(artist=artist)
176             if not similar:
177                 continue
178             ret_extra.extend(self.get_artists_from_player(similar))
179
180         if last_trk.Artist in ret_extra:
181             ret_extra.remove(last_trk.Artist)
182         if ret_extra:
183             self.log.debug('similar artist(s) found: %s',
184                            ' / '.join(map(str, MetaContainer(ret_extra))))
185         return ret_extra
186
187     def get_local_similar_artists(self):
188         """Check against local player for similar artists
189         """
190         if not self.player.playlist:
191             return []
192         tolookfor = self.player.playlist[-1].Artist
193         self.log.info('Looking for artist similar to "%s"', tolookfor)
194         self.log.debug('%r', tolookfor)
195         similar = self.ws_similar_artists(tolookfor)
196         if not similar:
197             self.log.info('Got nothing from %s!', self.ws.name)
198             return []
199         self.log.info('First five similar artist(s): %s...',
200                       ' / '.join(map(str, list(similar)[:5])))
201         self.log.info('Looking availability in music library')
202         ret = MetaContainer(self.get_artists_from_player(similar))
203         if ret:
204             self.log.debug('regular found in library: %s',
205                            ' / '.join(map(str, ret)))
206         else:
207             self.log.debug('Got nothing similar from library!')
208         ret_extra = None
209         if len(self.history) >= 2:
210             if self.plugin_conf.getint('depth') > 1:
211                 ret_extra = self.get_recursive_similar_artist()
212         if ret_extra:
213             # get them reorg to pick up best element
214             ret_extra = self.get_reorg_artists_list(ret_extra)
215             # tries to pickup less artist from extra art
216             if len(ret) > 4:
217                 ret_extra = MetaContainer(ret_extra[:max(4, len(ret))//2])
218             if ret_extra:
219                 self.log.debug('extra found in library: %s',
220                                ' / '.join(map(str, ret_extra)))
221             ret = ret | ret_extra
222         if not ret:
223             self.log.warning('Got nothing from music library.')
224             return []
225         # In random play mode use complete playlist to filter
226         if self.player.playmode.get('random'):
227             queued_artists = MetaContainer([trk.Artist for trk
228                                             in self.player.playlist])
229         else:
230             queued_artists = MetaContainer([trk.Artist for trk
231                                             in self.player.queue])
232         self.log.trace('Already queued: %s', queued_artists)
233         self.log.trace('Candidate: %s', ret)
234         if ret & queued_artists:
235             self.log.debug('Removing already queued artists: '
236                            '%s', '/'.join(map(str, ret & queued_artists)))
237             ret = ret - queued_artists
238         current = self.player.current
239         if current and current.Artist in ret:
240             self.log.debug('Removing current artist: %s', current.Artist)
241             ret = ret - MetaContainer([current.Artist])
242         # Move around similars items to get in unplayed|not recently played
243         # artist first.
244         self.log.info('Got %d artists in library', len(ret))
245         candidates = self.get_reorg_artists_list(ret)
246         if candidates:
247             self.log.info(' / '.join(map(str, candidates)))
248         return candidates
249
250     def find_album(self, artists):
251         """Find albums to queue.
252         """
253         to_add = list()
254         nb_album_add = 0
255         target_album_to_add = self.plugin_conf.getint('album_to_add')
256         for artist in artists:
257             album = self.album_candidate(artist, unplayed=True)
258             if not album:
259                 continue
260             nb_album_add += 1
261             candidates = self.player.find_tracks(album)
262             if self.plugin_conf.getboolean('shuffle_album'):
263                 random.shuffle(candidates)
264             # this allows to select a maximum number of track from the album
265             # a value of 0 (default) means keep all
266             nbtracks = self.plugin_conf.getint('track_to_add_from_album')
267             if nbtracks > 0:
268                 candidates = candidates[0:nbtracks]
269             to_add.extend(candidates)
270             if nb_album_add == target_album_to_add:
271                 return to_add
272
273     def find_top(self, artists):
274         """
275         find top tracks for artists in artists list.
276         """
277         to_add = list()
278         nbtracks_target = self.plugin_conf.getint('track_to_add')
279         for artist in artists:
280             if len(to_add) == nbtracks_target:
281                 return to_add
282             self.log.info('Looking for a top track for %s', artist)
283             titles = deque()
284             try:
285                 titles = [t for t in self.ws.get_toptrack(artist)]
286             except WSError as err:
287                 self.log.warning('%s: %s', self.ws.name, err)
288                 continue
289             for trk in titles:
290                 found = self.player.search_track(artist, trk.title)
291                 if found:
292                     random.shuffle(found)
293                     top_trk = self.filter_track(found, to_add)
294                     if top_trk:
295                         to_add.append(top_trk)
296                         break
297
298     def _track(self):
299         """Get some tracks for track queue mode
300
301         :return: list of Tracks
302         """
303         to_add = []
304         artists = self.get_local_similar_artists()
305         nbtracks_target = self.plugin_conf.getint('track_to_add')
306         for artist in artists:
307             self.log.debug('Trying to find titles to add for "%r"', artist)
308             found = self.player.find_tracks(artist)
309             if not found:
310                 self.log.debug('Found nothing to queue for %s', artist)
311                 continue
312             random.shuffle(found)
313             # find tracks not in history for artist
314             track_candidate = self.filter_track(found, to_add)
315             if track_candidate:
316                 to_add.append(track_candidate)
317                 self.log.info('%s plugin chose: %s',
318                               self.ws.name, track_candidate)
319             if len(to_add) == nbtracks_target:
320                 break
321         return to_add
322
323     def _album(self):
324         """Get albums for album queue mode
325
326         :return: list of Tracks
327         """
328         artists = self.get_local_similar_artists()
329         return self.find_album(artists)
330
331     def _top(self):
332         """Get some tracks for top track queue mode
333
334         :return: list of Tracks
335         """
336         artists = self.get_local_similar_artists()
337         chosen = self.find_top(artists)
338         for track in chosen:
339             self.log.info('%s candidates: %s', self.ws.name, track)
340         return chosen
341
342     def callback_need_track(self):
343         self._cleanup_cache()
344         if not self.player.playlist:
345             self.log.info('No last track, cannot queue')
346             return None
347         if not self.player.playlist[-1].artist:
348             self.log.warning('No artist set for the last track in queue')
349             self.log.debug(repr(self.player.current))
350             return None
351         candidates = self.queue_mode()
352         msg = ' '.join(['{0}: {1:>3d}'.format(k, v) for
353                         k, v in sorted(self.ws.stats.items())])
354         self.log.debug('http stats: ' + msg)
355         if not candidates:
356             self.log.info('%s plugin found nothing to queue', self.ws.name)
357         if self.plugin_conf.get('queue_mode') != 'album':
358             random.shuffle(candidates)
359         return candidates
360
361     def callback_player_database(self):
362         self._flush_cache()
363
364 # VIM MODLINE
365 # vim: ai ts=4 sw=4 sts=4 expandtab