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